From 2ec4ba629ef8db5c22c4539e7b758e428ee43075 Mon Sep 17 00:00:00 2001 From: dotta Date: Thu, 26 Mar 2026 16:49:11 -0500 Subject: [PATCH] Add mail-client keyboard shortcuts to inbox mine tab j/k navigate up/down, a to archive, U to mark unread, r to mark read, Enter to open. Includes server-side DELETE /issues/:id/read endpoint for mark-unread support on issues. Co-Authored-By: Paperclip --- server/src/routes/issues.ts | 32 ++++++ server/src/services/issues.ts | 14 +++ ui/src/api/issues.ts | 1 + ui/src/hooks/useInboxBadge.ts | 11 ++- ui/src/pages/Inbox.tsx | 179 +++++++++++++++++++++++++++++++--- 5 files changed, 221 insertions(+), 16 deletions(-) diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 11ec162c..1ff2840d 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -713,6 +713,38 @@ export function issueRoutes(db: Db, storage: StorageService) { res.json(readState); }); + router.delete("/issues/:id/read", async (req, res) => { + const id = req.params.id as string; + const issue = await svc.getById(id); + if (!issue) { + res.status(404).json({ error: "Issue not found" }); + return; + } + assertCompanyAccess(req, issue.companyId); + if (req.actor.type !== "board") { + res.status(403).json({ error: "Board authentication required" }); + return; + } + if (!req.actor.userId) { + res.status(403).json({ error: "Board user context required" }); + return; + } + const removed = await svc.markUnread(issue.companyId, issue.id, req.actor.userId); + const actor = getActorInfo(req); + await logActivity(db, { + companyId: issue.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "issue.read_unmarked", + entityType: "issue", + entityId: issue.id, + details: { userId: req.actor.userId }, + }); + res.json({ id: issue.id, removed }); + }); + router.post("/issues/:id/inbox-archive", async (req, res) => { const id = req.params.id as string; const issue = await svc.getById(id); diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 086f4658..70652535 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -791,6 +791,20 @@ export function issueService(db: Db) { return row; }, + markUnread: async (companyId: string, issueId: string, userId: string) => { + const deleted = await db + .delete(issueReadStates) + .where( + and( + eq(issueReadStates.companyId, companyId), + eq(issueReadStates.issueId, issueId), + eq(issueReadStates.userId, userId), + ), + ) + .returning(); + return deleted.length > 0; + }, + archiveInbox: async (companyId: string, issueId: string, userId: string, archivedAt: Date = new Date()) => { const now = new Date(); const [row] = await db diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 436c6dfd..9b19baa4 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -53,6 +53,7 @@ export const issuesApi = { deleteLabel: (id: string) => api.delete(`/labels/${id}`), get: (id: string) => api.get(`/issues/${id}`), markRead: (id: string) => api.post<{ id: string; lastReadAt: Date }>(`/issues/${id}/read`, {}), + markUnread: (id: string) => api.delete<{ id: string; removed: boolean }>(`/issues/${id}/read`), archiveFromInbox: (id: string) => api.post<{ id: string; archivedAt: Date }>(`/issues/${id}/inbox-archive`, {}), unarchiveFromInbox: (id: string) => diff --git a/ui/src/hooks/useInboxBadge.ts b/ui/src/hooks/useInboxBadge.ts index 50f4323d..6b7daa2b 100644 --- a/ui/src/hooks/useInboxBadge.ts +++ b/ui/src/hooks/useInboxBadge.ts @@ -64,7 +64,16 @@ export function useReadInboxItems() { }); }; - return { readItems, markRead }; + const markUnread = (id: string) => { + setReadItems((prev) => { + const next = new Set(prev); + next.delete(id); + saveReadInboxItems(next); + return next; + }); + }; + + return { readItems, markRead, markUnread }; } export function useInboxBadge(companyId: string | null | undefined) { diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 9e6fa39e..8a503092 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -1,4 +1,4 @@ -import { type ReactNode, useEffect, useMemo, useState } from "react"; +import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Link, useLocation, useNavigate } from "@/lib/router"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { approvalsApi } from "../api/approvals"; @@ -52,6 +52,7 @@ import { saveLastInboxTab, shouldShowInboxSection, type InboxTab, + type InboxWorkItem, } from "../lib/inbox"; import { useDismissedInboxItems, useReadInboxItems } from "../hooks/useInboxBadge"; @@ -512,7 +513,7 @@ export function Inbox() { const [allCategoryFilter, setAllCategoryFilter] = useState("everything"); const [allApprovalFilter, setAllApprovalFilter] = useState("all"); const { dismissed, dismiss } = useDismissedInboxItems(); - const { readItems, markRead: markItemRead } = useReadInboxItems(); + const { readItems, markRead: markItemRead, markUnread: markItemUnread } = useReadInboxItems(); const pathSegment = location.pathname.split("/").pop() ?? "mine"; const tab: InboxTab = @@ -540,6 +541,7 @@ export function Inbox() { useEffect(() => { saveLastInboxTab(tab); + setSelectedIndex(-1); }, [tab]); const { @@ -793,6 +795,8 @@ export function Inbox() { const [archivingIssueIds, setArchivingIssueIds] = useState>(new Set()); const [fadingNonIssueItems, setFadingNonIssueItems] = useState>(new Set()); const [archivingNonIssueIds, setArchivingNonIssueIds] = useState>(new Set()); + const [selectedIndex, setSelectedIndex] = useState(-1); + const listRef = useRef(null); const invalidateInboxIssueQueries = () => { if (!selectedCompanyId) return; @@ -875,7 +879,14 @@ export function Inbox() { }, }); - const handleMarkNonIssueRead = (key: string) => { + const markUnreadMutation = useMutation({ + mutationFn: (id: string) => issuesApi.markUnread(id), + onSuccess: () => { + invalidateInboxIssueQueries(); + }, + }); + + const handleMarkNonIssueRead = useCallback((key: string) => { setFadingNonIssueItems((prev) => new Set(prev).add(key)); markItemRead(key); setTimeout(() => { @@ -885,9 +896,9 @@ export function Inbox() { return next; }); }, 300); - }; + }, [markItemRead]); - const handleArchiveNonIssue = (key: string) => { + const handleArchiveNonIssue = useCallback((key: string) => { setArchivingNonIssueIds((prev) => new Set(prev).add(key)); setTimeout(() => { dismiss(key); @@ -897,7 +908,7 @@ export function Inbox() { return next; }); }, 200); - }; + }, [dismiss]); const nonIssueUnreadState = (key: string): NonIssueUnreadState => { if (tab !== "mine") return null; @@ -908,6 +919,133 @@ export function Inbox() { return "hidden"; }; + const getWorkItemKey = useCallback((item: InboxWorkItem): string => { + if (item.kind === "issue") return `issue:${item.issue.id}`; + if (item.kind === "approval") return `approval:${item.approval.id}`; + if (item.kind === "failed_run") return `run:${item.run.id}`; + return `join:${item.joinRequest.id}`; + }, []); + + // Reset selection when the list changes + useEffect(() => { + setSelectedIndex((prev) => + prev >= workItemsToRender.length ? workItemsToRender.length - 1 : prev, + ); + }, [workItemsToRender.length]); + + // Keyboard shortcuts (mail-client style) + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Don't capture when typing in inputs/textareas or with modifier keys + const target = e.target as HTMLElement; + if ( + target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.tagName === "SELECT" || + target.isContentEditable || + e.metaKey || + e.ctrlKey || + e.altKey + ) { + return; + } + + const itemCount = workItemsToRender.length; + if (itemCount === 0) return; + + const isMineTab = tab === "mine"; + + switch (e.key) { + case "j": { + e.preventDefault(); + setSelectedIndex((prev) => Math.min(prev + 1, itemCount - 1)); + break; + } + case "k": { + e.preventDefault(); + setSelectedIndex((prev) => Math.max(prev - 1, 0)); + break; + } + case "a": { + if (!isMineTab || selectedIndex < 0 || selectedIndex >= itemCount) return; + e.preventDefault(); + const item = workItemsToRender[selectedIndex]; + const key = getWorkItemKey(item); + if (item.kind === "issue") { + if (!archivingIssueIds.has(item.issue.id)) { + archiveIssueMutation.mutate(item.issue.id); + } + } else { + if (!archivingNonIssueIds.has(key)) { + handleArchiveNonIssue(key); + } + } + break; + } + case "U": { + if (selectedIndex < 0 || selectedIndex >= itemCount) return; + e.preventDefault(); + const item = workItemsToRender[selectedIndex]; + if (item.kind === "issue") { + markUnreadMutation.mutate(item.issue.id); + } else { + const key = getWorkItemKey(item); + markItemUnread(key); + } + break; + } + case "r": { + if (selectedIndex < 0 || selectedIndex >= itemCount) return; + e.preventDefault(); + const item = workItemsToRender[selectedIndex]; + if (item.kind === "issue") { + if (item.issue.isUnreadForMe && !fadingOutIssues.has(item.issue.id)) { + markReadMutation.mutate(item.issue.id); + } + } else { + const key = getWorkItemKey(item); + if (!readItems.has(key)) { + handleMarkNonIssueRead(key); + } + } + break; + } + case "Enter": { + if (selectedIndex < 0 || selectedIndex >= itemCount) return; + e.preventDefault(); + const item = workItemsToRender[selectedIndex]; + if (item.kind === "issue") { + const pathId = item.issue.identifier ?? item.issue.id; + navigate(`/issues/${pathId}`, { state: issueLinkState }); + } else if (item.kind === "approval") { + navigate(`/approvals/${item.approval.id}`); + } else if (item.kind === "failed_run") { + navigate(`/agents/${item.run.agentId}/runs/${item.run.id}`); + } + break; + } + default: + return; + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [ + workItemsToRender, selectedIndex, tab, navigate, issueLinkState, + getWorkItemKey, archivingIssueIds, archivingNonIssueIds, + fadingOutIssues, readItems, + archiveIssueMutation, markReadMutation, markUnreadMutation, + handleArchiveNonIssue, handleMarkNonIssueRead, markItemUnread, + ]); + + // Scroll selected item into view + useEffect(() => { + if (selectedIndex < 0 || !listRef.current) return; + const rows = listRef.current.querySelectorAll("[data-inbox-item]"); + const row = rows[selectedIndex]; + if (row) row.scrollIntoView({ block: "nearest" }); + }, [selectedIndex]); + if (!selectedCompanyId) { return ; } @@ -1050,8 +1188,18 @@ export function Inbox() { <> {showSeparatorBefore("work_items") && }
-
+
{workItemsToRender.flatMap((item, index) => { + const wrapItem = (key: string, isSelected: boolean, child: ReactNode) => ( +
setSelectedIndex(index)} + > + {child} +
+ ); const todayCutoff = Date.now() - 24 * 60 * 60 * 1000; const showTodayDivider = index > 0 && @@ -1070,6 +1218,7 @@ export function Inbox() { ); } const isMineTab = tab === "mine"; + const isSelected = selectedIndex === index; if (item.kind === "approval") { const approvalKey = `approval:${item.approval.id}`; @@ -1093,7 +1242,7 @@ export function Inbox() { } /> ); - elements.push(isMineTab ? ( + elements.push(wrapItem(approvalKey, isSelected, isMineTab ? ( {row} - ) : row); + ) : row)); return elements; } @@ -1129,7 +1278,7 @@ export function Inbox() { } /> ); - elements.push(isMineTab ? ( + elements.push(wrapItem(runKey, isSelected, isMineTab ? ( {row} - ) : row); + ) : row)); return elements; } @@ -1162,7 +1311,7 @@ export function Inbox() { } /> ); - elements.push(isMineTab ? ( + elements.push(wrapItem(joinKey, isSelected, isMineTab ? ( {row} - ) : row); + ) : row)); return elements; } @@ -1232,7 +1381,7 @@ export function Inbox() { /> ); - elements.push(isMineTab ? ( + elements.push(wrapItem(`issue:${issue.id}`, isSelected, isMineTab ? ( {row} - ) : row); + ) : row)); return elements; })}