From dd9915b43ba31c92b0330597a0bfa8345acbc9b6 Mon Sep 17 00:00:00 2001 From: Adam Higgins Date: Wed, 30 Sep 2026 23:35:54 +0100 Subject: [PATCH] feat(web): attention inbox for threads that need you Lists threads across every environment, project and chat tab that are waiting on an approval or an answer, failed, or finished unread, one entry per thread with its most urgent reason. Opening an entry lands on that exact tab. Derived client-side from thread shells and the existing read markers; the derivation lives in client-runtime. Surfaces: a sidebar header button with a count and popover, a "Needs attention" command palette view, and the attentionInbox.open keybinding (mod+alt+n). Failures and completions can be marked read; the thread menu's Mark unread brings a completion back. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/components/CommandPalette.logic.ts | 5 +- apps/web/src/components/CommandPalette.tsx | 99 ++++++++++- apps/web/src/components/Sidebar.logic.ts | 10 +- apps/web/src/components/Sidebar.tsx | 2 + .../sidebar/SidebarAttentionInbox.tsx | 164 +++++++++++++++++ .../sidebar/SidebarThreadHeader.tsx | 6 +- apps/web/src/hooks/useAttentionInbox.ts | 27 +++ docs/fork-differences.md | 21 +++ docs/user/thread-sidebar.md | 10 ++ packages/client-runtime/package.json | 4 + .../src/state/attentionInbox.test.ts | 167 ++++++++++++++++++ .../src/state/attentionInbox.ts | 124 +++++++++++++ packages/contracts/src/keybindings.ts | 1 + packages/shared/src/keybindings.ts | 1 + 14 files changed, 628 insertions(+), 13 deletions(-) create mode 100644 apps/web/src/components/sidebar/SidebarAttentionInbox.tsx create mode 100644 apps/web/src/hooks/useAttentionInbox.ts create mode 100644 packages/client-runtime/src/state/attentionInbox.test.ts create mode 100644 packages/client-runtime/src/state/attentionInbox.ts diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index b75ce84d14690..6bc0d464fb292 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -61,7 +61,7 @@ export function browseInputEndPaddingClass(input: { export type SearchOverlayMode = "command" | "files" | "content"; export type CommandPaletteOpenIntent = - | { readonly kind: "add-project" | "new-thread-in" | "change-theme" } + | { readonly kind: "add-project" | "new-thread-in" | "change-theme" | "attention-inbox" } | { readonly kind: "search"; readonly query: string; @@ -85,6 +85,7 @@ export type CommandPaletteUiAction = | { readonly _tag: "OpenAddProject" } | { readonly _tag: "OpenNewThreadIn" } | { readonly _tag: "OpenChangeTheme" } + | { readonly _tag: "OpenAttentionInbox" } | { readonly _tag: "ClearOpenIntent" }; export function reduceCommandPaletteUiState( @@ -116,6 +117,8 @@ export function reduceCommandPaletteUiState( return { open: true, mode: "command", openIntent: { kind: "new-thread-in" } }; case "OpenChangeTheme": return { open: true, mode: "command", openIntent: { kind: "change-theme" } }; + case "OpenAttentionInbox": + return { open: true, mode: "command", openIntent: { kind: "attention-inbox" } }; case "ClearOpenIntent": return state.openIntent ? { ...state, openIntent: null } : state; } diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 59988506580a9..fae640676dada 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -55,6 +55,7 @@ import { FolderIcon, FolderPlusIcon, GitBranchIcon, + InboxIcon, LayersIcon, LinkIcon, MessageSquareIcon, @@ -83,6 +84,7 @@ import { useAtomValue } from "@effect/atom-react"; import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; +import { useAttentionInbox } from "../hooks/useAttentionInbox"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; @@ -138,6 +140,7 @@ import { } from "../lib/utils"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes"; +import { formatRelativeTimeLabel } from "../timestampFormat"; import { useAvailableSettingsSearchItems } from "./settings/useAvailableSettingsSearchItems"; import { applyWslEnvironmentConfiguration, @@ -175,6 +178,10 @@ import { CommandPaletteResults } from "./CommandPaletteResults"; import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon, ForgejoIcon } from "./Icons"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { ProjectFavicon } from "./ProjectFavicon"; +import { + ATTENTION_REASON_PRESENTATION, + useProjectTitleByKey, +} from "./sidebar/SidebarAttentionInbox"; import { ProjectFilePicker } from "./files/ProjectFilePicker"; import { openLinkPullRequestDialog } from "./pullRequest/LinkPullRequestDialog"; import { openTranscriptExportDialog } from "./TranscriptExportDialog"; @@ -573,6 +580,13 @@ export function CommandPalette({ children }: { children: ReactNode }) { }); return; } + if (command === "attentionInbox.open") { + event.preventDefault(); + event.stopPropagation(); + if (event.repeat) return; + dispatch({ _tag: "OpenAttentionInbox" }); + return; + } if (command === "usage.open") { event.preventDefault(); event.stopPropagation(); @@ -708,6 +722,8 @@ function OpenCommandPaletteDialog(props: { const navigate = useNavigate(); const pathname = useLocation({ select: (location) => location.pathname }); const { clearOpenIntent, openIntent, openOverlayMode, setOpen } = props; + const attentionEntries = useAttentionInbox(); + const attentionProjectTitleByKey = useProjectTitleByKey(); const [query, setQuery] = useState(openIntent?.kind === "search" ? openIntent.query : ""); const [linkedThreadSearch, setLinkedThreadSearch] = useState( openIntent?.kind === "search" ? openIntent : null, @@ -2164,6 +2180,81 @@ function OpenCommandPaletteDialog(props: { }); } + const attentionInboxItem: CommandPaletteSubmenuItem = { + kind: "submenu", + value: "action:attention-inbox", + searchTerms: [ + "needs attention", + "inbox", + "attention", + "approval", + "input", + "question", + "failed", + "completed", + "unread", + "review", + ], + title: "Needs attention", + ...(attentionEntries.length > 0 + ? { + titleTrailingContent: ( + + {attentionEntries.length} + + ), + } + : {}), + icon: , + addonIcon: , + shortcutCommand: "attentionInbox.open", + groups: [ + { + value: "attention-inbox", + label: "Needs attention", + items: attentionEntries.map((entry) => { + const { label, Icon, iconClassName } = ATTENTION_REASON_PRESENTATION[entry.reason]; + const projectTitle = attentionProjectTitleByKey.get( + `${entry.thread.environmentId}:${entry.thread.projectId}`, + ); + return { + kind: "action" as const, + value: `attention:${entry.key}`, + searchTerms: [entry.thread.title, label, projectTitle ?? ""], + title: entry.thread.title, + description: projectTitle ? `${label} · ${projectTitle}` : label, + timestamp: formatRelativeTimeLabel(entry.at), + icon: , + run: async () => { + // The entry's own route, not its tab group's last-opened tab. + await navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams( + scopeThreadRef(entry.thread.environmentId, entry.thread.id), + ), + }); + }, + }; + }), + }, + ], + }; + actionItems.push(attentionInboxItem); + + useLayoutEffect(() => { + if (openIntent?.kind !== "attention-inbox") return; + clearOpenIntent(); + browseNavigation.invalidate(); + cloneLookupGeneration.current += 1; + setIsRemoteProjectLookingUp(false); + setAddProjectCloneFlow(null); + setViewStack([]); + pushPaletteView({ + addonIcon: , + groups: [{ value: "attention-inbox", label: "Needs attention", items: [] }], + }); + }, [browseNavigation, clearOpenIntent, openIntent, pushPaletteView]); + actionItems.push({ kind: "action", value: "action:usage", @@ -2258,9 +2349,11 @@ function OpenCommandPaletteDialog(props: { ) : currentView?.groups[0]?.value === "themes" ? changeThemeItem.groups - : currentView?.groups[0]?.value === "appearance" - ? changeAppearanceItem.groups - : (currentView?.groups ?? rootGroups); + : currentView?.groups[0]?.value === "attention-inbox" + ? attentionInboxItem.groups + : currentView?.groups[0]?.value === "appearance" + ? changeAppearanceItem.groups + : (currentView?.groups ?? rootGroups); const filteredGroups = filterCommandPaletteGroups({ activeGroups, diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 2557ffd7f76ed..9e9d973ab8989 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -6,6 +6,7 @@ import { type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; +import { hasUnseenCompletion as hasUnseenTurnCompletion } from "@t3tools/client-runtime/state/attention-inbox"; import type { ContextMenuItem, EnvironmentId, ThreadId } from "@t3tools/contracts"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import type { AsyncResult } from "effect/unstable/reactivity"; @@ -646,14 +647,7 @@ export function useThreadJumpHintVisibility(): { } export function hasUnseenCompletion(thread: ThreadStatusInput): boolean { - if (!thread.latestTurn?.completedAt) return false; - const completedAt = Date.parse(thread.latestTurn.completedAt); - if (Number.isNaN(completedAt)) return false; - if (!thread.lastVisitedAt) return false; - - const lastVisitedAt = Date.parse(thread.lastVisitedAt); - if (Number.isNaN(lastVisitedAt)) return true; - return completedAt > lastVisitedAt; + return hasUnseenTurnCompletion(thread.latestTurn, thread.lastVisitedAt); } export function shouldClearThreadSelectionOnMouseDown(target: HTMLElement | null): boolean { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 783210ca96e9d..e8452bdd36823 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -248,6 +248,7 @@ import { } from "./ui/combobox"; import { SidebarContent, SidebarGroup, useSidebar } from "./ui/sidebar"; import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; +import { SidebarAttentionInbox } from "./sidebar/SidebarAttentionInbox"; import { SidebarHeaderIconButton, SidebarThreadHeader } from "./sidebar/SidebarThreadHeader"; import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuShortcut, MenuTrigger } from "./ui/menu"; import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; @@ -5070,6 +5071,7 @@ export default function Sidebar() { } + attentionInbox={} onNewProject={openAddProjectCommandPalette} onNewThread={handleNewThreadClick} newThreadDisabled={projects.length === 0} diff --git a/apps/web/src/components/sidebar/SidebarAttentionInbox.tsx b/apps/web/src/components/sidebar/SidebarAttentionInbox.tsx new file mode 100644 index 0000000000000..bf909f22f32fa --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarAttentionInbox.tsx @@ -0,0 +1,164 @@ +/** + * The sidebar's attention inbox: a header button that appears while any + * thread needs the user, and a popover listing those threads across every + * project, environment and tab group. Opening an entry lands on that exact + * chat tab. Failures and completions can be marked read here; the thread + * menu's Mark unread brings a completion back. + */ +import type { ThreadAttentionReason } from "@t3tools/client-runtime/state/attention-inbox"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { useRouter } from "@tanstack/react-router"; +import { + CheckIcon, + CircleAlertIcon, + CircleCheckIcon, + InboxIcon, + MessageCircleQuestionIcon, + ShieldQuestionIcon, + type LucideIcon, +} from "lucide-react"; +import { useMemo, useState } from "react"; + +import { useAttentionInbox, type WebAttentionInboxEntry } from "../../hooks/useAttentionInbox"; +import { useProjects } from "../../state/entities"; +import { buildThreadRouteParams } from "../../threadRoutes"; +import { formatRelativeTimeLabel } from "../../timestampFormat"; +import { cn } from "../../lib/utils"; +import { useUiStateStore } from "../../uiStateStore"; +import { Button } from "../ui/button"; +import { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from "../ui/popover"; +import { useSidebar } from "../ui/sidebar"; +import { SidebarHeaderIconButton } from "./SidebarThreadHeader"; + +export const ATTENTION_REASON_PRESENTATION: Record< + ThreadAttentionReason, + { readonly label: string; readonly Icon: LucideIcon; readonly iconClassName: string } +> = { + approval: { + label: "Approval needed", + Icon: ShieldQuestionIcon, + iconClassName: "text-warning-foreground", + }, + input: { + label: "Input needed", + Icon: MessageCircleQuestionIcon, + iconClassName: "text-info-foreground", + }, + failed: { label: "Failed", Icon: CircleAlertIcon, iconClassName: "text-destructive-foreground" }, + completed: { + label: "Completed", + Icon: CircleCheckIcon, + iconClassName: "text-success-foreground", + }, +}; + +/** Project titles keyed like thread shells, for the entries' second line. */ +export function useProjectTitleByKey(): ReadonlyMap { + const projects = useProjects(); + return useMemo( + () => + new Map(projects.map((project) => [`${project.environmentId}:${project.id}`, project.title])), + [projects], + ); +} + +export function SidebarAttentionInbox() { + const entries = useAttentionInbox(); + const [open, setOpen] = useState(false); + if (entries.length === 0 && !open) return null; + const label = `Needs attention (${entries.length})`; + + return ( + + }> + + {entries.length > 0 ? ( + + {entries.length > 99 ? "99+" : entries.length} + + ) : null} + + + setOpen(false)} /> + + + ); +} + +function AttentionInboxList(props: { + entries: ReadonlyArray; + onOpenEntry: () => void; +}) { + const router = useRouter(); + const { isMobile, setOpenMobile } = useSidebar(); + const markThreadVisited = useUiStateStore((state) => state.markThreadVisited); + const projectTitleByKey = useProjectTitleByKey(); + + return ( +
+ Needs attention + {props.entries.length === 0 ? ( +

Nothing needs you right now.

+ ) : ( +
    + {props.entries.map((entry) => { + const { label, Icon, iconClassName } = ATTENTION_REASON_PRESENTATION[entry.reason]; + const projectTitle = projectTitleByKey.get( + `${entry.thread.environmentId}:${entry.thread.projectId}`, + ); + const canMarkRead = entry.reason === "failed" || entry.reason === "completed"; + return ( +
  • + + {canMarkRead ? ( + + ) : null} +
  • + ); + })} +
+ )} +
+ ); +} diff --git a/apps/web/src/components/sidebar/SidebarThreadHeader.tsx b/apps/web/src/components/sidebar/SidebarThreadHeader.tsx index 0e955266ccd3d..fa06400e476cc 100644 --- a/apps/web/src/components/sidebar/SidebarThreadHeader.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadHeader.tsx @@ -29,8 +29,10 @@ export interface SidebarThreadHeaderProps { searchFieldRef?: RefObject; /** Without projects there is nothing to scope, so those controls stay out. */ hasProjects: boolean; - /** The project scope combobox, rendered as the first icon of the group. */ + /** The project scope combobox, rendered with the project icons of the group. */ projectScope: ReactNode; + /** Leads the icon group; renders nothing while no thread needs the user. */ + attentionInbox: ReactNode; onNewProject: () => void; /** Receives the click so Shift+click can skip the project picker. */ onNewThread: (event: ReactMouseEvent) => void; @@ -59,6 +61,7 @@ export function SidebarThreadHeader({ searchFieldRef, hasProjects, projectScope, + attentionInbox, onNewProject, onNewThread, newThreadDisabled, @@ -131,6 +134,7 @@ export function SidebarThreadHeader({ hover states, and a background well reads far louder on themed palettes than on the base light and dark ones. */}
+ {attentionInbox} {tabsToggle ? ( ; + +/** + * Threads across every environment, project and tab group that need the user, + * derived from the thread shells and this client's read markers. The minute + * tick lets snoozes that expire bring their threads back. + */ +export function useAttentionInbox(): ReadonlyArray { + const threads = useThreadShells(); + const lastVisitedAtByKey = useUiStateStore((state) => state.threadLastVisitedAtById); + const now = useNowMinute(); + return useMemo( + () => buildAttentionInbox(threads, { lastVisitedAtByKey, now }), + [lastVisitedAtByKey, now, threads], + ); +} diff --git a/docs/fork-differences.md b/docs/fork-differences.md index 684d8a0c916ed..1e18edcc5e67a 100644 --- a/docs/fork-differences.md +++ b/docs/fork-differences.md @@ -480,6 +480,27 @@ Code: `apps/server/src/mcp/query/`, the `agentAccessToken` handler in `apps/web/src/components/settings/AgentAccessSettings.tsx`. User guide: [agent-access.md](./user/agent-access.md). +## Attention inbox + +On web and desktop, an inbox button appears in the sidebar header while any thread needs you, with +a count. Its popover lists those threads across every environment, project, and chat tab, one +entry per thread with its most urgent reason: an approval, a question for you, a failed turn, or +finished work you have not opened yet. Clicking an entry opens that exact tab, not the tab its +sidebar row would return to. Failures and completions leave the inbox once you open the thread or +mark them read; **Mark unread** in the thread menu brings a completion back. Approvals and +questions stay until answered. Archived threads are left out, and so are snoozed ones until they +wake or raise their hand. The same list is **Needs attention** in the command palette +(`mod+alt+n`, `attentionInbox.open`). + +The inbox is derived on the client from thread shells and the client's existing read markers, so +it adds no server state or payload. Mobile does not show it, because the mobile client does not +track which threads you have read. + +Code: [`attentionInbox.ts`](../packages/client-runtime/src/state/attentionInbox.ts), +[`SidebarAttentionInbox.tsx`](../apps/web/src/components/sidebar/SidebarAttentionInbox.tsx), and +the `attention-inbox` view in [`CommandPalette.tsx`](../apps/web/src/components/CommandPalette.tsx). +User guide: [thread-sidebar.md](./user/thread-sidebar.md#see-what-needs-you). + ## Keeping this page current Update this page in the same change that adds, changes, or removes a user-visible fork-only diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 6d3148b022c16..b775d0c197f14 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -68,6 +68,16 @@ in a new thread's model picker to add or remove them. A regular click returns to single model. Choose a base branch and send. Each selection starts a separate thread and worktree while you stay in the new thread composer. This requires a Git project. +## See what needs you + +On web and desktop, the inbox button appears in the sidebar header while a thread needs you. It +lists threads from every project and chat tab that are waiting for an approval or an answer, that +failed, or that finished since you last opened them. Click an entry to open that exact tab. Use +the check mark to mark a failure or completion read; **Mark unread** in the thread menu brings a +completion back. Approvals and questions stay listed until you answer them. Snoozed threads stay +out until they wake or ask for you. Press `Cmd+Option+N` on macOS or `Ctrl+Alt+N` on Windows and +Linux, or search the command palette for **Needs attention**, to see the same list. + ## Pin and reorder threads Pin a thread from its menu to keep it above your active work. diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 500c73cccb284..d326b7733afb5 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -251,6 +251,10 @@ "types": "./src/state/threadSort.ts", "default": "./src/state/threadSort.ts" }, + "./state/attention-inbox": { + "types": "./src/state/attentionInbox.ts", + "default": "./src/state/attentionInbox.ts" + }, "./state/thread-settled": { "types": "./src/state/threadSettled.ts", "default": "./src/state/threadSettled.ts" diff --git a/packages/client-runtime/src/state/attentionInbox.test.ts b/packages/client-runtime/src/state/attentionInbox.test.ts new file mode 100644 index 0000000000000..742a9be5cc03d --- /dev/null +++ b/packages/client-runtime/src/state/attentionInbox.test.ts @@ -0,0 +1,167 @@ +import { EnvironmentId, ThreadId, TurnId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + buildAttentionInbox, + resolveThreadAttention, + type AttentionThread, +} from "./attentionInbox.ts"; + +const NOW = "2026-04-10T12:00:00.000Z"; +const VISITED = "2026-04-10T10:00:00.000Z"; +const AFTER_VISIT = "2026-04-10T11:00:00.000Z"; +const BEFORE_VISIT = "2026-04-10T09:00:00.000Z"; + +function makeThread( + id: string, + input: { + readonly environmentId?: string; + readonly pending?: "approval" | "input"; + readonly sessionStatus?: "running" | "ready" | "error"; + readonly sessionUpdatedAt?: string; + readonly turnState?: "completed" | "error" | "running"; + readonly turnCompletedAt?: string | null; + readonly updatedAt?: string; + readonly archivedAt?: string | null; + readonly snoozedUntil?: string | null; + readonly backgroundLiveness?: "working" | "monitoring" | null; + } = {}, +): AttentionThread { + const threadId = ThreadId.make(id); + return { + id: threadId, + environmentId: EnvironmentId.make(input.environmentId ?? "env-1"), + archivedAt: input.archivedAt ?? null, + updatedAt: input.updatedAt ?? AFTER_VISIT, + backgroundLiveness: input.backgroundLiveness ?? null, + snoozedUntil: input.snoozedUntil ?? null, + snoozedAt: input.snoozedUntil ? BEFORE_VISIT : null, + hasPendingApprovals: input.pending === "approval", + hasPendingUserInput: input.pending === "input", + session: + input.sessionStatus === undefined + ? null + : { + threadId, + status: input.sessionStatus, + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: input.sessionStatus === "error" ? "boom" : null, + updatedAt: input.sessionUpdatedAt ?? AFTER_VISIT, + }, + latestTurn: + input.turnCompletedAt === undefined + ? null + : { + turnId: TurnId.make(`${id}-turn`), + state: input.turnState ?? "completed", + requestedAt: BEFORE_VISIT, + startedAt: BEFORE_VISIT, + completedAt: input.turnCompletedAt, + assistantMessageId: null, + }, + }; +} + +const visitedEverything = (threads: ReadonlyArray) => + Object.fromEntries(threads.map((thread) => [`${thread.environmentId}:${thread.id}`, VISITED])); + +describe("resolveThreadAttention", () => { + it("prefers an approval over an unread completion on the same thread", () => { + const thread = makeThread("t", { pending: "approval", turnCompletedAt: AFTER_VISIT }); + expect(resolveThreadAttention(thread, VISITED)?.reason).toBe("approval"); + }); + + it("keeps requests even after the thread was visited", () => { + const thread = makeThread("t", { pending: "input", updatedAt: BEFORE_VISIT }); + expect(resolveThreadAttention(thread, VISITED)?.reason).toBe("input"); + }); + + it("reports a failure only until the thread is visited", () => { + const thread = makeThread("t", { + sessionStatus: "error", + turnState: "error", + turnCompletedAt: AFTER_VISIT, + }); + expect(resolveThreadAttention(thread, VISITED)).toEqual({ + reason: "failed", + at: AFTER_VISIT, + }); + expect(resolveThreadAttention(thread, NOW)).toBeNull(); + }); + + it("does not fall back to completed when a seen failure is dismissed", () => { + const thread = makeThread("t", { turnState: "error", turnCompletedAt: BEFORE_VISIT }); + expect(resolveThreadAttention(thread, VISITED)).toBeNull(); + }); + + it("ignores threads that are still working, including background work", () => { + expect( + resolveThreadAttention( + makeThread("t", { sessionStatus: "running", turnCompletedAt: AFTER_VISIT }), + VISITED, + ), + ).toBeNull(); + expect( + resolveThreadAttention( + makeThread("t", { backgroundLiveness: "monitoring", turnCompletedAt: AFTER_VISIT }), + VISITED, + ), + ).toBeNull(); + }); + + it("treats a missing visit marker as read, like the sidebar", () => { + const thread = makeThread("t", { turnCompletedAt: AFTER_VISIT }); + expect(resolveThreadAttention(thread, undefined)).toBeNull(); + expect(resolveThreadAttention(thread, VISITED)?.reason).toBe("completed"); + }); +}); + +describe("buildAttentionInbox", () => { + it("orders by urgency, then newest first, with one entry per thread", () => { + const threads = [ + makeThread("old-done", { turnCompletedAt: "2026-04-10T10:30:00.000Z" }), + makeThread("new-done", { turnCompletedAt: "2026-04-10T11:30:00.000Z" }), + makeThread("failed", { sessionStatus: "error" }), + makeThread("input", { pending: "input" }), + makeThread("approval", { pending: "approval", turnCompletedAt: AFTER_VISIT }), + makeThread("approval", { pending: "approval", turnCompletedAt: AFTER_VISIT }), + makeThread("quiet", { turnCompletedAt: BEFORE_VISIT }), + ]; + const inbox = buildAttentionInbox(threads, { + lastVisitedAtByKey: visitedEverything(threads), + now: NOW, + }); + expect(inbox.map((entry) => [entry.thread.id, entry.reason])).toEqual([ + ["approval", "approval"], + ["input", "input"], + ["failed", "failed"], + ["new-done", "completed"], + ["old-done", "completed"], + ]); + }); + + it("keeps same thread ids from different environments apart", () => { + const threads = [ + makeThread("t", { environmentId: "env-1", pending: "input" }), + makeThread("t", { environmentId: "env-2", pending: "input" }), + ]; + const inbox = buildAttentionInbox(threads, { lastVisitedAtByKey: {}, now: NOW }); + expect(inbox.map((entry) => entry.key)).toEqual(["env-1:t", "env-2:t"]); + }); + + it("leaves out archived and snoozed threads until a snoozed one raises its hand", () => { + const snoozeUntil = "2026-04-11T09:00:00.000Z"; + const threads = [ + makeThread("archived", { pending: "input", archivedAt: VISITED }), + makeThread("snoozed", { snoozedUntil: snoozeUntil, turnCompletedAt: BEFORE_VISIT }), + makeThread("snoozed-asks", { snoozedUntil: snoozeUntil, pending: "approval" }), + ]; + const inbox = buildAttentionInbox(threads, { + lastVisitedAtByKey: visitedEverything(threads), + now: NOW, + }); + expect(inbox.map((entry) => entry.thread.id)).toEqual(["snoozed-asks"]); + }); +}); diff --git a/packages/client-runtime/src/state/attentionInbox.ts b/packages/client-runtime/src/state/attentionInbox.ts new file mode 100644 index 0000000000000..c1601aab18330 --- /dev/null +++ b/packages/client-runtime/src/state/attentionInbox.ts @@ -0,0 +1,124 @@ +import type { EnvironmentId, OrchestrationThreadShell, ThreadId } from "@t3tools/contracts"; + +import { effectiveSnoozed, type ThreadSnoozeShell } from "./threadSettled.ts"; + +/** + * Why a thread needs the user now, most urgent first. The agent is blocked on + * the user for approval and input; failed and completed wait for a look. + */ +export type ThreadAttentionReason = "approval" | "input" | "failed" | "completed"; + +const REASON_RANK: Record = { + approval: 0, + input: 1, + failed: 2, + completed: 3, +}; + +export type AttentionThread = ThreadSnoozeShell & + Pick & { + readonly environmentId: EnvironmentId; + }; + +export interface ThreadAttention { + readonly reason: ThreadAttentionReason; + /** When the reason arose, as far as the shell can tell. Sorts the inbox. */ + readonly at: string; +} + +export interface AttentionInboxEntry extends ThreadAttention { + /** `environmentId:threadId`, the same key client read markers use. */ + readonly key: string; + readonly thread: TThread; +} + +function isAfterVisit(at: string | null | undefined, lastVisitedAt: string | undefined): boolean { + if (!at) return false; + const atMs = Date.parse(at); + if (Number.isNaN(atMs)) return false; + // A missing visit marker counts as read, so a new client does not light up + // every historical thread. A corrupt marker counts as never read. + if (!lastVisitedAt) return false; + const visitedMs = Date.parse(lastVisitedAt); + return Number.isNaN(visitedMs) || atMs > visitedMs; +} + +/** The latest turn finished after the client last looked at the thread. */ +export function hasUnseenCompletion( + latestTurn: OrchestrationThreadShell["latestTurn"], + lastVisitedAt: string | undefined, +): boolean { + return isAfterVisit(latestTurn?.completedAt, lastVisitedAt); +} + +/** + * The one reason a thread needs the user, or null. Mirrors the sidebar's + * status order: requests beat failure, and anything still running (including + * background work) is not waiting on anyone. Failures and completions clear + * once the thread is visited; requests clear only when answered. + */ +export function resolveThreadAttention( + thread: AttentionThread, + lastVisitedAt: string | undefined, +): ThreadAttention | null { + if (thread.hasPendingApprovals) return { reason: "approval", at: thread.updatedAt }; + if (thread.hasPendingUserInput) return { reason: "input", at: thread.updatedAt }; + const session = thread.session; + if (session?.status === "running" || session?.status === "starting") return null; + const turn = thread.latestTurn; + const failedAt = + turn?.state === "error" && turn.completedAt + ? turn.completedAt + : session?.status === "error" + ? session.updatedAt + : null; + if (failedAt !== null) { + return isAfterVisit(failedAt, lastVisitedAt) ? { reason: "failed", at: failedAt } : null; + } + if (thread.backgroundLiveness) return null; + if (turn?.completedAt && hasUnseenCompletion(turn, lastVisitedAt)) { + return { reason: "completed", at: turn.completedAt }; + } + return null; +} + +/** + * Threads that need the user now, one entry per thread, most urgent reason + * first and newest first within a reason. Archived threads and threads still + * snoozed are left out; a snoozed thread that raised its hand comes back. + */ +export function buildAttentionInbox( + threads: ReadonlyArray, + input: { + readonly lastVisitedAtByKey: Readonly>; + readonly now: string; + }, +): AttentionInboxEntry[] { + const entries = new Map>(); + for (const thread of threads) { + if (thread.archivedAt !== null) continue; + if (effectiveSnoozed(thread, { now: input.now })) continue; + const key = attentionThreadKey(thread.environmentId, thread.id); + const attention = resolveThreadAttention(thread, input.lastVisitedAtByKey[key]); + if (attention === null) continue; + const existing = entries.get(key); + if (existing && compareEntries(existing, attention) <= 0) continue; + entries.set(key, { key, thread, ...attention }); + } + return [...entries.values()].sort(compareEntries); +} + +function attentionThreadKey(environmentId: EnvironmentId, threadId: ThreadId): string { + return `${environmentId}:${threadId}`; +} + +function compareEntries(a: ThreadAttention, b: ThreadAttention): number { + const rank = REASON_RANK[a.reason] - REASON_RANK[b.reason]; + if (rank !== 0) return rank; + return timestampMs(b.at) - timestampMs(a.at); +} + +function timestampMs(value: string): number { + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? 0 : parsed; +} diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 4951b8e2afa9c..0302965f90de4 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -80,6 +80,7 @@ export const STATIC_KEYBINDING_COMMANDS = [ "filePicker.toggle", "projectSearch.toggle", "usage.open", + "attentionInbox.open", "theme.select", "appearance.cycle", "themeEditor.toggle", diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 6c82baf4df5d6..58b7b9899ea95 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -42,6 +42,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+p", command: "filePicker.toggle", when: "!terminalFocus" }, { key: "mod+shift+f", command: "projectSearch.toggle", when: "!terminalFocus" }, { key: "mod+u", command: "usage.open", when: "!terminalFocus" }, + { key: "mod+alt+n", command: "attentionInbox.open", when: "!terminalFocus" }, { key: "mod+alt+a", command: "theme.select", when: "!terminalFocus" }, { key: "mod+alt+shift+a", command: "appearance.cycle", when: "!terminalFocus" }, { key: "mod+alt+shift+t", command: "themeEditor.toggle" },