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 <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-03-26 16:49:11 -05:00
parent 182b459235
commit 2ec4ba629e
5 changed files with 221 additions and 16 deletions

View file

@ -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);

View file

@ -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

View file

@ -53,6 +53,7 @@ export const issuesApi = {
deleteLabel: (id: string) => api.delete<IssueLabel>(`/labels/${id}`),
get: (id: string) => api.get<Issue>(`/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) =>

View file

@ -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) {

View file

@ -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<InboxCategoryFilter>("everything");
const [allApprovalFilter, setAllApprovalFilter] = useState<InboxApprovalFilter>("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<Set<string>>(new Set());
const [fadingNonIssueItems, setFadingNonIssueItems] = useState<Set<string>>(new Set());
const [archivingNonIssueIds, setArchivingNonIssueIds] = useState<Set<string>>(new Set());
const [selectedIndex, setSelectedIndex] = useState<number>(-1);
const listRef = useRef<HTMLDivElement>(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 <EmptyState icon={InboxIcon} message="Select a company to view inbox." />;
}
@ -1050,8 +1188,18 @@ export function Inbox() {
<>
{showSeparatorBefore("work_items") && <Separator />}
<div>
<div className="overflow-hidden rounded-xl border border-border bg-card">
<div ref={listRef} className="overflow-hidden rounded-xl border border-border bg-card">
{workItemsToRender.flatMap((item, index) => {
const wrapItem = (key: string, isSelected: boolean, child: ReactNode) => (
<div
key={`sel-${key}`}
data-inbox-item
className={isSelected ? "ring-2 ring-inset ring-primary/40 rounded-sm" : ""}
onClick={() => setSelectedIndex(index)}
>
{child}
</div>
);
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 ? (
<SwipeToArchive
key={approvalKey}
disabled={isArchiving}
@ -1101,7 +1250,7 @@ export function Inbox() {
>
{row}
</SwipeToArchive>
) : row);
) : row));
return elements;
}
@ -1129,7 +1278,7 @@ export function Inbox() {
}
/>
);
elements.push(isMineTab ? (
elements.push(wrapItem(runKey, isSelected, isMineTab ? (
<SwipeToArchive
key={runKey}
disabled={isArchiving}
@ -1137,7 +1286,7 @@ export function Inbox() {
>
{row}
</SwipeToArchive>
) : row);
) : row));
return elements;
}
@ -1162,7 +1311,7 @@ export function Inbox() {
}
/>
);
elements.push(isMineTab ? (
elements.push(wrapItem(joinKey, isSelected, isMineTab ? (
<SwipeToArchive
key={joinKey}
disabled={isArchiving}
@ -1170,7 +1319,7 @@ export function Inbox() {
>
{row}
</SwipeToArchive>
) : row);
) : row));
return elements;
}
@ -1232,7 +1381,7 @@ export function Inbox() {
/>
);
elements.push(isMineTab ? (
elements.push(wrapItem(`issue:${issue.id}`, isSelected, isMineTab ? (
<SwipeToArchive
key={`issue:${issue.id}`}
disabled={isArchiving || archiveIssueMutation.isPending}
@ -1240,7 +1389,7 @@ export function Inbox() {
>
{row}
</SwipeToArchive>
) : row);
) : row));
return elements;
})}
</div>