From 7646111c17fac767a68be8ebd719e3b40a4fdcf6 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sat, 29 Aug 2026 13:00:56 -0400 Subject: [PATCH 1/4] ship: checkpoint before ship --- .../components/chat/AgentChatPane.test.tsx | 152 +++++++++++++++++- .../components/chat/AgentChatPane.tsx | 109 ++++++------- .../components/chat/ChatAwayDigestCard.tsx | 46 ++++++ .../chat/ChatLifecycleBanner.test.tsx | 18 +-- .../components/chat/ChatLifecycleBanner.tsx | 97 ++++------- .../chat/chatTranscriptRows.test.ts | 7 +- .../components/chat/chatTranscriptRows.ts | 12 +- .../terminals/SessionListPane.test.tsx | 41 +++++ .../components/terminals/SessionListPane.tsx | 14 +- .../work/SessionLifecycleChips.test.tsx | 56 +------ .../components/work/SessionLifecycleChips.tsx | 111 ++++--------- .../components/work/WorkSurfaceHeader.tsx | 14 +- .../src/renderer/lib/terminalAttention.ts | 33 ++++ 13 files changed, 429 insertions(+), 281 deletions(-) create mode 100644 apps/desktop/src/renderer/components/chat/ChatAwayDigestCard.tsx diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx index 772c648d6..7458536af 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx @@ -22,6 +22,7 @@ import type { PrSummary, TerminalSessionChangedEvent, TerminalSessionDetail, + TerminalSessionSummary, } from "../../../shared/types"; import { createDynamicCursorCliModelDescriptor, getModelById } from "../../../shared/modelRegistry"; import { invalidateAgentChatSessionListCache } from "../../lib/agentChatSessionListCache"; @@ -8450,11 +8451,160 @@ describe("AgentChatPane submit recovery", () => { renderPane(session); expect(await screen.findByText("Check PR CI")).toBeTruthy(); - expect(screen.queryByText(/While you were away:/)).toBeNull(); + expect(screen.queryByTestId("chat-away-digest")).toBeNull(); expect(window.localStorage.getItem(`ade.chat.lastViewed.v1:${session.sessionId}`)) .toBe(String(openedAtMs)); }); + it("shows unattended scheduled wakes as one compact review card", async () => { + const openedAtMs = Date.parse("2026-07-10T12:00:00.000Z"); + vi.spyOn(Date, "now").mockReturnValue(openedAtMs); + const session = buildSession("session-1", { title: "Scheduled work" }); + window.localStorage.setItem( + `ade.chat.lastViewed.v1:${session.sessionId}`, + String(Date.parse("2026-07-10T10:00:00.000Z")), + ); + const longOutcome = "Deployment completed after a very long diagnostic summary that should remain in the transcript instead of being crammed into the notice."; + installAdeMocks({ + sessions: [session], + eventHistory: { + sessionId: session.sessionId, + truncated: false, + sessionFound: true, + events: [ + { + sessionId: session.sessionId, + timestamp: "2026-07-10T10:30:00.000Z", + sequence: 1, + event: { + type: "user_message", + text: "Check CI", + deliveryState: "delivered", + turnId: "turn-wake-1", + metadata: { + scheduledWake: { + scheduleId: "wake-1", + kind: "wakeup", + firedAt: "2026-07-10T10:30:00.000Z", + reason: "Check CI", + }, + }, + }, + }, + { + sessionId: session.sessionId, + timestamp: "2026-07-10T10:31:00.000Z", + sequence: 2, + event: { type: "text", text: longOutcome, turnId: "turn-wake-1" }, + }, + { + sessionId: session.sessionId, + timestamp: "2026-07-10T11:30:00.000Z", + sequence: 3, + event: { + type: "user_message", + text: "Check deployment", + deliveryState: "delivered", + turnId: "turn-wake-2", + metadata: { + scheduledWake: { + scheduleId: "wake-2", + kind: "wakeup", + firedAt: "2026-07-10T11:30:00.000Z", + reason: "Check deployment", + }, + }, + }, + }, + ], + }, + }); + + renderPane(session); + + const digest = await screen.findByTestId("chat-away-digest"); + expect(digest.className).toContain("rounded-2xl"); + expect(digest.className).not.toContain("w-full"); + expect(within(digest).getByText("While you were away")).toBeTruthy(); + expect(within(digest).getByText("2 scheduled wakeups ran")).toBeTruthy(); + expect(within(digest).queryByText(longOutcome)).toBeNull(); + expect(screen.getByTestId("chat-composer-notice-overlay").contains(digest)).toBe(true); + + const review = within(digest).getByRole("button", { name: "Review" }); + expect(review.getAttribute("title")).toBe("First wakeup: Check CI"); + expect(within(digest).getAllByRole("button")).toHaveLength(2); + + fireEvent.click(within(digest).getByRole("button", { name: "Dismiss while-you-were-away summary" })); + await waitFor(() => expect(screen.queryByTestId("chat-away-digest")).toBeNull()); + }); + + it("does not reserve an empty notice row above a live app-panel composer", async () => { + const session = buildSession("session-1", { title: "Live app-control chat" }); + writeChatCompanionUiState(session.sessionId, { + ...DEFAULT_CHAT_COMPANION_UI_STATE, + appControlOpen: true, + }); + installAdeMocks({ sessions: [session] }); + + const { container } = renderPane(session); + + expect(await screen.findByPlaceholderText("Type to vibecode...")).toBeTruthy(); + expect(screen.queryByTestId("chat-lifecycle-banner")).toBeNull(); + expect(screen.queryByTestId("chat-app-panel-notice-stack")).toBeNull(); + const emptyPaddedNoticeRows = [...container.querySelectorAll("div")].filter((element) => + element.childElementCount === 0 + && element.classList.contains("items-center") + && element.classList.contains("py-1.5"), + ); + expect(emptyPaddedNoticeRows).toHaveLength(0); + }); + + it("centers a lifecycle pill above an app-panel composer", async () => { + const session = buildSession("session-1", { title: "Settled app-control chat" }); + writeChatCompanionUiState(session.sessionId, { + ...DEFAULT_CHAT_COMPANION_UI_STATE, + appControlOpen: true, + }); + const projectRoot = "/tmp/project-under-test"; + const settledSession: TerminalSessionSummary = { + id: session.sessionId, + laneId: session.laneId, + laneName: "Lane 1", + ptyId: null, + tracked: true, + pinned: false, + goal: null, + toolType: "codex-chat", + title: session.title ?? "Settled app-control chat", + status: "completed", + startedAt: "2026-07-10T10:00:00.000Z", + endedAt: "2026-07-10T11:00:00.000Z", + exitCode: 0, + transcriptPath: "", + headShaStart: null, + headShaEnd: null, + lastOutputPreview: null, + summary: null, + runtimeState: "exited", + resumeCommand: null, + settledAt: "2026-07-10T11:01:00.000Z", + }; + useAppStore.setState({ + project: { rootPath: projectRoot } as never, + projectBinding: null, + sessionsCacheByProject: { [projectRoot]: [settledSession] }, + }); + installAdeMocks({ sessions: [session] }); + + renderPane(session); + + const pill = await screen.findByTestId("chat-lifecycle-banner"); + expect(pill.className).toContain("flex"); + expect(pill.className).toContain("w-fit"); + expect(pill.className).toContain("mx-auto"); + expect(pill.className).not.toContain("inline-flex"); + }); + it("validates empty legacy event-history snapshots before treating them as loaded", async () => { const session = buildSession("session-1", { title: "Possibly foreign chat" }); installAdeMocks({ diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index fb98820e5..ec3bfcb65 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -119,6 +119,7 @@ import { ChatAttachmentDropOverlay } from "./ChatAttachmentDropOverlay"; import type { AgentChatAttachmentDropTarget } from "./chatAttachmentDropTarget"; import { collectAgentChatPromptHistory, type AgentChatPromptHistoryEntry } from "./chatPromptHistory"; import { ChatLifecycleBanner } from "./ChatLifecycleBanner"; +import { ChatAwayDigestCard } from "./ChatAwayDigestCard"; import { ChatSubagentTakeoverBanner } from "./ChatSubagentTakeoverBanner"; import { resolveModelDescriptorWithRuntimeCatalog, descriptorsFromAgentChatModelCatalog } from "../shared/ModelPicker/modelCatalog"; import { latestContextUsageInput, toUsageViewModel, type ContextUsageViewModel } from "./usage/contextUsageModel"; @@ -4346,18 +4347,6 @@ export function AgentChatPane({ }]; }).sort((left, right) => left.firedAtMs - right.firedAtMs); }, [selectedEventsForDisplay, selectedSessionId, wakeAwayWindow]); - const latestUnattendedOutcome = useMemo(() => { - const latest = unattendedWakeTurns[unattendedWakeTurns.length - 1]; - if (!latest) return null; - const text = selectedEventsForDisplay - .filter((envelope) => envelope.event.type === "text" && envelope.event.turnId === latest.turnId) - .map((envelope) => envelope.event.type === "text" ? envelope.event.text : "") - .join("") - .replace(/\s+/g, " ") - .trim(); - return (text || latest.reason || selectedSession?.lastOutputPreview || "Scheduled work ran while this chat was closed.") - .slice(0, 180); - }, [selectedEventsForDisplay, selectedSession?.lastOutputPreview, unattendedWakeTurns]); const dispatchedAuthRecoveryRef = useRef>(new Set()); const selectedCodexGoal = useMemo(() => { let goalFromEvents: CodexThreadGoal | null = null; @@ -12478,15 +12467,15 @@ export function AgentChatPane({ type="button" onClick={() => navigateToSpawnedChat(spawnLineage.parentId, null)} className={cn( - "inline-flex max-w-[220px] items-center gap-1 rounded-full border px-2 py-0.5 font-sans text-[10px] font-medium transition-colors", + "inline-flex max-w-[220px] items-center gap-1.5 px-0.5 py-0.5 font-sans text-[10px] font-medium underline-offset-2 transition-colors hover:underline", spawnLineage.spawnKind === "peer" - ? "border-slate-400/18 bg-slate-400/[0.06] text-slate-300/75 hover:border-slate-300/30 hover:text-slate-100/90" - : "border-violet-400/20 bg-violet-400/[0.06] text-violet-200/80 hover:border-violet-300/32 hover:text-violet-100", + ? "text-slate-300/70 hover:text-slate-100/90" + : "text-violet-200/75 hover:text-violet-100", )} title={spawnLineage.parentTitle ? `Parent thread: "${spawnLineage.parentTitle}"` : "View parent thread"} > - - View parent thread + + Go to parent thread ) : null} {chatTerminalVisible && selectedSessionId ? ( @@ -12729,9 +12718,9 @@ export function AgentChatPane({ onLaneChipClick={laneId ? () => navigate(openLaneInLanesTabPath(laneId)) : undefined} showCacheBadge={showClaudeCacheTimer} cacheIdleSinceAt={selectedSession?.idleSinceAt ?? null} - // Ambient settled/snoozed chips — the chat pane otherwise has no - // lifecycle awareness at all. The composer slot below stays with drift. - lifecycleSessionId={selectedSessionId ?? null} + // Snooze keeps a small header affordance; settled state is shown only + // in the compact pill floating directly above the composer. + snoozeSessionId={selectedSessionId ?? null} showGitToolbar={showWorkspaceChrome} prSessionId={renderedSessionId} // Only wire the pane toggle where the pane actually renders (a selected @@ -12882,12 +12871,17 @@ export function AgentChatPane({ /> ) : null; - // Settled/snoozed notice pinned above the composer. The header chips say WHAT - // the state is; this says what sending will do about it, where the eye already - // is while typing. Renders null for a live chat, so nothing below it moves. - const lifecycleBanner = composerSessionId ? ( + const lifecyclePill = composerSessionId ? ( ) : null; + const lifecycleOverlay = lifecyclePill ? ( +
+ {lifecyclePill} +
+ ) : null; const takeoverBanner = composerSessionId && selectedSession?.spawnKind === "subagent" && selectedSession.orchestrationParentSessionId @@ -13394,35 +13388,31 @@ export function AgentChatPane({ /> ); - const awayDigestStrip = unattendedWakeTurns.length > 0 ? ( -
- - While you were away: {unattendedWakeTurns.length} wakeup{unattendedWakeTurns.length === 1 ? "" : "s"} - {latestUnattendedOutcome ? ` · ${latestUnattendedOutcome}` : ""} - - - {unattendedWakeTurns.slice(-3).map((wake, index) => ( - - ))} - - + const firstUnattendedWake = unattendedWakeTurns[0] ?? null; + const awayDigestCard = firstUnattendedWake ? ( + setWakeJumpRequest((current) => ({ + key: `scheduled-wake:${firstUnattendedWake.scheduleId}:${firstUnattendedWake.turnId}`, + requestId: (current?.requestId ?? 0) + 1, + }))} + onDismiss={() => setWakeAwayWindow((current) => current ? { ...current, dismissed: true } : current)} + /> + ) : null; + const appPanelLifecyclePill = composerSessionId ? ( + + ) : null; + const composerNoticeOverlay = awayDigestCard || lifecycleOverlay ? ( +
+ {awayDigestCard} + {lifecycleOverlay}
) : null; @@ -13540,9 +13530,7 @@ export function AgentChatPane({ ); })} {authStickyBar} - {awayDigestStrip} - {lifecycleBanner} {takeoverBanner} {composerElement}
@@ -13820,7 +13808,7 @@ export function AgentChatPane({ data-chat-sync-pending={selectedSyncPending ? "true" : undefined} style={{ ...chatAppearanceRootStyle, ...splitChatColStyle, paddingLeft: "var(--chat-pane-reserve-left, 0px)", paddingRight: "var(--chat-pane-reserve-right, 0px)" }} className={cn( - "flex min-h-0 flex-1 basis-0 flex-col overflow-hidden", + "relative flex min-h-0 flex-1 basis-0 flex-col overflow-hidden", layoutVariant === "grid-tile" ? "min-w-0" : "min-w-[280px]", )} > @@ -14054,6 +14042,7 @@ export function AgentChatPane({ /> ) : null} + {!appPanelOpen ? composerNoticeOverlay : null} {sessionDelta ? (
+{sessionDelta.insertions} @@ -14063,9 +14052,13 @@ export function AgentChatPane({ {appPanelOpen ? (
{authStickyBar} - {awayDigestStrip} - {lifecycleBanner} + {awayDigestCard ? ( +
+ {awayDigestCard} + {appPanelLifecyclePill} +
+ ) : appPanelLifecyclePill} {takeoverBanner} {composerElement}
diff --git a/apps/desktop/src/renderer/components/chat/ChatAwayDigestCard.tsx b/apps/desktop/src/renderer/components/chat/ChatAwayDigestCard.tsx new file mode 100644 index 000000000..55ceb0703 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/ChatAwayDigestCard.tsx @@ -0,0 +1,46 @@ +import { ClockCounterClockwise, X } from "@phosphor-icons/react"; + +export function ChatAwayDigestCard({ + count, + firstReason, + onReview, + onDismiss, +}: { + count: number; + firstReason: string | null; + onReview: () => void; + onDismiss: () => void; +}) { + return ( +
+ + + + + While you were away + + {count} scheduled wakeup{count === 1 ? "" : "s"} ran + + + + +
+ ); +} diff --git a/apps/desktop/src/renderer/components/chat/ChatLifecycleBanner.test.tsx b/apps/desktop/src/renderer/components/chat/ChatLifecycleBanner.test.tsx index efd1310a9..e55c1ec58 100644 --- a/apps/desktop/src/renderer/components/chat/ChatLifecycleBanner.test.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatLifecycleBanner.test.tsx @@ -93,16 +93,16 @@ describe("ChatLifecycleBanner", () => { expect(container.firstChild).toBeNull(); }); - it("renders the settled variant with copy that matches ADE's settle semantics", () => { + it("renders the settled state as a compact floating pill", () => { seedSessions([makeSession(settledOverrides())]); render(); const banner = screen.getByTestId("chat-lifecycle-banner"); expect(banner.getAttribute("data-lifecycle-variant")).toBe("settled"); - expect(banner.textContent).toContain("This chat is settled"); - // `settledAt` is cleared at the write site on real activity, so sending is - // what un-settles it. - expect(banner.textContent).toContain("Sending a message clears the settle"); + expect(banner.textContent).toContain("Settled"); + expect(banner.textContent).toContain("Sending reopens this chat"); + expect(banner.className).toContain("rounded-full"); + expect(banner.className).not.toContain("w-full"); // Emerald means "finished cleanly"; amber is reserved for "your move". expect(banner.className).toContain("emerald"); expect(banner.className).not.toContain("amber"); @@ -114,8 +114,8 @@ describe("ChatLifecycleBanner", () => { const banner = screen.getByTestId("chat-lifecycle-banner"); expect(banner.getAttribute("data-lifecycle-variant")).toBe("snoozed"); - expect(banner.textContent).toContain("This chat is snoozed"); - expect(banner.textContent).toContain("Hidden from the sidebar until"); + expect(banner.textContent).toContain("Snoozed"); + expect(banner.textContent).toContain("Hidden until"); // Snooze is a visibility overlay, so it gets neither emerald nor amber. expect(banner.className).not.toContain("emerald"); expect(banner.className).not.toContain("amber"); @@ -140,8 +140,8 @@ describe("ChatLifecycleBanner", () => { seedSessions([makeSession(settledOverrides())]); render(); const settledButton = screen.getByTestId("chat-lifecycle-unsettle"); - expect(settledButton.className).toContain("hover:bg-emerald-400/[0.13]"); - expect(settledButton.className).toContain("focus-visible:bg-emerald-400/[0.13]"); + expect(settledButton.className).toContain("hover:bg-emerald-300/[0.10]"); + expect(settledButton.className).toContain("focus-visible:bg-emerald-300/[0.10]"); cleanup(); seedSessions([makeSession(snoozedOverrides())]); diff --git a/apps/desktop/src/renderer/components/chat/ChatLifecycleBanner.tsx b/apps/desktop/src/renderer/components/chat/ChatLifecycleBanner.tsx index bde3c499f..858455aa5 100644 --- a/apps/desktop/src/renderer/components/chat/ChatLifecycleBanner.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatLifecycleBanner.tsx @@ -6,42 +6,19 @@ import { useSessionLifecycleSnapshot } from "../work/SessionLifecycleChips"; import { unsettleSession, wakeSessionNow } from "../terminals/sessionLifecycleActions"; import { cn } from "../ui/cn"; -/** - * "This chat is parked" notice, pinned directly above the composer. - * - * The header already carries ambient settled/snoozed CHIPS - * (`SessionLifecycleChips`) — a 10px marker you have to already be looking at. - * This is the other half: when you are typing, your eyes are at the bottom of - * the pane, and the one fact that matters is that this thread is currently out - * of the way and what sending will do about it. The chip identifies the state; - * this explains the consequence. Both read from the same derived helpers - * (`sessionCanonicalUiState` + `isSessionSnoozed`) so they cannot disagree. - * - * Colour follows the shared status vocabulary in - * `shared/sessionStatusPresentation.ts`: emerald for settled ("finished - * cleanly"), neutral for snoozed (a VISIBILITY OVERLAY, not a lifecycle state — - * it is `tone: "neutral"` there for exactly this reason). Amber is spent - * entirely on "your move" and appears in neither variant. - * - * Layout note: the composer sits at the bottom of a column, so this banner - * grows upward into the transcript rather than pushing the composer down, and - * it renders `null` — not a reserved-height placeholder — when neither state - * applies. Nothing under it moves when it appears. - */ - -const CARD_BASE_CLASS = - "mb-1.5 flex items-start gap-2.5 rounded-[calc(var(--chat-radius-card)-8px)] border px-3 py-2.5"; -const ICON_TILE_CLASS = - "flex h-6 w-6 shrink-0 items-center justify-center rounded-lg border"; +/** Compact lifecycle pill that floats over the transcript above the composer. */ +const PILL_BASE_CLASS = + "pointer-events-auto inline-flex min-w-0 max-w-[calc(100%-1.5rem)] items-center gap-1.5 rounded-full border px-2.5 py-1 font-sans shadow-[0_10px_28px_rgba(0,0,0,0.28)] backdrop-blur-xl"; const BUTTON_BASE_CLASS = - "inline-flex shrink-0 items-center rounded-md border px-2.5 py-1 font-mono text-[length:calc(var(--chat-font-size)*9/14)] font-semibold transition-colors disabled:pointer-events-none disabled:opacity-40"; + "ml-0.5 inline-flex shrink-0 items-center rounded-full px-1.5 py-0.5 text-[length:calc(var(--chat-font-size)*9.5/14)] font-medium transition-colors disabled:pointer-events-none disabled:opacity-40"; type LifecycleVariant = "settled" | "snoozed"; const VARIANT_CHROME: Record` does not match its @@ -51,23 +28,25 @@ const VARIANT_CHROME: Record -
- -
-
-
- {title} -
-
- {line} -
-
+ + + {title} + + · + + {detail} + - {openChip === "snoozed" ? ( - setOpenChip(null)} - items={[ - { key: "wake", label: "Wake now", onSelect: () => { void wakeSessionNow(session); } }, - ]} - /> - ) : null} - - ) : null} - - {settled ? ( - - - {openChip === "settled" ? ( - setOpenChip(null)} - items={[ - { - key: "unsettle", - label: "Unsettle", - // The declared-vs-derived branch lives in the shared action so - // the chip and the Work row menu can never disagree. - onSelect: () => { void unsettleSession(session); }, - }, - ]} - /> - ) : null} - + + + {menuOpen ? ( + setMenuOpen(false)} + items={[ + { key: "wake", label: "Wake now", onSelect: () => { void wakeSessionNow(session); } }, + ]} + /> ) : null} - + ); } diff --git a/apps/desktop/src/renderer/components/work/WorkSurfaceHeader.tsx b/apps/desktop/src/renderer/components/work/WorkSurfaceHeader.tsx index 29202d028..b70de633e 100644 --- a/apps/desktop/src/renderer/components/work/WorkSurfaceHeader.tsx +++ b/apps/desktop/src/renderer/components/work/WorkSurfaceHeader.tsx @@ -3,7 +3,7 @@ import { SidebarSimple } from "@phosphor-icons/react"; import { ChatGitToolbar } from "../chat/ChatGitToolbar"; import { LaneBranchDriftChip } from "../lanes/LaneBranchDrift"; import { LaneChip } from "../terminals/LaneChip"; -import { SessionLifecycleChips } from "./SessionLifecycleChips"; +import { SessionSnoozeChip } from "./SessionLifecycleChips"; import { ClaudeCacheTtlBadge } from "../shared/ClaudeCacheTtlBadge"; import { useFloatingPaneEmbeddedChrome } from "../ui/FloatingPane"; import { cn } from "../ui/cn"; @@ -198,12 +198,8 @@ export type WorkSurfaceHeaderProps = { */ showCacheBadge?: boolean; cacheIdleSinceAt?: string | null; - /** - * Session id whose lifecycle (settled / snoozed) should surface as ambient - * header chips. Chips render only when the session is actually in one of those - * states; the composer slot below is owned by lane branch drift. - */ - lifecycleSessionId?: string | null; + /** Session id whose snooze state should surface as an ambient header chip. */ + snoozeSessionId?: string | null; /** When true and laneId is set, renders the ChatGitToolbar. */ showGitToolbar?: boolean; /** Chat session owning the header; lets PR badges stay chat-specific. */ @@ -257,7 +253,7 @@ export function WorkSurfaceHeader({ onLaneChipClick, showCacheBadge = false, cacheIdleSinceAt, - lifecycleSessionId = null, + snoozeSessionId = null, showGitToolbar = false, prSessionId = null, onTogglePrPane, @@ -307,7 +303,7 @@ export function WorkSurfaceHeader({ /> ) : null} {laneId ? : null} - {lifecycleSessionId ? : null} + {snoozeSessionId ? : null} {showCacheBadge ? ( ) : null} diff --git a/apps/desktop/src/renderer/lib/terminalAttention.ts b/apps/desktop/src/renderer/lib/terminalAttention.ts index 817cc74dc..bc37ba855 100644 --- a/apps/desktop/src/renderer/lib/terminalAttention.ts +++ b/apps/desktop/src/renderer/lib/terminalAttention.ts @@ -297,6 +297,39 @@ export function sessionFilingBucket( : canonicalStatusBucket(phase); } +/** + * Resolve relationship-aware filing without changing a child session's real + * runtime state. A non-chat helper attached to a settled chat files with its + * parent unless it currently needs the user's attention. + */ +export function effectiveSessionFilingBuckets( + sessions: readonly TerminalSessionSummary[], + nowMs: number = Date.now(), +): Map { + const byId = new Map(sessions.map((session) => [session.id, session])); + const buckets = new Map(); + + for (const session of sessions) { + buckets.set(session.id, sessionFilingBucket(session, nowMs)); + } + + for (const session of sessions) { + const parentId = session.chatSessionId; + if (!parentId || parentId === session.id || isChatToolType(session.toolType)) continue; + const parent = byId.get(parentId); + if ( + !parent + || parent.laneId !== session.laneId + || !isChatToolType(parent.toolType) + || buckets.get(parent.id) !== "settled" + || sessionNeedsYou(canonicalInputFromSummary(session)) + ) continue; + buckets.set(session.id, "settled"); + } + + return buckets; +} + export function sessionMatchesStatusFilter( args: SessionCanonicalUiInput, filter: SessionStatusFilter, From c992b33a72a08fa8c91e6f81ea34195a07cf0b87 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sat, 29 Aug 2026 14:39:52 -0400 Subject: [PATCH 2/4] fix: streamline settled chat lifecycle UI --- .../components/app/CommandPalette.test.tsx | 38 +++++ .../components/app/CommandPalette.tsx | 47 +++++- .../components/app/commandPaletteThreads.tsx | 10 +- .../components/app/commandPaletteWork.tsx | 8 +- .../components/chat/AgentChatPane.tsx | 29 ++-- .../chat/ChatLifecycleBanner.test.tsx | 16 ++ .../components/chat/ChatLifecycleBanner.tsx | 18 ++- .../chat/chatTranscriptRows.test.ts | 18 +++ .../components/chat/chatTranscriptRows.ts | 2 + .../terminals/SessionListPane.test.tsx | 55 +++++++ .../components/terminals/SessionListPane.tsx | 137 +++++++++++++++--- .../components/terminals/TerminalsPage.tsx | 1 + .../terminals/sessionLifecycleActions.ts | 5 +- .../terminals/useWorkSessions.test.ts | 82 +++++++++++ .../components/terminals/useWorkSessions.ts | 67 ++++++--- .../terminals/workSessionFilters.ts | 6 +- .../work/SessionLifecycleChips.test.tsx | 92 ++++++++++-- .../components/work/SessionLifecycleChips.tsx | 50 +++++-- .../components/work/WorkSurfaceHeader.tsx | 2 +- .../renderer/lib/terminalAttention.test.ts | 50 +++++++ .../src/renderer/lib/terminalAttention.ts | 1 + docs/ARCHITECTURE.md | 2 +- docs/features/chat/README.md | 14 +- docs/features/chat/composer-and-ui.md | 33 ++--- docs/features/chat/transcript-and-turns.md | 2 +- docs/features/sync-and-multi-device/README.md | 6 +- .../features/terminals-and-sessions/README.md | 51 ++++--- .../terminals-and-sessions/ui-surfaces.md | 21 ++- 28 files changed, 705 insertions(+), 158 deletions(-) diff --git a/apps/desktop/src/renderer/components/app/CommandPalette.test.tsx b/apps/desktop/src/renderer/components/app/CommandPalette.test.tsx index 15ebff7cb..d6eb51abd 100644 --- a/apps/desktop/src/renderer/components/app/CommandPalette.test.tsx +++ b/apps/desktop/src/renderer/components/app/CommandPalette.test.tsx @@ -898,6 +898,44 @@ describe("CommandPalette", () => { expect(screen.getByRole("button", { name: /Remove provider filter codex/i })).toBeTruthy(); }); + it("files an attached shell under its settled chat for Work status search", async () => { + const parent = makeSession({ + id: "session-settled-parent", + title: "Settled parent chat", + toolType: "codex-chat", + status: "completed", + runtimeState: "idle", + endedAt: new Date().toISOString(), + exitCode: 0, + settledAt: new Date().toISOString(), + }); + const child = makeSession({ + id: "session-attached-shell", + title: "Attached shell", + toolType: "shell", + status: "running", + runtimeState: "running", + chatSessionId: parent.id, + settledAt: null, + }); + seedThreads([parent, child]); + + render( + + + , + ); + + fireEvent.change( + screen.getByPlaceholderText("Search commands, projects, and threads…"), + { target: { value: "status:settled attached" } }, + ); + + expect(await screen.findByTestId("thread-status-session-attached-shell")).toBeTruthy(); + expect(screen.queryByText("Settled parent chat")).toBeNull(); + expect(document.querySelector('[data-thread-id="session-attached-shell"]')).toBeTruthy(); + }); + it("shows lifecycle actions on the highlighted Work result and targets that session", async () => { const settle = vi.fn(async () => {}); globalThis.window.ade.sessions = { settle } as any; diff --git a/apps/desktop/src/renderer/components/app/CommandPalette.tsx b/apps/desktop/src/renderer/components/app/CommandPalette.tsx index 5a0709c4e..587b9ebdf 100644 --- a/apps/desktop/src/renderer/components/app/CommandPalette.tsx +++ b/apps/desktop/src/renderer/components/app/CommandPalette.tsx @@ -45,7 +45,11 @@ import { WORK_SEARCH_FILTER_KEYS, type WorkSearchFilterKey, } from "../../../shared/workSearch"; -import { sessionFilingBucket } from "../../lib/terminalAttention"; +import { + effectiveSessionFilingBuckets, + type SessionFilingBucket, +} from "../../lib/terminalAttention"; +import { nextSnoozeDeadlineMs } from "../../lib/sessionSnooze"; import { ENTITY_SECTION_PREVIEW, SearchResultRow, @@ -273,6 +277,7 @@ function saveLastBrowsePath(locationKey: string, path: string): void { // Resolved project icons are stable for a given root path within a session, so // cache them module-wide to avoid rescanning the disk on every re-highlight. const PROJECT_ICON_CACHE_MAX = 64; +const PALETTE_SNOOZE_TICK_MAX_DELAY_MS = 10 * 60 * 1000; const PROJECT_ICON_CACHE = new Map(); function rememberProjectIcon(rootPath: string, icon: ProjectIcon): void { @@ -925,10 +930,38 @@ export function CommandPalette({ foreignMachines, activeMachine, ); + const threadSessionsForFiling = useMemo( + () => threadIndex.map((entry) => entry.session), + [threadIndex], + ); + const [filingEpoch, setFilingEpoch] = useState(0); + const filingNowMs = useMemo(() => { + // The epoch is a deadline tick; reading it makes this clock refresh when a + // snooze expires even if the indexed session objects retain their identity. + void filingEpoch; + return Date.now(); + }, [filingEpoch]); + useEffect(() => { + if (!open || mode !== "default") return undefined; + const deadlineMs = nextSnoozeDeadlineMs(threadSessionsForFiling); + if (deadlineMs == null) return undefined; + const delay = Math.min( + Math.max(deadlineMs - Date.now(), 250), + PALETTE_SNOOZE_TICK_MAX_DELAY_MS, + ); + const timer = window.setTimeout(() => setFilingEpoch((value) => value + 1), delay); + return () => window.clearTimeout(timer); + }, [filingEpoch, mode, open, threadSessionsForFiling]); + const effectiveFilingBuckets = useMemo>( + () => effectiveSessionFilingBuckets(threadSessionsForFiling, filingNowMs), + [filingNowMs, threadSessionsForFiling], + ); const threadMatches = useMemo( () => - open && mode === "default" ? rankThreads(threadIndex, trimmedQuery) : [], - [mode, open, threadIndex, trimmedQuery], + open && mode === "default" + ? rankThreads(threadIndex, trimmedQuery, effectiveFilingBuckets) + : [], + [effectiveFilingBuckets, mode, open, threadIndex, trimmedQuery], ); const workFacetOptions = useMemo< @@ -944,7 +977,8 @@ export function CommandPalette({ for (const entry of threadIndex) { if (entry.laneName) values.lane.add(entry.laneName); if (entry.provider) values.provider.add(entry.provider); - values.status.add(sessionFilingBucket(entry.session)); + const filingBucket = effectiveFilingBuckets.get(entry.session.id); + if (filingBucket) values.status.add(filingBucket); values.type.add( isChatToolType(entry.session.toolType) ? "chat" : "terminal", ); @@ -961,7 +995,7 @@ export function CommandPalette({ options[key] = [...values[key]].sort((a, b) => a.localeCompare(b)); } return options; - }, [threadIndex]); + }, [effectiveFilingBuckets, threadIndex]); const { loading: searchLoading, @@ -979,8 +1013,9 @@ export function CommandPalette({ sessionResults, threadIndex, threadMatches, + effectiveFilingBuckets, }), - [parsedWorkQuery, sessionResults, threadIndex, threadMatches], + [effectiveFilingBuckets, parsedWorkQuery, sessionResults, threadIndex, threadMatches], ); const visibleWorkResults = useMemo( diff --git a/apps/desktop/src/renderer/components/app/commandPaletteThreads.tsx b/apps/desktop/src/renderer/components/app/commandPaletteThreads.tsx index 25f7df3f3..d499634f1 100644 --- a/apps/desktop/src/renderer/components/app/commandPaletteThreads.tsx +++ b/apps/desktop/src/renderer/components/app/commandPaletteThreads.tsx @@ -54,10 +54,12 @@ import { } from "../../lib/sessionSnooze"; import { canonicalInputFromSummary, + effectiveSessionFilingBuckets, sessionFilingBucket, sessionIsMidFlight, sessionCanonicalUiState, sessionStatusDisplay, + type SessionFilingBucket, } from "../../lib/terminalAttention"; import { cn } from "../ui/cn"; import { highlightRanges, highlightTitle } from "./commandPaletteSearch"; @@ -250,8 +252,10 @@ export function buildThreadIndex( export function matchesThreadWorkFacets( entry: ThreadIndexEntry, parsed: ParsedWorkSearch, + effectiveFilingBuckets?: ReadonlyMap, ): boolean { - const filingBucket = sessionFilingBucket(entry.session); + const filingBucket = effectiveFilingBuckets?.get(entry.session.id) + ?? sessionFilingBucket(entry.session); if (!matchesWorkSearchFilters(parsed.filters, { lane: [entry.laneName], provider: [entry.provider, entry.toolTypeLower], @@ -372,6 +376,8 @@ export type ThreadRowAction = "new-chat" | "rename" | "settle" | "snooze"; export function rankThreads( index: readonly ThreadIndexEntry[], query: string, + effectiveFilingBuckets: ReadonlyMap = + effectiveSessionFilingBuckets(index.map((entry) => entry.session)), ): ThreadMatch[] { const parsed = parseWorkSearchQuery(query); if ( @@ -383,7 +389,7 @@ export function rankThreads( } const matches: ThreadMatch[] = []; for (const entry of index) { - if (!matchesThreadWorkFacets(entry, parsed)) continue; + if (!matchesThreadWorkFacets(entry, parsed, effectiveFilingBuckets)) continue; let total = 0; let matchedEveryTerm = true; const matchFields: ThreadMatchField[] = []; diff --git a/apps/desktop/src/renderer/components/app/commandPaletteWork.tsx b/apps/desktop/src/renderer/components/app/commandPaletteWork.tsx index d32a9e82c..aa1e9f8cf 100644 --- a/apps/desktop/src/renderer/components/app/commandPaletteWork.tsx +++ b/apps/desktop/src/renderer/components/app/commandPaletteWork.tsx @@ -13,6 +13,7 @@ import { type ThreadMatch, type ThreadRowAction, } from "./commandPaletteThreads"; +import type { SessionFilingBucket } from "../../lib/terminalAttention"; import { projectStateKeyForBinding, type WorkProjectViewState, @@ -21,6 +22,7 @@ import { invalidateSessionListCache } from "../../lib/sessionListCache"; import { isSessionSnoozed } from "../../lib/sessionSnooze"; import { canonicalInputFromSummary, + effectiveSessionFilingBuckets, sessionCanonicalUiState, } from "../../lib/terminalAttention"; import { @@ -263,11 +265,13 @@ export function buildWorkResults({ sessionResults, threadIndex, threadMatches, + effectiveFilingBuckets, }: { parsedWorkQuery: ParsedWorkSearch; sessionResults: readonly SearchResultItem[]; threadIndex: readonly ThreadIndexEntry[]; threadMatches: readonly ThreadMatch[]; + effectiveFilingBuckets?: ReadonlyMap; }): PaletteWorkResult[] { const contentBySessionId = new Map(); for (const item of sessionResults) { @@ -279,6 +283,8 @@ export function buildWorkResults({ const localEntriesById = new Map( threadIndex.map((entry) => [entry.session.id, entry] as const), ); + const filingBuckets = effectiveFilingBuckets + ?? effectiveSessionFilingBuckets(threadIndex.map((entry) => entry.session)); const matchedSessionIds = new Set(); const merged: PaletteWorkResult[] = threadMatches.map((match) => { const sessionId = match.entry.session.id; @@ -300,7 +306,7 @@ export function buildWorkResults({ if (item.sessionId) { const localEntry = localEntriesById.get(item.sessionId); if (localEntry) { - if (!matchesThreadWorkFacets(localEntry, parsedWorkQuery)) continue; + if (!matchesThreadWorkFacets(localEntry, parsedWorkQuery, filingBuckets)) continue; matchedSessionIds.add(item.sessionId); merged.push({ type: "thread", diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index ec3bfcb65..4301cddd3 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -118,7 +118,7 @@ import { import { ChatAttachmentDropOverlay } from "./ChatAttachmentDropOverlay"; import type { AgentChatAttachmentDropTarget } from "./chatAttachmentDropTarget"; import { collectAgentChatPromptHistory, type AgentChatPromptHistoryEntry } from "./chatPromptHistory"; -import { ChatLifecycleBanner } from "./ChatLifecycleBanner"; +import { ChatLifecycleBanner, shouldRenderChatLifecycleBanner } from "./ChatLifecycleBanner"; import { ChatAwayDigestCard } from "./ChatAwayDigestCard"; import { ChatSubagentTakeoverBanner } from "./ChatSubagentTakeoverBanner"; import { resolveModelDescriptorWithRuntimeCatalog, descriptorsFromAgentChatModelCatalog } from "../shared/ModelPicker/modelCatalog"; @@ -136,6 +136,7 @@ import { } from "./AgentChatMessageList"; import { ChatWorkspacePathProvider, useWorkspacePathOpener } from "./chatWorkspacePaths"; import { ChatRuntimeScopeProvider, useChatScopeDerivation } from "./ChatRuntimeScope"; +import { useSessionLifecycleSnapshot } from "../work/SessionLifecycleChips"; import { useForeignSessionLaneId } from "../../state/crossMachineLanes"; import { CHAT_HISTORY_PAGE_MAX_BYTES, @@ -4023,6 +4024,11 @@ export function AgentChatPane({ () => chatMachineRouter.pinForLane(renderedSession?.laneId ?? foreignRenderedLaneId ?? laneId), [chatMachineRouter, foreignRenderedLaneId, laneId, renderedSession?.laneId], ); + // Lifecycle actions must follow the session currently rendered in the pane. + // Resolve this after the machine pin so foreign chats never send a wake or + // unsettle request to the tab-bound runtime. + const composerLifecycleSession = useSessionLifecycleSnapshot(composerSessionId); + const hasComposerLifecycleBanner = shouldRenderChatLifecycleBanner(composerLifecycleSession); turnActiveBySessionRef.current = turnActiveBySession; const promptSuggestion = selectedSessionId ? promptSuggestionsBySession[selectedSessionId] ?? null : null; @@ -12472,7 +12478,7 @@ export function AgentChatPane({ ? "text-slate-300/70 hover:text-slate-100/90" : "text-violet-200/75 hover:text-violet-100", )} - title={spawnLineage.parentTitle ? `Parent thread: "${spawnLineage.parentTitle}"` : "View parent thread"} + title={spawnLineage.parentTitle ? `Parent thread: "${spawnLineage.parentTitle}"` : "Go to parent thread"} > Go to parent thread @@ -12871,16 +12877,8 @@ export function AgentChatPane({ />
) : null; - const lifecyclePill = composerSessionId ? ( - - ) : null; - const lifecycleOverlay = lifecyclePill ? ( -
- {lifecyclePill} -
+ const lifecyclePill = hasComposerLifecycleBanner && composerSessionId ? ( + ) : null; const takeoverBanner = composerSessionId && selectedSession?.spawnKind === "subagent" @@ -13400,19 +13398,20 @@ export function AgentChatPane({ onDismiss={() => setWakeAwayWindow((current) => current ? { ...current, dismissed: true } : current)} /> ) : null; - const appPanelLifecyclePill = composerSessionId ? ( + const appPanelLifecyclePill = hasComposerLifecycleBanner && composerSessionId ? ( ) : null; - const composerNoticeOverlay = awayDigestCard || lifecycleOverlay ? ( + const composerNoticeOverlay = awayDigestCard || lifecyclePill ? (
{awayDigestCard} - {lifecycleOverlay} + {lifecyclePill}
) : null; diff --git a/apps/desktop/src/renderer/components/chat/ChatLifecycleBanner.test.tsx b/apps/desktop/src/renderer/components/chat/ChatLifecycleBanner.test.tsx index e55c1ec58..4dd0f6469 100644 --- a/apps/desktop/src/renderer/components/chat/ChatLifecycleBanner.test.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatLifecycleBanner.test.tsx @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import type { TerminalSessionSummary } from "../../../shared/types"; import { useAppStore } from "../../state/appStore"; +import { showToast } from "../app/toast/toastStore"; import { ChatLifecycleBanner } from "./ChatLifecycleBanner"; vi.mock("../app/toast/toastStore", () => ({ @@ -177,6 +178,21 @@ describe("ChatLifecycleBanner", () => { expect(sessionsApi.setSettleOverride).not.toHaveBeenCalled(); }); + it("reports an unsettle failure instead of swallowing it", async () => { + const error = new Error("runtime unavailable"); + sessionsApi.unsettle.mockRejectedValue(error); + seedSessions([makeSession(settledOverrides())]); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Un-settle" })); + + await waitFor(() => expect(showToast).toHaveBeenCalledWith(expect.objectContaining({ + title: "Unsettle failed", + message: "runtime unavailable", + tone: "error", + }))); + }); + it("wakes a snoozed chat with the manual reason", async () => { seedSessions([makeSession(snoozedOverrides())]); render(); diff --git a/apps/desktop/src/renderer/components/chat/ChatLifecycleBanner.tsx b/apps/desktop/src/renderer/components/chat/ChatLifecycleBanner.tsx index 858455aa5..d1b5ff6c2 100644 --- a/apps/desktop/src/renderer/components/chat/ChatLifecycleBanner.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatLifecycleBanner.tsx @@ -1,5 +1,6 @@ import { CheckCircle, Moon } from "@phosphor-icons/react"; +import type { OpenProjectBinding, TerminalSessionSummary } from "../../../shared/types"; import { canonicalInputFromSummary, sessionCanonicalUiState } from "../../lib/terminalAttention"; import { isSessionSnoozed, snoozeWakeDescription } from "../../lib/sessionSnooze"; import { useSessionLifecycleSnapshot } from "../work/SessionLifecycleChips"; @@ -33,7 +34,7 @@ const VARIANT_CHROME: Record {actionLabel} diff --git a/apps/desktop/src/renderer/components/chat/chatTranscriptRows.test.ts b/apps/desktop/src/renderer/components/chat/chatTranscriptRows.test.ts index 73c548184..c39379648 100644 --- a/apps/desktop/src/renderer/components/chat/chatTranscriptRows.test.ts +++ b/apps/desktop/src/renderer/components/chat/chatTranscriptRows.test.ts @@ -1450,6 +1450,24 @@ describe("chatTranscriptRows edge cases", () => { timestamp: "2026-03-17T10:00:02.000Z", event: { type: "tokens", turnId: "turn-1", inputTokens: 406_700, outputTokens: 1_200 }, }, + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:03.000Z", + event: { + type: "codex_token_usage", + turnId: "turn-1", + usage: { last: { inputTokens: 406_700 }, modelContextWindow: 1_000_000 }, + }, + }, + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:04.000Z", + event: { + type: "codex_moderation_metadata", + turnId: "turn-1", + metadata: { turnId: "turn-1", metadata: { is_blocked: false } }, + }, + }, ]); expect(rows).toHaveLength(0); }); diff --git a/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts b/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts index 8b2556c2a..5899a9f4f 100644 --- a/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts +++ b/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts @@ -89,6 +89,8 @@ type HiddenTranscriptEvent = | Extract | Extract | Extract + | Extract + | Extract // Token usage drives the chat-column-bottom token footer; inline transcript // rows would be duplicate noise. | Extract; diff --git a/apps/desktop/src/renderer/components/terminals/SessionListPane.test.tsx b/apps/desktop/src/renderer/components/terminals/SessionListPane.test.tsx index 58bbf6eef..39304c2bb 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionListPane.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionListPane.test.tsx @@ -196,6 +196,7 @@ function headerChevron(sectionId: string): HTMLElement { describe("SessionListPane", () => { afterEach(() => { + vi.useRealTimers(); cleanup(); lanePrsByLaneIdForTest.clear(); useAppStore.setState({ laneDeleteProgressByLaneId: {} }); @@ -1689,6 +1690,38 @@ describe("SessionListPane", () => { expect(foreignGroup(container)!.querySelector("[data-machine-marker-mode]")).toBeTruthy(); }); + it("keeps an attached child settled when search hides its parent", () => { + const parent = foreignSettled({ + id: "session-foreign-parent", + title: "Settled parent chat", + }); + const child = makeSession({ + id: "session-foreign-shell", + laneId: "lane-elsewhere", + laneName: "Elsewhere Lane", + title: "Attached shell", + toolType: "shell", + chatSessionId: parent.id, + status: "running", + runtimeState: "running", + settledAt: null, + }); + seedForeignMachine({ sessions: [parent, child] }); + const { container } = renderPane({ + q: "attached shell", + workCollapsedSectionIds: [ + ...OPEN_QUIET_SHELVES, + "lane-open:target-studio:lane-elsewhere", + ], + }); + + // The parent is not in the filtered row, but it still participates in + // the relationship-aware filing map. The child therefore follows the + // settled parent into the same shelf instead of keeping the lane live. + expect(shelfContains(container, "settled")).toBe(true); + expect(document.querySelector('[data-session-id="session-foreign-shell"]')).toBeTruthy(); + }); + it("files a fully snoozed foreign lane into the Snoozed shelf", () => { seedForeignMachine({ sessions: [foreignSnoozed()] }); const { container } = renderPane({ workCollapsedSectionIds: OPEN_QUIET_SHELVES }); @@ -1697,6 +1730,28 @@ describe("SessionListPane", () => { expect(shelfContains(container, "settled")).toBe(false); }); + it("re-files a foreign lane when its snooze deadline expires", () => { + vi.useFakeTimers(); + const nowMs = Date.parse("2026-08-29T12:00:00.000Z"); + vi.setSystemTime(nowMs); + seedForeignMachine({ + sessions: [foreignSnoozed({ + snoozedUntil: new Date(nowMs + 1_000).toISOString(), + })], + }); + const { container } = renderPane({ workCollapsedSectionIds: OPEN_QUIET_SHELVES }); + + expect(shelfContains(container, "snoozed")).toBe(true); + act(() => { + vi.advanceTimersByTime(1_250); + }); + + // The row is still running; only the derived filing changed. The lane + // therefore returns to the inbox without waiting for a store refresh. + expect(shelfContains(container, "snoozed")).toBe(false); + expect(foreignGroup(container)).toBeTruthy(); + }); + it("files a mixed-quiet foreign lane by its dominant kind, ties to Snoozed", () => { seedForeignMachine({ sessions: [ diff --git a/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx b/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx index 8681d0832..6ab9de540 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx @@ -13,7 +13,9 @@ import { effectiveSessionFilingBuckets, sessionNeedsYou, sessionStatusBucket, + type SessionFilingBucket, } from "../../lib/terminalAttention"; +import { nextSnoozeDeadlineMs } from "../../lib/sessionSnooze"; import { useAppStore } from "../../state/appStore"; import { useLaneNamePending } from "../../state/sessionMetadataGeneratingStore"; import { @@ -89,6 +91,8 @@ const WORK_LANE_SORT_LABELS: Record = { manual: "Manual", }; const EMPTY_FOREIGN_ROWS: CrossMachineLaneRow[] = []; +/** Upper bound on the foreign-row snooze-expiry timer. */ +const FOREIGN_SNOOZE_TICK_MAX_DELAY_MS = 10 * 60 * 1000; const FILTER_OPTION_GRID_CLASS = "grid min-w-0 flex-1 gap-0.5 [grid-template-columns:repeat(auto-fit,minmax(2.4rem,1fr))]"; const FILTER_OPTION_BUTTON_CLASS = "ade-chat-drawer-row min-w-0 truncate rounded-md px-1.5 py-1 text-center text-[10px] font-medium"; /** @@ -266,6 +270,8 @@ type ForeignLaneEntry = { row: CrossMachineLaneRow; compositeLaneId: string; quiet: ReturnType; + /** Full-row partition used for stable shelving and collapse shape. */ + fullQuiet: ReturnType; shelf: "snoozed" | "settled" | null; }; @@ -317,7 +323,10 @@ function renderSharedBranchClusters( return nodes; } -function partitionQuietSessions(sessions: readonly TerminalSessionSummary[]): { +function partitionQuietSessions( + sessions: readonly TerminalSessionSummary[], + effectiveFilingBuckets?: ReadonlyMap, +): { active: TerminalSessionSummary[]; snoozed: TerminalSessionSummary[]; settled: TerminalSessionSummary[]; @@ -325,7 +334,7 @@ function partitionQuietSessions(sessions: readonly TerminalSessionSummary[]): { const active: TerminalSessionSummary[] = []; const snoozed: TerminalSessionSummary[] = []; const settled: TerminalSessionSummary[] = []; - const buckets = effectiveSessionFilingBuckets(sessions); + const buckets = effectiveFilingBuckets ?? effectiveSessionFilingBuckets(sessions); for (const session of sessions) { const bucket = buckets.get(session.id) ?? null; if (bucket === "snoozed") { @@ -872,6 +881,7 @@ export const SessionListPane = React.memo(function SessionListPane({ endedFiltered, settledFiltered, snoozedFiltered = EMPTY_SESSIONS, + effectiveFilingBuckets: effectiveFilingBucketsProp, allSessionsUnfiltered, loading: _loading, filterLaneId, @@ -924,6 +934,8 @@ export const SessionListPane = React.memo(function SessionListPane({ * by `useWorkSessions` (snooze is a visibility overlay, not a status). */ snoozedFiltered?: TerminalSessionSummary[]; + /** Relationship-aware filing map for the complete Work roster. */ + effectiveFilingBuckets?: ReadonlyMap; /** All sessions before the search/lane filter — the live-children badge counts * from this so a filtered-out running child doesn't undercount its parent. */ allSessionsUnfiltered: TerminalSessionSummary[]; @@ -1072,6 +1084,62 @@ export const SessionListPane = React.memo(function SessionListPane({ workLaneSortMode, workLaneOrder, ); + // Standalone callers can omit the hook's full-roster filing map, so keep the + // foreign slice fresh here as well. The normal Work path supplies the map and + // its own timer; this fallback timer prevents a foreign snooze from staying + // visible until an unrelated store update when the pane is rendered alone. + const foreignSessionsForFiling = useMemo(() => { + const seen = new Set(); + const sessions: TerminalSessionSummary[] = []; + for (const session of allSessionsUnfiltered) { + if (seen.has(session.id)) continue; + seen.add(session.id); + sessions.push(session); + } + for (const row of foreignRows) { + for (const session of row.sessions) { + if (seen.has(session.id)) continue; + seen.add(session.id); + sessions.push(session); + } + } + return sessions; + }, [allSessionsUnfiltered, foreignRows]); + const [foreignFilingEpoch, setForeignFilingEpoch] = useState(0); + const foreignFilingNowMs = useMemo(() => { + // The epoch is a deadline tick; reading it makes this clock refresh when a + // foreign snooze expires even if the row arrays retain their identity. + void foreignFilingEpoch; + return Date.now(); + }, [foreignFilingEpoch]); + useEffect(() => { + // The normal Work hook already arms the complete-roster timer and supplies + // its refreshed map. This local timer is only the standalone-pane fallback. + if (effectiveFilingBucketsProp) return undefined; + const deadlineMs = nextSnoozeDeadlineMs(foreignSessionsForFiling); + if (deadlineMs == null) return undefined; + const delay = Math.min( + Math.max(deadlineMs - Date.now(), 250), + FOREIGN_SNOOZE_TICK_MAX_DELAY_MS, + ); + const timer = window.setTimeout(() => setForeignFilingEpoch((value) => value + 1), delay); + return () => window.clearTimeout(timer); + }, [effectiveFilingBucketsProp, foreignFilingEpoch, foreignSessionsForFiling]); + const filingBucketsForForeignSessions = useCallback( + (sessions: readonly TerminalSessionSummary[]) => { + // The Work hook's map covers the retained cross-machine roster. If a + // standalone caller supplies only a partial map, derive this row from its + // complete snapshot so parent/child relationships are still visible. + if ( + effectiveFilingBucketsProp + && sessions.every((session) => effectiveFilingBucketsProp.has(session.id)) + ) { + return effectiveFilingBucketsProp; + } + return effectiveSessionFilingBuckets(sessions, foreignFilingNowMs); + }, + [effectiveFilingBucketsProp, foreignFilingNowMs], + ); const [createLaneOpen, setCreateLaneOpen] = useState(false); const [settleUndo, setSettleUndo] = useState<{ ids: string[]; count: number } | null>(null); const { @@ -1177,14 +1245,16 @@ export const SessionListPane = React.memo(function SessionListPane({ // // Split per bucket, not just unioned: deciding WHICH bottom shelf a fully // quiet lane files into needs "all snoozed" and "all settled" separately. - // Both come from `sessionFilingBucket`, which routes through - // `isSessionFiledAsSnoozed` — so a lane where everything is snoozed but one + // Both come from `effectiveSessionFilingBuckets` (whose base rule is + // `sessionFilingBucket` and which also folds settled-chat children) — so a + // lane where everything is snoozed but one // row has raised its hand is not snoozed here either, for free. const unfilteredQuietBuckets = useMemo(() => { const snoozed = new Set(); const settled = new Set(); const all = new Set(); - const buckets = effectiveSessionFilingBuckets(allSessionsUnfiltered); + const buckets = effectiveFilingBucketsProp + ?? effectiveSessionFilingBuckets(allSessionsUnfiltered); for (const session of allSessionsUnfiltered) { const bucket = buckets.get(session.id) ?? null; if (bucket === "snoozed") { @@ -1196,9 +1266,7 @@ export const SessionListPane = React.memo(function SessionListPane({ } } return { snoozed, settled, all }; - // Snooze expiry changes `snoozedFiltered`, forcing this full-roster - // classification to re-evaluate even when the session array is reused. - }, [allSessionsUnfiltered, snoozedFiltered]); + }, [allSessionsUnfiltered, effectiveFilingBucketsProp]); const unfilteredQuietIdSet = unfilteredQuietBuckets.all; const unfilteredSessionsByLane = useMemo(() => { const map = new Map(); @@ -1370,8 +1438,9 @@ export const SessionListPane = React.memo(function SessionListPane({ }, [allSessionsUnfiltered, handoffJobs]); /** - * A lane whose every session is snoozed or settled. `sessionFilingBucket` - * already yields to `needs_you`, so a lane can never read as quiet while + * A lane whose every session is snoozed or settled. The relationship-aware + * `effectiveSessionFilingBuckets` wrapper uses `sessionFilingBucket` for each + * row and yields to `needs_you`, so a lane can never read as quiet while * something in it is waiting on the user. * * Deliberately computed from the UNFILTERED roster: quietness describes the @@ -1400,10 +1469,11 @@ export const SessionListPane = React.memo(function SessionListPane({ const visibleForeignRows = useMemo(() => { if (foreignRows.length === 0) return EMPTY_FOREIGN_ROWS; const query = q.trim().toLowerCase(); - const nowMs = Date.now(); + const nowMs = foreignFilingNowMs; const rows: CrossMachineLaneRow[] = []; for (const row of foreignRows) { if (laneFilterActive && row.lane.id !== normalizedFilterLaneId) continue; + const effectiveFilingBuckets = filingBucketsForForeignSessions(row.sessions); const matched = query || chipFiltersActive ? row.sessions.filter((session) => { if (query && !`${primarySessionLabel(session)} ${row.lane.name}`.toLowerCase().includes(query)) { @@ -1415,6 +1485,7 @@ export const SessionListPane = React.memo(function SessionListPane({ // remote lane instead of incorrectly treating an unknown PR as open. laneHasPr: () => false, laneIsDirty: (laneId) => laneId === row.lane.id && row.lane.status.dirty, + effectiveFilingBuckets, }); }) : row.sessions; @@ -1431,7 +1502,7 @@ export const SessionListPane = React.memo(function SessionListPane({ rows.push(sessions === row.sessions && lane === row.lane ? row : { ...row, lane, sessions }); } return rows.length > 0 ? rows : EMPTY_FOREIGN_ROWS; - }, [chipFiltersActive, foreignRows, laneFilterActive, normalizedFilterLaneId, q, workSessionFilters]); + }, [chipFiltersActive, filingBucketsForForeignSessions, foreignFilingNowMs, foreignRows, laneFilterActive, normalizedFilterLaneId, q, workSessionFilters]); /** * Which shelf a CROSS-MACHINE lane files into, or null to stay in the inbox. @@ -1440,9 +1511,9 @@ export const SessionListPane = React.memo(function SessionListPane({ * goes to the shelf" — or it silently becomes "…unless the work happens to * live on another machine", which is exactly the bug this fixes. So the quiet * test is not re-implemented here: `partitionQuietSessions` runs the same - * `sessionFilingBucket` derivation the local path runs through, which is what - * gives foreign lanes the yield-to-`needs_you` and settled-beats-ended - * precedence for free. + * relationship-aware `effectiveSessionFilingBuckets` derivation the local + * path runs through, which gives foreign lanes the yield-to-`needs_you`, + * settled-beats-ended, and settled-chat-child precedence for free. * * Offline is deliberately NOT consulted. A dropped machine's rows can still * report "running" — that is only the last thing that machine said before it @@ -1452,7 +1523,7 @@ export const SessionListPane = React.memo(function SessionListPane({ * exactly as it would with the machine awake, because the sleeping Mac * has no opinion about work already settled; * - offline must not FORCE one either: a row last reported as running keeps - * its lane upstairs. Hence this reads `quiet.active` rather than + * its lane upstairs. Hence this reads `fullQuiet.active` rather than * `foreignRowHasLiveWork`, which folds `online` in on purpose — but only * for the COLLAPSE default, where "unreachable" really does mean "not * live right now". @@ -1463,7 +1534,18 @@ export const SessionListPane = React.memo(function SessionListPane({ */ const foreignLaneShelving: ForeignLaneEntry[] = visibleForeignRows.map((row) => { const compositeLaneId = `${row.machineId}:${row.lane.id}`; - const quiet = partitionQuietSessions(row.sessions); + // Search and chip filters can hide the parent that makes an attached child + // settled. Resolve relationships against the complete foreign row first, + // then partition the visible slice with that same map. This keeps both the + // shelf decision and the rendered child card on the sidebar's filing rule. + const fullRow = foreignRows.find( + (candidate) => `${candidate.machineId}:${candidate.lane.id}` === compositeLaneId, + ) ?? row; + const effectiveFilingBuckets = filingBucketsForForeignSessions(fullRow.sessions); + const fullQuiet = partitionQuietSessions(fullRow.sessions, effectiveFilingBuckets); + const quiet = row.sessions === fullRow.sessions + ? fullQuiet + : partitionQuietSessions(row.sessions, effectiveFilingBuckets); const shelf = ((): "snoozed" | "settled" | null => { // The same two exemptions `laneShelfByLaneId` grants. A pin is an explicit // "keep this where I can see it"; a Primary is the column's fixed landmark @@ -1472,15 +1554,15 @@ export const SessionListPane = React.memo(function SessionListPane({ // everywhere in this pane, while the pin store only ever writes lane ids. if (workPinnedLaneIdSet.has(compositeLaneId) || workPinnedLaneIdSet.has(row.lane.id)) return null; if (row.lane.laneType === "primary") return null; - if (row.sessions.length === 0 || quiet.active.length > 0) return null; - const quietRows = quiet.snoozed.length + quiet.settled.length; + if (row.sessions.length === 0 || fullQuiet.active.length > 0) return null; + const quietRows = fullQuiet.snoozed.length + fullQuiet.settled.length; if (quietRows === 0) return null; // Dominant kind, ties to Snoozed — the more visible shelf. Identical to // the local rule, and safe for the same reason: the body renders flat and // every card still states its own status. - return quiet.settled.length > quiet.snoozed.length ? "settled" : "snoozed"; + return fullQuiet.settled.length > fullQuiet.snoozed.length ? "settled" : "snoozed"; })(); - return { row, compositeLaneId, quiet, shelf }; + return { row, compositeLaneId, quiet, fullQuiet, shelf }; }); /** @@ -2170,12 +2252,19 @@ export const SessionListPane = React.memo(function SessionListPane({ ); if ( foreignRow - && foreignRowHasLiveWork(foreignRow, partitionQuietSessions(foreignRow.sessions).active) + && foreignRowHasLiveWork( + foreignRow, + partitionQuietSessions( + foreignRow.sessions, + filingBucketsForForeignSessions(foreignRow.sessions), + ).active, + ) ) { toggleWorkSectionCollapsed(marker, { preserveDeeplink: true }); } } }, [ + filingBucketsForForeignSessions, foreignRows, isLaneQuiet, toggleWorkSectionCollapsed, @@ -2462,7 +2551,7 @@ export const SessionListPane = React.memo(function SessionListPane({ * shelve at all. */ const renderForeignLaneGroup = (entry: ForeignLaneEntry) => { - const { row, compositeLaneId, quiet, shelf } = entry; + const { row, compositeLaneId, quiet, fullQuiet, shelf } = entry; // One resolver, one answer — including for Primary, which used to get its // name spelled out here on the theory that two identically-named Primaries // are otherwise indistinguishable. Under the physical-machine rule they are @@ -2478,7 +2567,7 @@ export const SessionListPane = React.memo(function SessionListPane({ // inspectable, not presented as live work. Note this is NOT the shelving // test (see `foreignLaneShelving`) — an offline machine's last-reported // running row collapses the group but keeps the lane in the inbox. - const laneQuiet = !foreignRowHasLiveWork(row, quiet.active); + const laneQuiet = !foreignRowHasLiveWork(row, fullQuiet.active); const laneOpenMarker = `lane-open:${compositeLaneId}`; const collapsed = laneQuiet ? !workCollapsedSectionIds.includes(laneOpenMarker) diff --git a/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx b/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx index 09f2dc93c..db106b2a3 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx @@ -1497,6 +1497,7 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { endedFiltered={work.endedFiltered} settledFiltered={work.settledFiltered} snoozedFiltered={work.snoozedFiltered} + effectiveFilingBuckets={work.effectiveFilingBuckets} allSessionsUnfiltered={work.sessions} loading={work.loading} filterLaneId={work.filterLaneId} diff --git a/apps/desktop/src/renderer/components/terminals/sessionLifecycleActions.ts b/apps/desktop/src/renderer/components/terminals/sessionLifecycleActions.ts index eb4c8e8c0..ff735f8ff 100644 --- a/apps/desktop/src/renderer/components/terminals/sessionLifecycleActions.ts +++ b/apps/desktop/src/renderer/components/terminals/sessionLifecycleActions.ts @@ -17,8 +17,9 @@ import { /** * One place for the Work tab's snooze/wake/keep-active writes, so the sidebar - * row menu, the row context menu, and the chat header chips can never disagree - * about what an action does (or about the copy it confirms with). + * row menu, the row context menu, the chat header snooze affordance, and the + * composer lifecycle pill can never disagree about what an action does (or + * about the copy it confirms with). */ const UNDO_TOAST_MS = 5_000; diff --git a/apps/desktop/src/renderer/components/terminals/useWorkSessions.test.ts b/apps/desktop/src/renderer/components/terminals/useWorkSessions.test.ts index 103856ad0..eef830629 100644 --- a/apps/desktop/src/renderer/components/terminals/useWorkSessions.test.ts +++ b/apps/desktop/src/renderer/components/terminals/useWorkSessions.test.ts @@ -156,6 +156,7 @@ import { forgetWorkPtyLaunchPin, workPtyLaunchPinFor } from "./cliLaunch"; import { invalidateSessionListCache } from "../../lib/sessionListCache"; import { seedCrossMachineOptimisticSession } from "../../state/crossMachineLanes"; import { shouldRefreshSessionListForChatEvent } from "../../lib/chatSessionEvents"; +import { isChatToolType } from "../../lib/sessions"; // --------------------------------------------------------------------------- // window.ade stubs @@ -1844,6 +1845,36 @@ describe("useWorkSessions — refresh-before-focus ordering", () => { expect(listSessionsCachedMock).toHaveBeenLastCalledWith({ limit: 500 }, undefined); }); + it("recomputes snooze filing when Work re-enters after the deadline elapsed off-route", async () => { + const nowMs = Date.parse("2026-08-29T12:00:00.000Z"); + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(nowMs); + const snoozed = makeSession("session-off-route-snooze", "lane-a", { + snoozedUntil: new Date(nowMs + 1_000).toISOString(), + snoozedAt: new Date(nowMs - 1_000).toISOString(), + }); + listSessionsCachedMock.mockResolvedValue([snoozed]); + + const { rerender, result } = renderHook( + ({ active }: { active: boolean }) => useWorkSessions({ active }), + { initialProps: { active: true } }, + ); + await waitFor(() => { + expect(result.current.snoozedFiltered.map((session) => session.id)) + .toEqual(["session-off-route-snooze"]); + }); + + // Work remains mounted while another ADE tab is active, so its deadline + // timer is cleaned up. Advance the clock past expiry, then re-enter. + rerender({ active: false }); + nowSpy.mockReturnValue(nowMs + 2_000); + rerender({ active: true }); + + expect(result.current.snoozedFiltered).toEqual([]); + expect(result.current.runningFiltered.map((session) => session.id)) + .toEqual(["session-off-route-snooze"]); + nowSpy.mockRestore(); + }); + it("preserves saved Work filters when a URL targets a specific session", async () => { const session = { id: "session-1", @@ -3165,6 +3196,44 @@ describe("useWorkSessions — grouping defaults and derived tab order", () => { expect(model.groups[2]!.sessionIds).toEqual(["session-settled"]); }); + it("files a running shell attached to a settled chat in the Settled status group", () => { + const isChatToolTypeMock = vi.mocked(isChatToolType); + isChatToolTypeMock.mockImplementation((toolType) => Boolean(toolType?.toLowerCase().endsWith("-chat"))); + const nowMs = Date.parse("2026-04-01T12:00:00.000Z"); + try { + const parent = makeSession("settled-chat", "lane-a", { + status: "completed" as const, + runtimeState: "exited" as const, + endedAt: "2026-04-01T11:55:00.000Z", + settledAt: "2026-04-01T11:56:00.000Z", + }); + const child = makeSession("attached-shell", "lane-a", { + toolType: "shell" as const, + title: "Attached shell", + startedAt: "2026-04-01T11:58:00.000Z", + chatSessionId: parent.id, + }); + const model = buildWorkTabGroupModel({ + sessions: [parent, child], + lanes: [{ + id: "lane-a", + name: "Lane A", + laneType: "worktree" as const, + createdAt: "2026-04-01T10:00:00.000Z", + color: null as string | null, + }], + organization: "all-lanes-by-status", + collapsedGroupIds: [], + nowMs, + }); + + expect(model.groups.map((group) => group.id)).toEqual(["status:settled"]); + expect(model.groups[0]!.sessionIds).toEqual(["attached-shell", "settled-chat"]); + } finally { + isChatToolTypeMock.mockImplementation(() => false); + } + }); + // Regression: "Until I'm asked" snooze hid an explicitly raised hand. it("does NOT file a snoozed needs-you row into the Snoozed group", () => { const nowMs = Date.parse("2026-04-01T12:00:00.000Z"); @@ -3298,6 +3367,19 @@ describe("useWorkSessions — chip filters and lane ordering", () => { setTimeoutSpy.mockRestore(); }); + it("keeps a snoozed row's wake timer armed when search hides it", async () => { + // Search/lane filters remove rows from `filtered`; the expiry timer must + // still watch the full roster so the filing map is fresh when they return. + seedViewState({ search: "running" }); + const setTimeoutSpy = vi.spyOn(window, "setTimeout"); + const { result } = await renderWithSessions([runningSession, snoozedSession]); + + expect(result.current.filtered.map((session) => session.id)).toEqual(["session-running"]); + expect(result.current.snoozedFiltered).toEqual([]); + expect(setTimeoutSpy).toHaveBeenCalled(); + setTimeoutSpy.mockRestore(); + }); + it("returns the unfiltered list by reference when no chip is set", async () => { const { result } = await renderWithSessions([runningSession, snoozedSession]); const first = result.current.filtered; diff --git a/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts b/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts index 642889be3..24ce20c7e 100644 --- a/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts +++ b/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts @@ -22,6 +22,7 @@ import { import { listSessionsCached, invalidateSessionListCache } from "../../lib/sessionListCache"; import { canonicalInputFromSummary, + effectiveSessionFilingBuckets, sessionCanonicalUiState, sessionFilingBucket, } from "../../lib/terminalAttention"; @@ -194,6 +195,8 @@ export function buildWorkTabGroupModel(args: { collapsedGroupIds: string[]; laneSessionOrder?: Record; pinnedSessionIds?: string[]; + /** Full-roster filing buckets, when the caller has sessions outside this tab slice. */ + effectiveFilingBuckets?: ReadonlyMap>; /** Injectable clock so snooze expiry stays testable (expiry is derived, never scheduled). */ nowMs?: number; }): WorkTabGroupModel { @@ -201,6 +204,9 @@ export function buildWorkTabGroupModel(args: { const collapseSet = new Set(args.collapsedGroupIds); const pinnedSet = new Set(args.pinnedSessionIds ?? []); const laneOrderMap = args.laneSessionOrder ?? {}; + const nowMs = args.nowMs ?? Date.now(); + const effectiveFilingBuckets = args.effectiveFilingBuckets + ?? effectiveSessionFilingBuckets(args.sessions, nowMs); if (args.organization === "by-lane") { const laneOrder = new Map(sortLanesForTabs(args.lanes).map((lane, index) => [lane.id, index] as const)); @@ -306,16 +312,17 @@ export function buildWorkTabGroupModel(args: { return { groups, sessionIds: visibleSessions.map((session) => session.id), visibleSessions }; } - const nowMs = args.nowMs ?? Date.now(); const statusBuckets = new Map(); for (const session of orderedSessions) { // Snooze is a visibility overlay: it pulls the row out of its normal bucket // entirely — the same partitioning the flat sidebar list uses — EXCEPT when // the row's canonical phase is needs_you. The overlay yields to a raised - // hand (`sessionFilingBucket`), which is the only thing that makes + // hand (`effectiveSessionFilingBuckets`, falling back to + // `sessionFilingBucket`), which is the only thing that makes // "Until I'm asked" true for tracked CLI rows: their needs-input state is // derived, so no early-wake event ever fires for them. - const bucket: WorkStatusGroupBucket = sessionFilingBucket(session, nowMs); + const bucket: WorkStatusGroupBucket = effectiveFilingBuckets.get(session.id) + ?? sessionFilingBucket(session, nowMs); const list = statusBuckets.get(bucket) ?? []; list.push(session); statusBuckets.set(bucket, list); @@ -605,6 +612,27 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) } return map; }, [crossMachineSessionsById, localSessionsById]); + // Resolve filing against the complete Work roster, including foreign rows + // kept open in this tab. An attached shell must follow its settled chat even + // when either row belongs to a different machine. + const allKnownSessions = useMemo( + () => [...sessionsById.values()], + [sessionsById], + ); + const filingNowMs = useMemo(() => { + // The epoch is a deadline tick; reading it makes this clock refresh when a + // snooze expires even if the roster array retains its identity. Reading the + // route also refreshes it when Work is re-entered after a deadline elapsed + // while the page was parked on another tab (the timer is intentionally + // disabled off-route). + void snoozeEpoch; + void isWorkRoute; + return Date.now(); + }, [isWorkRoute, snoozeEpoch]); + const effectiveFilingBuckets = useMemo( + () => effectiveSessionFilingBuckets(allKnownSessions, filingNowMs), + [allKnownSessions, filingNowMs], + ); const sessionsByIdRef = useRef(sessionsById); useLayoutEffect(() => { sessionsByIdRef.current = sessionsById; @@ -652,10 +680,9 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) collapsedGroupIds: workCollapsedTabGroupIds, laneSessionOrder, pinnedSessionIds, + effectiveFilingBuckets, }), - // `snoozeEpoch` re-derives the by-status snoozed group when a deadline lapses. - // eslint-disable-next-line react-hooks/exhaustive-deps - [lanes, openSessions, sessionListOrganization, workCollapsedTabGroupIds, laneSessionOrder, pinnedSessionIds, snoozeEpoch], + [effectiveFilingBuckets, lanes, openSessions, sessionListOrganization, workCollapsedTabGroupIds, laneSessionOrder, pinnedSessionIds], ); const visibleSessions = openSessions; @@ -1542,14 +1569,14 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) return matchesWorkSearchFilters(parsedQuery.filters, { lane: [session.laneName], provider: [session.toolType ?? ""], - status: [sessionFilingBucket(session, nowMs)], + status: [effectiveFilingBuckets.get(session.id) ?? sessionFilingBucket(session, nowMs)], type: [ session.toolType ?? "", isChatToolType(session.toolType) ? "chat" : "terminal", ], }); }); - }, [sessions, filterLaneId, q]); + }, [effectiveFilingBuckets, sessions, filterLaneId, q]); const prsByLaneId = useLanePrsByLaneId(); const laneStatusById = useMemo(() => { @@ -1577,12 +1604,10 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) // use the machine-scoped lookups instead — see `lanePrsForMachine`. laneHasPr: (laneId: string) => laneHasAnyPr(prsByLaneId, laneId), laneIsDirty: (laneId: string) => laneStatusById.get(laneId)?.status.dirty === true, + effectiveFilingBuckets, }; return filtered.filter((session) => matchesWorkSessionFilters(session, workSessionFilters, ctx)); - // `snoozeEpoch` matters here too: a lapsing snooze changes a row's filing - // bucket, which is what the status chips match on. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [filtered, workSessionFilters, prsByLaneId, laneStatusById, snoozeEpoch]); + }, [effectiveFilingBuckets, filtered, workSessionFilters, prsByLaneId, laneStatusById]); const { runningFiltered, @@ -1604,7 +1629,8 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) // hand. A needs_you row is filed normally even while snoozed, which // keeps "Until I'm asked" honest. const phase = sessionCanonicalUiState(canonicalInputFromSummary(session)).phase; - const filingBucket = sessionFilingBucket(session, nowMs); + const filingBucket = effectiveFilingBuckets.get(session.id) + ?? sessionFilingBucket(session, nowMs); if (filingBucket === "snoozed") { snoozed.push(session); continue; @@ -1626,25 +1652,23 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) settledFiltered: settled.sort(compareSessionsBySettledAtDesc), snoozedFiltered: snoozed.sort(compareSessionsByWakeAtAsc), }; - // `snoozeEpoch` re-partitions when the soonest snooze deadline lapses; there - // is no snooze scheduler anywhere, expiry is always derived from now. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [chipFiltered, snoozeEpoch]); + }, [chipFiltered, effectiveFilingBuckets]); // Exactly one timer, armed only while something is actually snoozed, firing at // the soonest deadline (clamped so a 100-year "until I'm asked" snooze can't // overflow setTimeout). No polling and no document-level listener. // - // Reads `filtered`, NOT `chipFiltered`, on purpose: a snoozed row hidden by a - // status chip must still schedule its own wake, or it would never return. + // Reads the complete Work roster, NOT a filtered view, on purpose: a snoozed + // row hidden by search, lane, status chips, or a foreign-machine slice must + // still schedule its own wake so its filing bucket is fresh when visible. useEffect(() => { if (!isWorkRoute) return undefined; - const deadlineMs = nextSnoozeDeadlineMs(filtered); + const deadlineMs = nextSnoozeDeadlineMs(allKnownSessions); if (deadlineMs == null) return undefined; const delay = Math.min(Math.max(deadlineMs - Date.now(), 250), SNOOZE_TICK_MAX_DELAY_MS); const timer = window.setTimeout(() => setSnoozeEpoch((value) => value + 1), delay); return () => window.clearTimeout(timer); - }, [filtered, isWorkRoute, snoozeEpoch]); + }, [allKnownSessions, isWorkRoute, snoozeEpoch]); const sessionsGroupedByLane = useMemo(() => { if (sessionListOrganization !== "by-lane") return null; @@ -2107,6 +2131,7 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) endedFiltered, settledFiltered, snoozedFiltered, + effectiveFilingBuckets, runningSessions, visibleSessions, gridLayoutId, diff --git a/apps/desktop/src/renderer/components/terminals/workSessionFilters.ts b/apps/desktop/src/renderer/components/terminals/workSessionFilters.ts index 48d9616ea..61886fdf2 100644 --- a/apps/desktop/src/renderer/components/terminals/workSessionFilters.ts +++ b/apps/desktop/src/renderer/components/terminals/workSessionFilters.ts @@ -140,6 +140,8 @@ export type WorkSessionFilterContext = { nowMs: number; laneHasPr: (laneId: string) => boolean; laneIsDirty: (laneId: string) => boolean; + /** Optional full-roster filing map so attached children follow settled parents. */ + effectiveFilingBuckets?: ReadonlyMap; }; export function matchesWorkSessionFilters( @@ -152,7 +154,9 @@ export function matchesWorkSessionFilters( // "Your move" section always agree about which rows they mean. if ( filters.status.length > 0 - && !filters.status.includes(sessionFilingBucket(session, ctx.nowMs)) + && !filters.status.includes( + ctx.effectiveFilingBuckets?.get(session.id) ?? sessionFilingBucket(session, ctx.nowMs), + ) ) return false; if ( diff --git a/apps/desktop/src/renderer/components/work/SessionLifecycleChips.test.tsx b/apps/desktop/src/renderer/components/work/SessionLifecycleChips.test.tsx index 7fb3b629b..d169ded07 100644 --- a/apps/desktop/src/renderer/components/work/SessionLifecycleChips.test.tsx +++ b/apps/desktop/src/renderer/components/work/SessionLifecycleChips.test.tsx @@ -1,15 +1,11 @@ /* @vitest-environment jsdom */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import type { TerminalSessionSummary } from "../../../shared/types"; import { useAppStore } from "../../state/appStore"; import { SessionSnoozeChip } from "./SessionLifecycleChips"; -vi.mock("../app/toast/toastStore", () => ({ - showToast: vi.fn(), -})); - const PROJECT_ROOT = "/tmp/project"; function makeSession(overrides: Partial = {}): TerminalSessionSummary { @@ -43,17 +39,47 @@ function seedSessions(sessions: TerminalSessionSummary[]): void { project: { rootPath: PROJECT_ROOT } as never, projectBinding: null, sessionsCacheByProject: { [PROJECT_ROOT]: sessions }, + crossMachineLanesByMachineId: {}, }); } +function seedForeignSessions(sessions: TerminalSessionSummary[]): void { + useAppStore.setState({ + project: { rootPath: PROJECT_ROOT } as never, + projectBinding: null, + sessionsCacheByProject: { [PROJECT_ROOT]: [] }, + crossMachineLanesByMachineId: { + "machine-foreign": { + machineId: "machine-foreign", + machineName: "Mac Studio (12)", + targetId: "target-foreign", + projectId: "project-foreign", + binding: { + kind: "remote", + key: "remote:target-foreign:project-foreign", + targetId: "target-foreign", + runtimeName: "Mac Studio (12)", + projectId: "project-foreign", + rootPath: "/repo-foreign", + displayName: "Foreign repo", + }, + online: true, + lanes: [], + sessions, + prs: [], + lastSyncedAtMs: Date.now(), + error: null, + }, + }, + } as never); +} + describe("SessionSnoozeChip", () => { let sessionsApi: Record>; beforeEach(() => { sessionsApi = { wakeSession: vi.fn().mockResolvedValue(true), - unsettle: vi.fn().mockResolvedValue(undefined), - setSettleOverride: vi.fn().mockResolvedValue(true), }; Object.defineProperty(window, "ade", { configurable: true, @@ -62,8 +88,10 @@ describe("SessionSnoozeChip", () => { }); afterEach(() => { + vi.useRealTimers(); cleanup(); useAppStore.setState({ sessionsCacheByProject: {} }); + useAppStore.setState({ crossMachineLanesByMachineId: {} }); Reflect.deleteProperty(window, "ade"); vi.clearAllMocks(); }); @@ -98,7 +126,53 @@ describe("SessionSnoozeChip", () => { expect(container.firstChild).toBeNull(); expect(screen.queryByTestId("chat-session-settled-chip")).toBeNull(); - expect(sessionsApi.unsettle).not.toHaveBeenCalled(); - expect(sessionsApi.setSettleOverride).not.toHaveBeenCalled(); + }); + + it("resolves a foreign snoozed row and routes Wake now to its owning runtime", async () => { + const foreign = makeSession({ + id: "foreign-session-1", + snoozedUntil: new Date(Date.now() + 3_600_000).toISOString(), + snoozedAt: new Date(Date.now() - 60_000).toISOString(), + }); + const runtimePin = { + kind: "remote" as const, + key: "remote:target-foreign:project-foreign", + targetId: "target-foreign", + runtimeName: "Mac Studio (12)", + projectId: "project-foreign", + rootPath: "/repo-foreign", + displayName: "Foreign repo", + }; + seedForeignSessions([foreign]); + render(); + + expect(screen.getByTestId("chat-session-snoozed-chip")).toBeTruthy(); + fireEvent.click(screen.getByTestId("chat-session-snoozed-chip")); + fireEvent.click(screen.getByRole("menuitem", { name: "Wake now" })); + + await waitFor(() => expect(sessionsApi.wakeSession).toHaveBeenCalledWith( + foreign.id, + "manual", + runtimePin, + )); + }); + + it("repaints a foreign snoozed chip when its deadline expires", () => { + vi.useFakeTimers(); + const nowMs = Date.parse("2026-08-29T12:00:00.000Z"); + vi.setSystemTime(nowMs); + seedForeignSessions([makeSession({ + id: "foreign-session-expiring", + snoozedUntil: new Date(nowMs + 1_000).toISOString(), + snoozedAt: new Date(nowMs - 60_000).toISOString(), + })]); + const { container } = render(); + + expect(screen.getByTestId("chat-session-snoozed-chip")).toBeTruthy(); + act(() => { + vi.advanceTimersByTime(1_250); + }); + + expect(container.firstChild).toBeNull(); }); }); diff --git a/apps/desktop/src/renderer/components/work/SessionLifecycleChips.tsx b/apps/desktop/src/renderer/components/work/SessionLifecycleChips.tsx index 863e14755..79ef82399 100644 --- a/apps/desktop/src/renderer/components/work/SessionLifecycleChips.tsx +++ b/apps/desktop/src/renderer/components/work/SessionLifecycleChips.tsx @@ -1,9 +1,9 @@ -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Moon } from "@phosphor-icons/react"; -import type { TerminalSessionSummary } from "../../../shared/types"; -import { selectActiveProjectStateKey, useAppStore } from "../../state/appStore"; -import { isSessionSnoozed, snoozeWakeLabel } from "../../lib/sessionSnooze"; +import type { OpenProjectBinding, TerminalSessionSummary } from "../../../shared/types"; +import { selectActiveProjectStateKey, useAppStore, useRootAppStore } from "../../state/appStore"; +import { isSessionSnoozed, nextSnoozeDeadlineMs, snoozeWakeLabel } from "../../lib/sessionSnooze"; import { wakeSessionNow } from "../terminals/sessionLifecycleActions"; import { cn } from "../ui/cn"; @@ -17,10 +17,12 @@ import { cn } from "../ui/cn"; const CHIP_CLASS = "inline-flex h-5 shrink-0 items-center gap-1 rounded-md border border-white/[0.10] bg-white/[0.04] px-1.5 font-sans text-[10px] font-medium text-muted-fg/75 transition-colors hover:border-white/[0.18] hover:text-fg/85"; +const LIFECYCLE_TICK_MAX_DELAY_MS = 10 * 60 * 1000; /** - * Read a chat's terminal-session row out of the per-project cache the Work tab - * already mirrors into the store. No extra IPC, and it stays as fresh as the + * Read a chat's terminal-session row out of the local per-project cache the Work + * tab already mirrors into the store, falling back to the root cross-machine + * snapshot for a foreign chat. No extra IPC, and it stays as fresh as the * sidebar it is mirroring. */ export function useSessionLifecycleSnapshot( @@ -30,11 +32,35 @@ export function useSessionLifecycleSnapshot( const cached = useAppStore((state) => (projectStateKey ? state.sessionsCacheByProject[projectStateKey] : undefined), ); - return useMemo(() => { + const crossMachineLanesByMachineId = useRootAppStore((state) => state.crossMachineLanesByMachineId); + const snapshot = useMemo(() => { const id = sessionId?.trim(); - if (!id || !cached) return null; - return cached.find((session) => session.id === id) ?? null; - }, [cached, sessionId]); + if (!id) return null; + const local = cached?.find((session) => session.id === id); + if (local) return local; + for (const machine of Object.values(crossMachineLanesByMachineId)) { + const foreign = machine.sessions.find((session) => session.id === id); + if (foreign) return foreign; + } + return null; + }, [cached, crossMachineLanesByMachineId, sessionId]); + + // A snooze is represented by a persisted deadline, not a scheduler event. + // Arm one deadline timer here so an open chat header/composer re-renders when + // the row becomes live even if the session cache object never changes. + const [lifecycleEpoch, setLifecycleEpoch] = useState(0); + useEffect(() => { + const deadlineMs = nextSnoozeDeadlineMs(snapshot ? [snapshot] : []); + if (deadlineMs == null) return undefined; + const delay = Math.min( + Math.max(deadlineMs - Date.now(), 250), + LIFECYCLE_TICK_MAX_DELAY_MS, + ); + const timer = window.setTimeout(() => setLifecycleEpoch((value) => value + 1), delay); + return () => window.clearTimeout(timer); + }, [lifecycleEpoch, snapshot]); + + return snapshot; } function ChipMenu({ @@ -76,9 +102,11 @@ function ChipMenu({ export function SessionSnoozeChip({ sessionId, className, + runtimePin = null, }: { sessionId: string | null | undefined; className?: string; + runtimePin?: OpenProjectBinding | null; }) { const session = useSessionLifecycleSnapshot(sessionId); const [menuOpen, setMenuOpen] = useState(false); @@ -110,7 +138,7 @@ export function SessionSnoozeChip({ label="Snoozed session" onClose={() => setMenuOpen(false)} items={[ - { key: "wake", label: "Wake now", onSelect: () => { void wakeSessionNow(session); } }, + { key: "wake", label: "Wake now", onSelect: () => { void wakeSessionNow(session, runtimePin); } }, ]} /> ) : null} diff --git a/apps/desktop/src/renderer/components/work/WorkSurfaceHeader.tsx b/apps/desktop/src/renderer/components/work/WorkSurfaceHeader.tsx index b70de633e..456d7dd26 100644 --- a/apps/desktop/src/renderer/components/work/WorkSurfaceHeader.tsx +++ b/apps/desktop/src/renderer/components/work/WorkSurfaceHeader.tsx @@ -303,7 +303,7 @@ export function WorkSurfaceHeader({ /> ) : null} {laneId ? : null} - {snoozeSessionId ? : null} + {snoozeSessionId ? : null} {showCacheBadge ? ( ) : null} diff --git a/apps/desktop/src/renderer/lib/terminalAttention.test.ts b/apps/desktop/src/renderer/lib/terminalAttention.test.ts index 61245860c..7e7560e50 100644 --- a/apps/desktop/src/renderer/lib/terminalAttention.test.ts +++ b/apps/desktop/src/renderer/lib/terminalAttention.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vitest"; +import type { TerminalSessionSummary } from "../../shared/types"; import { + effectiveSessionFilingBuckets, runningSessionNeedsAttention, sanitizeTerminalInlineText, sessionNeedsChatTabHighlight, @@ -30,6 +32,54 @@ describe("terminalAttention", () => { ).toBe("running"); }); + it("preserves an explicitly snoozed child when its chat parent is settled", () => { + const nowMs = Date.parse("2026-08-01T10:00:00.000Z"); + const parent = { + id: "chat-parent", + laneId: "lane-1", + laneName: "lane-1", + ptyId: null, + tracked: true, + pinned: false, + goal: null, + toolType: "codex-chat", + title: "Settled chat", + status: "completed", + startedAt: "2026-08-01T09:00:00.000Z", + endedAt: "2026-08-01T09:30:00.000Z", + exitCode: 0, + transcriptPath: "", + headShaStart: null, + headShaEnd: null, + lastOutputPreview: "Done", + summary: null, + runtimeState: "idle", + settledAt: "2026-08-01T09:30:00.000Z", + resumeCommand: null, + } as TerminalSessionSummary; + const child = { + ...parent, + id: "shell-child", + ptyId: "pty-child", + toolType: "shell", + title: "Attached shell", + status: "running", + startedAt: "2026-08-01T09:45:00.000Z", + endedAt: null, + exitCode: null, + runtimeState: "running", + settledAt: null, + chatSessionId: parent.id, + snoozedUntil: "2026-08-01T12:00:00.000Z", + snoozedAt: "2026-08-01T09:50:00.000Z", + } as TerminalSessionSummary; + + const buckets = effectiveSessionFilingBuckets([parent, child], nowMs); + + expect(buckets.get(parent.id)).toBe("settled"); + expect(buckets.get(child.id)).toBe("snoozed"); + }); + it("removes cursor save and restore escapes from inline previews", () => { expect(sanitizeTerminalInlineText("\u001b7Claude Code\u001b8 ready")).toBe("Claude Code ready"); }); diff --git a/apps/desktop/src/renderer/lib/terminalAttention.ts b/apps/desktop/src/renderer/lib/terminalAttention.ts index bc37ba855..e991998a1 100644 --- a/apps/desktop/src/renderer/lib/terminalAttention.ts +++ b/apps/desktop/src/renderer/lib/terminalAttention.ts @@ -321,6 +321,7 @@ export function effectiveSessionFilingBuckets( !parent || parent.laneId !== session.laneId || !isChatToolType(parent.toolType) + || buckets.get(session.id) === "snoozed" || buckets.get(parent.id) !== "settled" || sessionNeedsYou(canonicalInputFromSummary(session)) ) continue; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9676a3eee..d198a2ba2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1074,7 +1074,7 @@ Electron renderer runtime does **not** wrap the app in `React.StrictMode`. Brows - Narrow selectors on components to minimize re-renders. - `refreshLanes` accepts independent lane-status and lane-snapshot flags. Callers can refresh cheap runtime snapshot decorations without recomputing git status, or update git status without rebuilding conflict/rebase/auto-rebase overlays; statusless refreshes preserve the previous `LaneStatus`/`parentStatus` in store so the UI does not flicker to unknown git state. - Per-project work-view state keyed by project identity (`WorkProjectViewState`): local bindings use their root path, while remote bindings use `OpenProjectBinding.key` (`remote::`). Alongside Work filters, collapsed section ids, and right-sidebar state, version 3 includes the Lanes tab's filter, pinned lane ids, and expanded lane id. Version 4 adds the Work-sidebar-only `workPinnedLaneIds`, lane sort mode, sparse manual lane order, and structured session chips; normalization is additive, so a v3 blob retains the former Created order with no Work pins or chips. It keeps Settled reachable as a quiet collapsed tail: Status/Time mode use `status:settled`, Lane mode uses explicit `settled-open:` markers, and a fully quiet lane uses the inverted `lane-open:` marker. There is no independent Tiers/Show settled filter. The one-time status-collapse migration is gated against its own version-2 threshold, so later additive schema bumps do not re-collapse a section the user expanded. Persistence under `ade.workViewState.v1` is a scoped delta read-modify-write: every mounted project store upserts or deletes only the project/lane keys it owns instead of flushing its stale copy of the whole map. `refreshLanes` prunes lane scopes only from a non-empty lane inventory and only inside that store's project scope. `registerProjectSurfaceStore` / `workViewStoreForProject` route project-specific writes made by `AppShell` and `TopBar`, which render above `AppStoreProvider`, to the owning surface store. Lane/status deeplinks are transient view overrides and user filter, grouping, chip, pin, or ordering changes return to and then update the saved base. The right-edge fields are `workSidebarOpen`, `workSidebarTab` (`"git" | "files" | "ios" | "app-control" | "browser"`), and `workSidebarWidthPct` (clamped 26–55). The sidebar consolidates lane-scoped tools that were previously split across separate floating panes; per-chat iOS / App Control drawers still exist on `AgentChatPane` but are suppressed when the chat is mounted as a Work tile so the sidebar owns those surfaces at lane scope. Remote-bound Work sidebars expose only the runtime-backed Git and Files tabs; local-only iOS Simulator, App Control, and Browser panes stay hidden. The `browser` tab is not lane-scoped on local bindings: each ADE window/project keeps its own tab collection and inspect state, while browser authentication storage is global to the installation/channel through `persist:ade-browser`. -- Cross-machine Work union. `crossMachineLanesByMachineId` + `crossMachineLaneScopeKey` (fed by `renderer/state/crossMachineLanes.ts`) hold every *other* connected machine's lanes, sessions, and mapped PR rows for the repository the active tab is showing, so the Work sidebar can list work in flight anywhere — chats and CLI/shell sessions alike, each with its own PR badge — without changing the tab's binding. Keyed by machine because a lane owns its machine (`lanes.worktree_path` is an absolute path on exactly one machine) and both sessions and PR rows inherit theirs through `laneId` — there is no per-session or per-PR machine field. PRs are read on the lane cadence rather than the chat cadence and ride along with the lane read (see [features/pull-requests/README.md](./features/pull-requests/README.md#which-machine-answers-a-pr-read)). `mergeCrossMachineLanes` retains omitted `lanes`/`sessions`/`prs` so a failed read leaves the machine's rows on screen, and `setCrossMachineMachinesOnline` flags rather than deletes an entry that goes offline — the sidebar renders it dimmed, collapsed, and inert, and the retained slice also backs the push-divergence guard. Presence is decided in one place, `applyReachability`, from connection state alone: a drop is believed only after a reconnect attempt has completed and failed (`connecting` observed, then a non-connected state) plus a 45 s floor, with a 120 s ceiling for a dial that never finishes. Two states have no attempt left to wait for and dim on the floor alone: `idle`, which will not redial on its own, and `connected` but unable to re-prove this repository, which answers yet is never read for it. The floor and the ceiling are one deadline, not two rules. The `connecting`/`error` states a redial or sleep/wake publishes therefore do not reflow the sidebar, and reconnecting is applied instantly. The verdict belongs to the store rather than the tick: a machine that is already dimmed stays dimmed until it is eligible again, so a Work-tab remount — which tears down the shared runtime and its drop records while the store slice survives — cannot re-brighten it for another floor; the retention deadline is re-anchored to that machine's last successful read instead. Only `dropCrossMachineLanes` deletes, and only for a target gone from the snapshot, a connected machine that positively reports the repository missing *and* has a resolvable origin to prove it by (`repoMatchFor` will say "missing" off a folder-name mismatch alone, and the scope's origin is transiently null while the bound machine blips), or 24 hours unreachable. The scope key is per repository, so a project-tab switch invalidates the union wholesale. A detached chat launch whose runtime pin differs from the active binding seeds an optimistic summary directly into the owning machine slice; the binding/session-keyed pending record survives stale in-flight list responses and is replaced by the authoritative row with the same stable session id (or pruned on delete/expiry). This keeps the active binding free of foreign UUID-lane placeholders while avoiding a blank interval before the next remote list arrives. `buildCrossMachineLaneRows` also takes the active binding's own session ids and holds one claim set across every machine slice, so a session is contributed by exactly one list — local first, then the first machine that reports the lane it names — and neither an optimistic launch nor two slices naming the same session can render it twice; the hook stabilizes that id set by content, since the roster array is replaced by every session poll. Foreign lanes use the same `sessionFilingBucket` active/snoozed/settled partition, fully quiet collapsed header, and quiet-tail renderer as local lanes, with composite machine/lane persistence keys. Their **Manage lane** dialog and mutations carry the same owning binding. Sessions whose referenced lane is absent render as explicit warning-tinted **Orphaned sessions** groups with a refresh-only recovery action; ADE preserves the sessions and never interprets unknown ownership as permission to mutate the active machine. Refreshes ride the connection-snapshot subscription plus existing lane-lifecycle/session-changed events (coalesced, bounded, timed out, capped in parallelism) and a fallback loop for machines with no renderer change feed; that loop is paused entirely while the window is hidden, re-reads chats every 10 s, and re-reads lanes on a 30 s cadence, because `lane.list` with `includeStatus` costs a git status per lane on the other machine. A chat naming a lane that machine has never reported forces the lane read immediately, but only once: lane ids a completed read did not explain are remembered until the next one, because `session.list` does not filter on lane status while `lane.list` excludes archived lanes, so a chat on an archived lane is permanently unresolvable and would otherwise demand the expensive read on every tick forever. Foreign reads never gate the local list. `selectOtherMachineBranchStates` is the memoized selector the push-divergence guard reads at click time. +- Cross-machine Work union. `crossMachineLanesByMachineId` + `crossMachineLaneScopeKey` (fed by `renderer/state/crossMachineLanes.ts`) hold every *other* connected machine's lanes, sessions, and mapped PR rows for the repository the active tab is showing, so the Work sidebar can list work in flight anywhere — chats and CLI/shell sessions alike, each with its own PR badge — without changing the tab's binding. Keyed by machine because a lane owns its machine (`lanes.worktree_path` is an absolute path on exactly one machine) and both sessions and PR rows inherit theirs through `laneId` — there is no per-session or per-PR machine field. PRs are read on the lane cadence rather than the chat cadence and ride along with the lane read (see [features/pull-requests/README.md](./features/pull-requests/README.md#which-machine-answers-a-pr-read)). `mergeCrossMachineLanes` retains omitted `lanes`/`sessions`/`prs` so a failed read leaves the machine's rows on screen, and `setCrossMachineMachinesOnline` flags rather than deletes an entry that goes offline — the sidebar renders it dimmed, collapsed, and inert, and the retained slice also backs the push-divergence guard. Presence is decided in one place, `applyReachability`, from connection state alone: a drop is believed only after a reconnect attempt has completed and failed (`connecting` observed, then a non-connected state) plus a 45 s floor, with a 120 s ceiling for a dial that never finishes. Two states have no attempt left to wait for and dim on the floor alone: `idle`, which will not redial on its own, and `connected` but unable to re-prove this repository, which answers yet is never read for it. The floor and the ceiling are one deadline, not two rules. The `connecting`/`error` states a redial or sleep/wake publishes therefore do not reflow the sidebar, and reconnecting is applied instantly. The verdict belongs to the store rather than the tick: a machine that is already dimmed stays dimmed until it is eligible again, so a Work-tab remount — which tears down the shared runtime and its drop records while the store slice survives — cannot re-brighten it for another floor; the retention deadline is re-anchored to that machine's last successful read instead. Only `dropCrossMachineLanes` deletes, and only for a target gone from the snapshot, a connected machine that positively reports the repository missing *and* has a resolvable origin to prove it by (`repoMatchFor` will say "missing" off a folder-name mismatch alone, and the scope's origin is transiently null while the bound machine blips), or 24 hours unreachable. The scope key is per repository, so a project-tab switch invalidates the union wholesale. A detached chat launch whose runtime pin differs from the active binding seeds an optimistic summary directly into the owning machine slice; the binding/session-keyed pending record survives stale in-flight list responses and is replaced by the authoritative row with the same stable session id (or pruned on delete/expiry). This keeps the active binding free of foreign UUID-lane placeholders while avoiding a blank interval before the next remote list arrives. `buildCrossMachineLaneRows` also takes the active binding's own session ids and holds one claim set across every machine slice, so a session is contributed by exactly one list — local first, then the first machine that reports the lane it names — and neither an optimistic launch nor two slices naming the same session can render it twice; the hook stabilizes that id set by content, since the roster array is replaced by every session poll. Foreign lanes use the same `effectiveSessionFilingBuckets` active/snoozed/settled partition (which wraps the base `sessionFilingBucket` rule), fully quiet collapsed header, and quiet-tail renderer as local lanes, with composite machine/lane persistence keys. Their **Manage lane** dialog and mutations carry the same owning binding. Sessions whose referenced lane is absent render as explicit warning-tinted **Orphaned sessions** groups with a refresh-only recovery action; ADE preserves the sessions and never interprets unknown ownership as permission to mutate the active machine. Refreshes ride the connection-snapshot subscription plus existing lane-lifecycle/session-changed events (coalesced, bounded, timed out, capped in parallelism) and a fallback loop for machines with no renderer change feed; that loop is paused entirely while the window is hidden, re-reads chats every 10 s, and re-reads lanes on a 30 s cadence, because `lane.list` with `includeStatus` costs a git status per lane on the other machine. A chat naming a lane that machine has never reported forces the lane read immediately, but only once: lane ids a completed read did not explain are remembered until the next one, because `session.list` does not filter on lane status while `lane.list` excludes archived lanes, so a chat on an archived lane is permanently unresolvable and would otherwise demand the expensive read on every tick forever. Foreign reads never gate the local list. `selectOtherMachineBranchStates` is the memoized selector the push-divergence guard reads at click time. The union's pin-resolution hooks live with the store rather than in the components that consume them: `machineEntryForBinding(state, pin)` / `useMachineEntryForBinding(pin)` join by binding **key**, not machine id, because `machineId` is only known once a machine has answered while a pin carries its routing target from the moment a chat is selected; `useLanesForPin(pin)` resolves the lane list for a pin; `useForeignSessionLaneId(sessionId, presentLocally)` names the lane a foreign session belongs to. `crossMachineLanesByMachineId` is written only to the ROOT store (`rootAppStoreApi`) and `createProjectAppStore` does not copy it, so a project-scoped `useAppStore` read of it sees an empty record forever — `useMachineEntryForBinding` and `useForeignSessionLaneId` therefore read `useRootAppStore`, and `useLanesForPin` reads each half from the store that owns it (the union from the root store, the warm `laneCacheByProject` lane cache from the surrounding project-scoped store). `useLanesForPin` is the only list a pinned lane id may be resolved against. Never `state.lanes`: lane ids are unique per machine rather than globally, so falling back to the tab-bound machine's list can match a *different* lane sharing the id and hand its worktree path to a tool about to drive the other machine. An unread machine yields an empty list, which surfaces as "not found" instead of as the wrong lane, and the hook returns `null` for an absent pin so callers keep their own unpinned source (`state.lanes`, `availableLanes`) explicitly rather than by accident. - Project tab bookkeeping. `openProjectTabRoots: string[]` tracks local roots open in the window (mirrored to the main process via `ade.app.setWindowProjectTabs` so background services keep those projects warm); `openRemoteProjectTabs` tracks full remote bindings so inactive remote tabs remain first-class retained surfaces. `ProjectTabHost` applies one shared eight-surface LRU across local and remote tabs: inactive mounted surfaces are hidden, inert, and animation-paused, while an open surface that falls outside the bound snapshots its scoped state back into the root caches before unmounting. `projectInfoByRoot: Record` caches local `ProjectInfo` payloads for tab favicons and offline tab rendering. - Stale-while-revalidate switch caches. `laneSelectionByProject` remembers the `{ laneId, sessionId }` selection per project identity so switching tabs lands on the lane/chat the user last had open instead of "first lane". `laneCacheByProject` mirrors the last good `{ lanes, laneSnapshots }`; local and remote switches apply it immediately (no spinner, no chat-pane unmount) and refresh silently in the background. `sessionsCacheByProject` does the same for `useWorkSessions` so chat tabs and terminal grids do not blank during a tab swap. `projectRouteStorage.ts` persists the last route under the binding key, independent of whether the surface is currently mounted. Cache pruning retains every open local root and remote binding key; tab close and target disconnect deliberately evict only their affected remote state. The two eviction paths are distinct: `evictProjectState(key)` + `removeStoredProjectRoute(key)` is the full "forget this surface" wipe used by an explicit tab close and by removing a machine, while `evictProjectDataCaches(key)` is the disconnect-only sibling that drops just the snapshots that can go stale while a remote is unreachable (`laneCacheByProject`, `laneSelectionByProject`, `sessionsCacheByProject`, and the persisted lane cache) and preserves `workViewByProject` / `laneWorkViewByScope` plus the stored route so reconnecting restores the chat or tile that was open. `closeProject({ preserveRemoteViewState: true })` applies the same narrower rule when a disconnect closes the last remaining tab. diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index 5f6e6f8e0..e5838f3c7 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -106,7 +106,7 @@ for its separate RPC, sync, storage, and UI contracts. | `apps/desktop/src/shared/chatMosaic.ts` | Mosaic v1 — agent-emitted interactive cards. Strict versioned (`"v":1`) parser for ```` ```mosaic ```` fence bodies (`parseMosaicCard`: unknown version/element types, duplicate ids, or malformed JSON → null → callers render the plain fence), submission serializer (`serializeMosaicSubmission`: readable lines + machine JSON, sent through the normal `agentChat.send` path with `displayText`), and `summarizeMosaicCard` for the TUI's one-line summary. Data only — no expressions, no eval, no host actions. Schema documented for agents in the `ade-mosaic` Agent Skill (`apps/desktop/resources/agent-skills/ade-mosaic/SKILL.md`). | | `apps/desktop/src/renderer/components/chat/MosaicCard.tsx` | Interactive mosaic card renderer (text, select, multiselect, number/slider, input, approve/deny, key-value table). Hooked in at `MarkdownBlock`'s code-fence handler behind a Claude-gated `mosaic` context prop from `AgentChatPane`; answered state persists across virtualized unmounts via a session-lifetime latch that rolls back on send failure. Non-Claude sessions render the plain fence. | | `apps/desktop/src/shared/types/chat.ts` | All chat types: `AgentChatSession`, `AgentChatEvent` union, `AgentChatEventHistorySnapshot` (with optional `sessionFound` for stale-session detection), provider-neutral `AgentChatMcpToolSource` / app context metadata, Codex goal/token-usage/runtime-state DTOs (`CodexSafetyBufferingState`, moderation metadata, sleep/thread-deleted/stall events), the `web_search` event's structured `CodexWebSearchResult[]` (`results`, max 8) plus `resultsTotal`, typed Codex goal/recovery control args, image generation/view events with large-inline-payload omission metadata, permission modes, pending input (including app-server `autoResolutionMs`), completion reports, `AgentChatMessageSession*` peer-message routing DTOs (`auto` / `queue` / `wake` / `interrupt-replace`), `AgentChatCreateScheduledWork*`, `AgentChatSetScheduledWorkPaused*`, `PARALLEL_CHAT_MAX_ATTACHMENTS`, and parallel launch state DTOs. `AgentChatSubagentSnapshot.label` carries provider-assigned display labels such as Codex Agent #N. `AgentChatScheduledWakeMetadata` marks synthetic unattended user turns with schedule id, kind, fire time, reason, and late state. `scheduled_work_update` captures action/Claude wake/cron/background lifecycle including `paused`, `firedAt`, and `late`; `AgentChatSessionSummary.nextWakeAt` and `scheduledWorkPaused` project durable scheduler state into session lists. `transcript_retraction` removes provider-superseded assistant text from renderers without rewriting the persisted JSONL stream. `user_message` events may also carry metadata such as `hideFullPrompt` for internal handoff briefs, while `displayText` remains the user-facing transcript text. `AgentChatSessionSummary.linearIssueLinks?: SessionLinearIssueLink[]` carries the Linear issues attached to the session (chat or CLI), populated from `session_linear_issues` independent of any lane link. The `session_meta_updated` event additionally carries optional permission/interaction mode fields (`permissionMode`, `interactionMode`, `claudePermissionMode`, `codexApprovalPolicy`, `codexSandbox`, `codexConfigSource`, `opencodePermissionMode`, `droidPermissionMode`, `cursorModeId`, `cursorModeSnapshot`) so a mode change made on one client patches every other client's composer state; a title-only emit carries none of them and stays backward-compatible. | -| `apps/desktop/src/renderer/components/chat/AgentChatPane.tsx` | Top-level renderer surface: state derivation, IPC wiring, composer mount, message-list mount, End/Delete chat controls in the header, parallel multi-model lane launch orchestration, transient-lane cleanup, and multi-lane deep-link navigation. Mounts `AskQuestionComposer` in place of the composer textarea when the active pending input is a question/structured-question. Resolves the surface accent colour through `chatAccentForRenderedChat(...)` so Claude/Codex/Cursor stay visually consistent regardless of model variant; the question/plan cards inherit that same `--chat-accent`. The provider comes from the **rendered** session (never the outgoing one), model-derived inputs are withheld until the composer model describes the chat on screen, and an embedding host may supply `lockSessionProvider` so a locked pane paints the right colour on the switch frame instead of borrowing the previous chat's — see [Resolving the accent](composer-and-ui.md#resolving-the-accent-for-the-chat-on-screen). Visible Work grid tiles flush user/lifecycle/live events immediately and poll-recover active transcripts when IPC misses an event, even when the tile is not focused. A visible session whose transcript is still empty but whose summary no longer looks active receives two bounded forced history reads (after 900 ms and 3 s), covering newly-created headless sessions whose first append/event raced the renderer without introducing idle polling. Event-history snapshots with `sessionFound: false` clear stale locked-pane state instead of rendering a dead transcript. Draft chats scope their last-launch config by project/lane/surface/draft-kind and mark local model/reasoning/permission edits as touched so late lane-session hydration cannot overwrite the user's draft selection; composer text is also keyed by the real session id or the lane draft key (`draft:`) so switching draft lanes does not leak text through a shared null session key. During project transitions the pane blocks send/model/permission mutations and shows a "Project is switching..." composer placeholder so chat calls do not hit the wrong runtime binding. On macOS, polls `ade.iosSimulator.getStatus` and renders the iOS Simulator drawer toggle in the header when the platform is supported (see [iOS Simulator feature](../ios-simulator/README.md)); selecting elements inside the drawer flows back through the pane as `IosElementContextItem` chips on the composer. Polls `ade.appControl.getStatus` and exposes the App Control drawer toggle when the platform is supported, mounting `ChatAppControlPanel`; selections become `AppControlContextItem` chips + attachments on the composer. See [App Control](../computer-use/app-control.md). When mounted as a Work tile (`SessionSurface` passes `hideLaneToolDrawers={true}`) the iOS, App Control, and chat terminal drawer toggles are suppressed because the Work right-edge sidebar owns those lane-scoped drawers; hidden lane-tool mode also skips App Control status polling and terminal listing. Remote-bound panes keep proof snapshot polling and proof-event subscriptions active for inline proof, while continuing to defer local-only App Control work until its drawer is open, delay unfinished parallel-launch cleanup recovery briefly after mount, cache chat-session lists and slash-command catalogs by active project root, and avoid mount-time session-delta fetches until a remote turn completes. The pane still listens on `ade:agent-chat:add-attachment` / `add-ios-context` / `add-app-control-context` / `add-builtin-browser-context` / `insert-draft` window events so selections from the sidebar flow into the active chat composer; event handlers match on either `sessionId` (for active sessions) or `draftTargetId` (for unsaved draft composers when `draftContextTargetId` is set), enabling the Work sidebar to insert context into a draft composer before a chat session exists. Work-tab CLI launches pass the active lane worktree into the shared launcher so the spawned CLI sees lane-aware Agent Skill roots. Work CLI launches intentionally skip the direct-argv path: the pane drops `command` / `args` from the `onLaunchPtySession` payload and always sends `startupCommand` plus `workCliStartupDelayMs = 180` so the spawned shell can finish drawing its prompt before the CLI invocation is typed in (see [pty-and-sessions.md](../terminals-and-sessions/pty-and-sessions.md#create-flow-createargs) for how `ptyService.create` consumes the delay). The `onLaunchCliSession` prop is typed as `(args: WorkPtyLaunchArgs) => Promise` and passes `disposition` matching the draft launch mode so background CLI launches do not steal focus. Internal draft launch state is structured through `DraftLaunchMode`, `DraftLaunchKind`, `DraftLaunchLaneTarget`, `StartedDraftLaunch`, and `DraftLaunchJob`. Each draft launch creates a `DraftLaunchJob` that tracks multi-step progress through a state machine (`creating-lane` -> `starting-session` -> `sending-prompt` -> `ready` | `failed`; auto-created lanes are named deterministically up front and the AI rename runs in the background via `startBackgroundLaneNaming` / `startBackgroundParallelLaneNaming`, surfaced through `laneNamingStore`, so there is no blocking `naming-lane` phase) and stores it in the **root** store's `draftLaunchJobsByScope` (read via `useRootAppStore` / `rootAppStoreApi.getState()`) keyed by project root, lane, surface profile, and Work draft kind so loading/error strips survive pane remounts — and a remote project switch that tears down the originating per-project store — without leaking into another lane pane. The detached launch chain captures the originating `OpenProjectBinding`, passes it as a `pin` to branch/lane/chat/orchestration/PTY calls so a mid-launch project switch keeps targeting the originating runtime, pins rollback (`lanes.delete` / `agentChat.delete` with a `pin`) to that binding, and caps each step with `withDraftLaunchTimeout` (90 s). The composer is cleared optimistically when the job starts rather than after it finishes; active jobs remain visible while terminal rows are pruned by scope. The pane renders status strips with Open/Restore for ready/failed jobs, Dismiss for terminal jobs, and a hide-status escape hatch for stale active jobs. Failed jobs offer a Restore button that merges the snapshot back into the composer (merging attachments and context items by identity rather than replacing). `clearDraftLaunchComposer` resets the draft, attachments, and context items after a successful launch. `DraftLaunchJob` carries `draftKind` so the dismissible job strip's "Open" action restores the correct Work draft kind (chat vs. CLI). Locked Work embeddings accept a full session-title index for spawned-chat roster labels, and spawned chats render a type-tinted **View parent thread** header control. Proof remains chat-scoped and stays on the chat header. The pane also owns **per-chat runtime routing**: a lane owns its machine and a chat inherits its machine from its lane, so a chat opened from the union Work sidebar can live on a machine this tab is not bound to. `createChatMachineRouter` (from `renderer/lib/chatMachineRouting.ts`) resolves the chat's lane to the owning `OpenProjectBinding`, and every chat-scoped `window.ade` call passes it as a plain trailing argument read from `chatRuntimePinRef.current` — preload treats `null` and `undefined` identically as "the bound path", so a pinned call and an unpinned one are the same call shape, and the ref lets the ~40 call sites read the pin without perturbing any hook dependency array. The pane resolves the whole scope once through `useChatScopeDerivation` and wraps its subtree in `ChatRuntimeScopeProvider` (both from `ChatRuntimeScope.tsx`) so the pane and its tools cannot disagree about which machine the chat is on. Two derived values it owns directly: App Control support is probed as `appControl.getStatus(chatRuntimePin)` — support is a property of the machine the chat runs on, and the probe used to be skipped entirely for a remote project, which left the toggle permanently hidden, and hiding the toggle is what kept the panel from ever opening to un-skip it; and `iosSimulatorProjectRoot` is `chatLaneWorktreePath ?? (chatRuntimePin ? chatRuntimePin.rootPath : projectRoot)`, the chat lane's checkout on the chat's own machine, because the old global fallback silently handed the local project root to a tool that was about to drive another machine. The pane builds the router's inputs with that module's shared constructors — `collectOpenProjectBindings` (active binding, open remote tabs, open local roots, cross-machine machine slices) and `buildChatMachineRoutingState` (which gives the active binding's live lane list precedence over any cached copy) — the same pair the Work tab's `useWorkMachineRouter` uses for CLI/shell rows, so the two surfaces cannot drift into different definitions of "open" or of lane precedence. A chat on the tab's own binding resolves to `null`, passes no extra argument, and takes the byte-for-byte unchanged path. The tab's binding is never rewritten by opening a chat — rebinding would drag Lanes / PRs / Files / Git / Run with it — and a pin that differs from the active binding is checked with `isLivePinnedBinding` (is it still open?) rather than against the active binding. | +| `apps/desktop/src/renderer/components/chat/AgentChatPane.tsx` | Top-level renderer surface: state derivation, IPC wiring, composer mount, message-list mount, End/Delete chat controls in the header, parallel multi-model lane launch orchestration, transient-lane cleanup, and multi-lane deep-link navigation. Mounts `AskQuestionComposer` in place of the composer textarea when the active pending input is a question/structured-question. Resolves the surface accent colour through `chatAccentForRenderedChat(...)` so Claude/Codex/Cursor stay visually consistent regardless of model variant; the question/plan cards inherit that same `--chat-accent`. The provider comes from the **rendered** session (never the outgoing one), model-derived inputs are withheld until the composer model describes the chat on screen, and an embedding host may supply `lockSessionProvider` so a locked pane paints the right colour on the switch frame instead of borrowing the previous chat's — see [Resolving the accent](composer-and-ui.md#resolving-the-accent-for-the-chat-on-screen). Visible Work grid tiles flush user/lifecycle/live events immediately and poll-recover active transcripts when IPC misses an event, even when the tile is not focused. A visible session whose transcript is still empty but whose summary no longer looks active receives two bounded forced history reads (after 900 ms and 3 s), covering newly-created headless sessions whose first append/event raced the renderer without introducing idle polling. Event-history snapshots with `sessionFound: false` clear stale locked-pane state instead of rendering a dead transcript. Draft chats scope their last-launch config by project/lane/surface/draft-kind and mark local model/reasoning/permission edits as touched so late lane-session hydration cannot overwrite the user's draft selection; composer text is also keyed by the real session id or the lane draft key (`draft:`) so switching draft lanes does not leak text through a shared null session key. During project transitions the pane blocks send/model/permission mutations and shows a "Project is switching..." composer placeholder so chat calls do not hit the wrong runtime binding. On macOS, polls `ade.iosSimulator.getStatus` and renders the iOS Simulator drawer toggle in the header when the platform is supported (see [iOS Simulator feature](../ios-simulator/README.md)); selecting elements inside the drawer flows back through the pane as `IosElementContextItem` chips on the composer. Polls `ade.appControl.getStatus` and exposes the App Control drawer toggle when the platform is supported, mounting `ChatAppControlPanel`; selections become `AppControlContextItem` chips + attachments on the composer. See [App Control](../computer-use/app-control.md). When mounted as a Work tile (`SessionSurface` passes `hideLaneToolDrawers={true}`) the iOS, App Control, and chat terminal drawer toggles are suppressed because the Work right-edge sidebar owns those lane-scoped drawers; hidden lane-tool mode also skips App Control status polling and terminal listing. Remote-bound panes keep proof snapshot polling and proof-event subscriptions active for inline proof, while continuing to defer local-only App Control work until its drawer is open, delay unfinished parallel-launch cleanup recovery briefly after mount, cache chat-session lists and slash-command catalogs by active project root, and avoid mount-time session-delta fetches until a remote turn completes. The pane still listens on `ade:agent-chat:add-attachment` / `add-ios-context` / `add-app-control-context` / `add-builtin-browser-context` / `insert-draft` window events so selections from the sidebar flow into the active chat composer; event handlers match on either `sessionId` (for active sessions) or `draftTargetId` (for unsaved draft composers when `draftContextTargetId` is set), enabling the Work sidebar to insert context into a draft composer before a chat session exists. Work-tab CLI launches pass the active lane worktree into the shared launcher so the spawned CLI sees lane-aware Agent Skill roots. Work CLI launches intentionally skip the direct-argv path: the pane drops `command` / `args` from the `onLaunchPtySession` payload and always sends `startupCommand` plus `workCliStartupDelayMs = 180` so the spawned shell can finish drawing its prompt before the CLI invocation is typed in (see [pty-and-sessions.md](../terminals-and-sessions/pty-and-sessions.md#create-flow-createargs) for how `ptyService.create` consumes the delay). The `onLaunchCliSession` prop is typed as `(args: WorkPtyLaunchArgs) => Promise` and passes `disposition` matching the draft launch mode so background CLI launches do not steal focus. Internal draft launch state is structured through `DraftLaunchMode`, `DraftLaunchKind`, `DraftLaunchLaneTarget`, `StartedDraftLaunch`, and `DraftLaunchJob`. Each draft launch creates a `DraftLaunchJob` that tracks multi-step progress through a state machine (`creating-lane` -> `starting-session` -> `sending-prompt` -> `ready` | `failed`; auto-created lanes are named deterministically up front and the AI rename runs in the background via `startBackgroundLaneNaming` / `startBackgroundParallelLaneNaming`, surfaced through `laneNamingStore`, so there is no blocking `naming-lane` phase) and stores it in the **root** store's `draftLaunchJobsByScope` (read via `useRootAppStore` / `rootAppStoreApi.getState()`) keyed by project root, lane, surface profile, and Work draft kind so loading/error strips survive pane remounts — and a remote project switch that tears down the originating per-project store — without leaking into another lane pane. The detached launch chain captures the originating `OpenProjectBinding`, passes it as a `pin` to branch/lane/chat/orchestration/PTY calls so a mid-launch project switch keeps targeting the originating runtime, pins rollback (`lanes.delete` / `agentChat.delete` with a `pin`) to that binding, and caps each step with `withDraftLaunchTimeout` (90 s). The composer is cleared optimistically when the job starts rather than after it finishes; active jobs remain visible while terminal rows are pruned by scope. The pane renders status strips with Open/Restore for ready/failed jobs, Dismiss for terminal jobs, and a hide-status escape hatch for stale active jobs. Failed jobs offer a Restore button that merges the snapshot back into the composer (merging attachments and context items by identity rather than replacing). `clearDraftLaunchComposer` resets the draft, attachments, and context items after a successful launch. `DraftLaunchJob` carries `draftKind` so the dismissible job strip's "Open" action restores the correct Work draft kind (chat vs. CLI). Locked Work embeddings accept a full session-title index for spawned-chat roster labels, and spawned chats render a type-tinted **Go to parent thread** header control. Proof remains chat-scoped and stays on the chat header. The pane also owns **per-chat runtime routing**: a lane owns its machine and a chat inherits its machine from its lane, so a chat opened from the union Work sidebar can live on a machine this tab is not bound to. `createChatMachineRouter` (from `renderer/lib/chatMachineRouting.ts`) resolves the chat's lane to the owning `OpenProjectBinding`, and every chat-scoped `window.ade` call passes it as a plain trailing argument read from `chatRuntimePinRef.current` — preload treats `null` and `undefined` identically as "the bound path", so a pinned call and an unpinned one are the same call shape, and the ref lets the ~40 call sites read the pin without perturbing any hook dependency array. The pane resolves the whole scope once through `useChatScopeDerivation` and wraps its subtree in `ChatRuntimeScopeProvider` (both from `ChatRuntimeScope.tsx`) so the pane and its tools cannot disagree about which machine the chat is on. Two derived values it owns directly: App Control support is probed as `appControl.getStatus(chatRuntimePin)` — support is a property of the machine the chat runs on, and the probe used to be skipped entirely for a remote project, which left the toggle permanently hidden, and hiding the toggle is what kept the panel from ever opening to un-skip it; and `iosSimulatorProjectRoot` is `chatLaneWorktreePath ?? (chatRuntimePin ? chatRuntimePin.rootPath : projectRoot)`, the chat lane's checkout on the chat's own machine, because the old global fallback silently handed the local project root to a tool that was about to drive another machine. The pane builds the router's inputs with that module's shared constructors — `collectOpenProjectBindings` (active binding, open remote tabs, open local roots, cross-machine machine slices) and `buildChatMachineRoutingState` (which gives the active binding's live lane list precedence over any cached copy) — the same pair the Work tab's `useWorkMachineRouter` uses for CLI/shell rows, so the two surfaces cannot drift into different definitions of "open" or of lane precedence. A chat on the tab's own binding resolves to `null`, passes no extra argument, and takes the byte-for-byte unchanged path. The tab's binding is never rewritten by opening a chat — rebinding would drag Lanes / PRs / Files / Git / Run with it — and a pin that differs from the active binding is checked with `isLivePinnedBinding` (is it still open?) rather than against the active binding. | | `apps/desktop/src/renderer/components/chat/ChatRuntimeScope.tsx` | The one answer to "which machine is THIS chat on, and what does it look like there". A lane owns its machine and a chat inherits its machine from its lane, but a Work tab unions chats from every machine on the account, so every chat-scoped surface — Git toolbar, iOS simulator, App Control, built-in browser, file changes, PR pane, terminals — has two candidate answers available to it: the chat's machine, and whatever machine the project tab happens to be bound to. Reading the tab's machine is wrong in exactly the case that matters, and it is the reading every global `useAppStore` selector gives you. `useChatRuntimeScope()` returns `{ pin, binding, laneId, lane, laneWorktreePath, rootPath, isRemote, machineName, online }` from context; `pin === null` means, and only means, "this chat lives on the tab's binding" — the unpinned path, byte-for-byte what the surface did before per-chat routing. Outside a provider it returns the unpinned/local/online fallback, which is how a chat-less surface (a bare preview, a test harness) should behave. `useChatRuntimeScopeForPin(pin, laneId, bindingOverride?)` is the same derivation from a pin handed in as a prop, for surfaces reused outside a chat pane — the CLI session header renders `ChatGitToolbar` with its own pin and no provider above it. `useChatScopeDerivation({...})` asks the same question one level higher: a pane starts from a *session*, not a pin, and has to find the lane (possibly a foreign one, absent from this tab's session list, found through `useForeignSessionLaneId`) before it can find the machine; it returns `chatScopeLaneId`, `chatRuntimePin`, `chatEffectiveBinding`, `isRemoteChat`, `chatMachineName`, `handoffLaneSourceLanes`, and `chatLaneWorktreePath`. `ChatRuntimeScopeProvider` is what `AgentChatPane` wraps its panel/drawer subtree in after resolving the scope once. Four derivation rules the module exists to enforce: a foreign lane is absent from the tab-bound `lanes` array, so its worktree path — and therefore the iOS / App Control project root — is knowable only from the pinned machine's slice of the cross-machine union (`useLanesForPin` in `renderer/state/crossMachineLanes.ts`), and never from `state.lanes`, because lane ids are unique only per machine and that fallback can match a *different* lane sharing the id; `handoffLaneSourceLanes` targets the chat's machine because a brief handoff lands in a lane there, while `availableLanes` / `lanes` describe the tab's bound machine and are right only for an unpinned chat; `online` is false only when the chat's *pinned* machine is known unreachable, since the bound machine's own liveness is the window's problem, not the chat's; and `machineName` comes from `machineNameForBinding` in `shared/machineIdentity.ts` and is always absolute ("This computer", "MacBook Pro (97)"), never the word "remote". The invariant is lint-enforced: `apps/desktop/eslint.config.mjs` bans global-store reads inside `src/renderer/components/chat/**` — `useAppStore` / `useRootAppStore` reads of `projectBinding` or `lanes` (subscribed or through `getState()`), reads of `project.rootPath`, and importing `selectActiveProjectRoot` — with messages pointing callers at `useChatRuntimeScope()`. `AgentChatPane.tsx` (which owns the tab-vs-chat distinction), this module (which is the derivation), and tests are exempt. | | `apps/desktop/src/renderer/components/chat/usage/contextUsageModel.ts` | Provider-neutral context meter reducer. Automatic per-turn Claude `context_usage` snapshots (`origin: "live"`) are filtered out of the transcript and feed only this reducer, so the composer meter's hover (`ContextUsageDial`) — not an inline card — is the primary context-usage surface; the user-invoked `/context` command (`origin: "command"`) is the only `context_usage` that still renders an inline breakdown card. The reducer reads the snapshot's typed breakdown (`inputTokens` / `outputTokens` / `cacheReadTokens` / `cacheCreationTokens`) so the dial's hover is a complete replacement for that card, falling back to showing the total as input when no breakdown is present. A completed `context_compact` boundary invalidates older usage for Claude, Codex, OpenCode, Cursor, and Droid; generic same-turn counters are ignored because those SDKs can report pre-compaction per-turn/cumulative totals after the history was replaced. Claude `postTokens` / exact `context_usage` and Codex `thread/tokenUsage/updated` snapshots can repopulate the meter immediately. Desktop, ADE Code, and iOS mirror this boundary rule. Codex token breakdowns are normalized in `agentChatService.ts` (`normalizeCodexTokenBreakdown`), which maps 0.145's `cacheWriteInputTokens` / `reasoningOutputTokens` onto `cacheWriteTokens` / `reasoningTokens`; the desktop `ContextUsageDial` tooltip renders `cache write` and `reasoning` segments (in addition to in/out/cached) whenever those counts are present. | | `apps/desktop/src/renderer/components/usage/ActivityModule.tsx` | Reusable activity, token, code-movement, and client-mix module. `AgentChatPane` mounts `WorkActivityModule` directly below an empty Work draft composer (desktop and web only); it reads `usage.getAdeStats` through the active `window.ade` adapter, defaults to all-time activity, and preserves explicit tab/range choices locally. | @@ -116,7 +116,7 @@ for its separate RPC, sync, storage, and UI contracts. | `apps/desktop/src/renderer/lib/draftLaunchJobs.ts` | Shared renderer helper for Work draft-launch job DTOs and pruning. Owns `NativeControlState`, `DraftLaunchSnapshot`, `PreparedDraftLaunch`, `DraftLaunchJobStatus`, `DraftLaunchJob`, `isDraftLaunchJobTerminal`, `isDraftLaunchJobStale`, and `pruneDraftLaunchJobs`; active jobs are kept ahead of terminal rows, with terminal rows filling the remaining retained slots and at least one terminal row retained alongside active jobs. Also owns the launch durability constants/helpers: `DRAFT_LAUNCH_TIMEOUT_MS` (90 s) + `withDraftLaunchTimeout(promise, label)` (rejects a launch step whose runtime call never settles; the underlying IPC is not cancellable, so on timeout it keeps running detached and the timeout only unwedges the renderer-side job) and `LAUNCH_PROJECT_CHANGED_MESSAGE` (the legacy/unpinned abort error used only when no originating project binding is available and the active project drifts mid-launch). | | `apps/desktop/src/renderer/lib/handoffLaunchJobs.ts` | Shared renderer helper for in-flight chat handoff placeholders. Defines the handoff job DTO, scope keying, mode-aware status labels (`preparing-summary` for brief, `forking-history` for fork), search matching, the stable placeholder id used by the Work session sidebar, and `handoffJobLikelyMaterialized` — the ADE-122 dedupe that hides a placeholder as soon as a matching real session row (same lane + tool type, started at/after the job began) is visible, so an in-flight handoff never reads as two new sessions with one vanishing. | | `apps/desktop/src/renderer/state/appStore.ts` | Shared renderer state store. Besides project/lane/work selection, it persists user preferences such as `launchPromptClipboardEnabled`, `launchPromptClipboardNoticeEnabled`, and the default-on `promptStashButtonEnabled`, mirrors them into per-project stores, and owns `draftLaunchJobsByScope` (+ `setDraftLaunchJobs`) for Work draft launch status strips plus `handoffLaunchJobsByScope` (+ `setHandoffLaunchJobs`) for Work sidebar handoff placeholders. These live in the **root** store (not the per-project store) on purpose: in-flight launches must survive a remote project switch that destroys the originating per-project store; `AgentChatPane` reads them via `useRootAppStore` / `rootAppStoreApi.getState()`. | -| `apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx` | Virtualized transcript renderer. Coalesces resize / measurement updates and, while sticky-to-bottom is active, follows height changes across multiple animation frames so streamed output and late row measurements do not leave the user above the newest message. Programmatic scroll writes are tracked by target scroll position, not a stale counter, so browser-coalesced scroll events do not swallow the next real user gesture. Workspace paths in Markdown links and inline code render with an explicit file glyph/click treatment and open with a relative path + lane id (parsing, resolution, index probing, and the opener live in `chatWorkspacePaths.tsx`, which the list feeds its `runtimePin` so a foreign chat's paths are looked up on the machine that has them). A file in this chat's own lane on this machine opens in the Work tools-pane Files panel next to the conversation; anything else navigates to `FilesTab`, which resolves the target against that machine's workspace roster — so the same click opens the correct file for local, remote-bound, and cross-machine chats without treating a remote path as a local OS file. `work_log_group` rows are filtered out of the rendered timeline entirely — tool calls are reachable from the working indicator and the done divider, and file changes are summarized once per turn by `ChatTurnFilesChangedSummary` at that turn's done divider (suppressed when the turn also emitted a checkpoint-backed `turn_diff_summary`). Older history prefetches roughly two viewport-heights before the top (`resolveOlderHistoryPrefetchTriggerPx`, shared by the scroll handler and the sentinel observer) so the reader normally never sees the head spinner. `stabilizeTranscriptToolActivity` reuses the previous per-turn entry arrays whenever a turn's entries did not change, so a streaming delta does not defeat `React.memo` on every settled turn in the thread. Activity bundles fold todo/scheduled updates and placeholder subagent updates into compact rows, dedupe placeholder subagent parents once the concrete child id is known, and open Chat Info / subagent detail instead of duplicating the drawer roster inline; real subagents instead render as inline `SubagentSpawnCard` / `SubagentResultCard` rows anchored where they started and settled (a backgrounded shell command instead gets a single `BackgroundJobLine` that covers its whole life), with jump-to-result / jump-to-start affordances that reuse the stable-row scroll machinery; a run of two or more interrupt-stopped subagents folds into one calm `SubagentStoppedGroupCard` instead of a wall of identical stopped cards. Synthetic scheduled turns render an amber `Woke on schedule` divider with fire time, reason, and late marker; the existing stable-row scroll machinery accepts jump requests from the while-you-were-away strip. Completed-turn dividers bucket chat-owned proof by capture timestamp, expose a collapsed `N proof` chip, and expand the filmstrip directly beneath the producing turn; there is no proof footer pinned to the tail. Codex goal lifecycle events render as compact user-facing rows (`Goal set`, `Goal paused`, `Goal cleared`) instead of raw JSON-RPC/status wording. Codex runtime notices (`codex_safety_buffering`, `codex_moderation_metadata`, `codex_sleep`, `codex_thread_deleted`) render as small transcript chips. `codex_turn_stalled` renders a live recovery card: Wait re-arms the watchdog, Nudge sends a status steer, Retry interrupts and replays work in the same thread, and Resume restarts app-server, resumes the thread, and retries. Handoff brief user messages with `metadata.hideFullPrompt` show only their `displayText` breadcrumb and do not expose or copy the internal prompt body. History seeded into a forked chat (envelopes tagged `providerOrigin: "handoff_fork"`) renders under a single `Forked from the previous chat — full history above` divider pinned to the first live row after the seeded tail, rather than a per-row marker. Error events whose `errorInfo.agentCli.category` is `"unauthenticated"` render as the calm `AgentCliAuthCard` (raw 401 behind a `Details` disclosure) rather than the red error block, so a recoverable logout reads as a re-login prompt, not a crash. The live turn's `working for ` counter is painted imperatively through a callback ref that survives the status line's mid-turn remount (see [composer-and-ui.md](composer-and-ui.md)). It also owns `ChatInfoHostContext`: `AgentChatPane` provides it because it owns the chat actions pane and listens for `ade:chat:open-info`; `PersonalChatsPage` does not, so transcript affordances that reveal that pane render only where dispatching would actually do something. Scoping is per-subtree by necessity — hidden `ProjectSurface`s stay mounted, so a module-level registry would report a host on the one surface that lacks one. | +| `apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx` | Virtualized transcript renderer. Coalesces resize / measurement updates and, while sticky-to-bottom is active, follows height changes across multiple animation frames so streamed output and late row measurements do not leave the user above the newest message. Programmatic scroll writes are tracked by target scroll position, not a stale counter, so browser-coalesced scroll events do not swallow the next real user gesture. Workspace paths in Markdown links and inline code render with an explicit file glyph/click treatment and open with a relative path + lane id (parsing, resolution, index probing, and the opener live in `chatWorkspacePaths.tsx`, which the list feeds its `runtimePin` so a foreign chat's paths are looked up on the machine that has them). A file in this chat's own lane on this machine opens in the Work tools-pane Files panel next to the conversation; anything else navigates to `FilesTab`, which resolves the target against that machine's workspace roster — so the same click opens the correct file for local, remote-bound, and cross-machine chats without treating a remote path as a local OS file. `work_log_group` rows are filtered out of the rendered timeline entirely — tool calls are reachable from the working indicator and the done divider, and file changes are summarized once per turn by `ChatTurnFilesChangedSummary` at that turn's done divider (suppressed when the turn also emitted a checkpoint-backed `turn_diff_summary`). Older history prefetches roughly two viewport-heights before the top (`resolveOlderHistoryPrefetchTriggerPx`, shared by the scroll handler and the sentinel observer) so the reader normally never sees the head spinner. `stabilizeTranscriptToolActivity` reuses the previous per-turn entry arrays whenever a turn's entries did not change, so a streaming delta does not defeat `React.memo` on every settled turn in the thread. Activity bundles fold todo/scheduled updates and placeholder subagent updates into compact rows, dedupe placeholder subagent parents once the concrete child id is known, and open Chat Info / subagent detail instead of duplicating the drawer roster inline; real subagents instead render as inline `SubagentSpawnCard` / `SubagentResultCard` rows anchored where they started and settled (a backgrounded shell command instead gets a single `BackgroundJobLine` that covers its whole life), with jump-to-result / jump-to-start affordances that reuse the stable-row scroll machinery; a run of two or more interrupt-stopped subagents folds into one calm `SubagentStoppedGroupCard` instead of a wall of identical stopped cards. Synthetic scheduled turns render an amber `Woke on schedule` divider with fire time, reason, and late marker; the existing stable-row scroll machinery accepts the Review jump from the while-you-were-away card. Completed-turn dividers bucket chat-owned proof by capture timestamp, expose a collapsed `N proof` chip, and expand the filmstrip directly beneath the producing turn; there is no proof footer pinned to the tail. Codex goal lifecycle events render as compact user-facing rows (`Goal set`, `Goal paused`, `Goal cleared`) instead of raw JSON-RPC/status wording. Codex runtime notices (`codex_safety_buffering`, `codex_moderation_metadata`, `codex_sleep`, `codex_thread_deleted`) render as small transcript chips. `codex_turn_stalled` renders a live recovery card: Wait re-arms the watchdog, Nudge sends a status steer, Retry interrupts and replays work in the same thread, and Resume restarts app-server, resumes the thread, and retries. Handoff brief user messages with `metadata.hideFullPrompt` show only their `displayText` breadcrumb and do not expose or copy the internal prompt body. History seeded into a forked chat (envelopes tagged `providerOrigin: "handoff_fork"`) renders under a single `Forked from the previous chat — full history above` divider pinned to the first live row after the seeded tail, rather than a per-row marker. Error events whose `errorInfo.agentCli.category` is `"unauthenticated"` render as the calm `AgentCliAuthCard` (raw 401 behind a `Details` disclosure) rather than the red error block, so a recoverable logout reads as a re-login prompt, not a crash. The live turn's `working for ` counter is painted imperatively through a callback ref that survives the status line's mid-turn remount (see [composer-and-ui.md](composer-and-ui.md)). It also owns `ChatInfoHostContext`: `AgentChatPane` provides it because it owns the chat actions pane and listens for `ade:chat:open-info`; `PersonalChatsPage` does not, so transcript affordances that reveal that pane render only where dispatching would actually do something. Scoping is per-subtree by necessity — hidden `ProjectSurface`s stay mounted, so a module-level registry would report a host on the one surface that lacks one. | | `apps/desktop/src/renderer/components/chat/chatHistoryWindow.ts` | Shared bounded-history policy for project and personal desktop chats: canonical event identity, byte estimates and resident caps, page-seam merging, strict cursor advancement, bounded continuation through empty physical pages, and stale-request predicates. `readOlderHistoryBatch` is turn-anchored: because pages are cut by bytes, a page can be entirely superseded streaming deltas that fold to one rendered line, so it keeps pulling pages until the accumulated span contains a `user_message` — bounded by `maxPages` (default 8, covering both empty pages and the anchoring extension) and `maxAnchorEvents` (default 400). Pages already pulled are returned even when a later read reports `sessionFound === false`. Snapshot cursor reconciliation preserves a known exhausted head only when the authoritative refresh overlaps the current window and retains its oldest event; replacement snapshots and cap eviction re-arm paging. | | `apps/desktop/src/renderer/components/chat/chatWorkspacePaths.tsx` | Workspace-path recognition, resolution, and navigation for every chat surface. Owns `parseWorkspacePathLocation` / `looksLikeWorkspacePath` / `resolveWorkspacePathFromHref` (line+column suffixes, `#L12C3` and `line=` fragments, `file:` URLs, Windows drive paths), `resolveFilesNavigationTarget` (absolute path → longest matching workspace root, preferring the chat's own lane), and the `useWorkspacePathOpener` hook. That hook takes the chat's `runtimePin` (`AgentChatPane` declares it below `chatRuntimePin`, and passes it to `AgentChatMessageList` too) because a chat on another machine reports paths on *that* machine's disk: `listWorkspaces` and the probe below are asked of it, and the workspace-root cache is keyed per machine so a foreign lane id can never resolve against the local roster. A clicked path is probed against the file-name index first (`probeWorkspacePath`): a bare filename resolves to a real path, several same-named files open the search panel instead of guessing, a folder reveals in the tree rather than erroring as a file, and a path under no known root raises a toast instead of the old silent no-op. A probe miss is deliberately **not** fatal for a path containing `/` — the index is stale by design (watcher-fed only while a Files/Git panel is open, gitignored files excluded, capped at 25,000), so the file an agent just created is opened anyway and a real read error is allowed to speak. Routing has two destinations: same machine + the chat's own lane + a `/work` surface goes to the Work tools-pane Files panel through the `files/v2/filesOpenRequests.ts` module channel (no navigation, the conversation stays put); anything else navigates to `/files` with `openFilePath` / `laneId` / `startLine` / `startColumn` plus `openPathType`, `searchQuery`, and `filesPin` in route state. `ChatWorkspacePathProvider` + `useChatWorkspacePaths` / `useChatWorkspacePathOpener` carry that opener through context so shared `ChatMarkdown` (plan cards, question-option previews) can route file paths to the Files tab instead of the browser opener; with no provider a path renders as inert text rather than a guessed URL. Workspace roots load lazily and are cached per module per machine (never on mount), warmed by the first click or by `ensureWorkspacesLoaded`, and force-re-read once after a resolve miss so a lane created since the warm-up is picked up — navigation always resolves against a fresh read, since a stale root that still *matches* would resolve successfully with the previous project's lane id. | | `apps/desktop/src/renderer/components/chat/chatAppearance.ts` | Chat density/font geometry plus the single responsive transcript width contract. `--chat-content-width` is `min(100%, clamp(720px, 62vw, 1180px))`; `--chat-column` aliases it so prose, composer, cards, pills, plans, file changes, and floating-pane reserve math share one viewport-scaling measure. | @@ -131,7 +131,7 @@ for its separate RPC, sync, storage, and UI contracts. | `apps/desktop/src/renderer/components/chat/RewindFilesConfirmDialog.tsx`, `rewindFilesPreview.ts` | Chat file-rewind confirmation surface. Claude uses the SDK `rewindFiles` control call; Codex uses ADE's git-backed file restore plan plus a version-gated app-server call — `thread/fork` before the target turn on servers >= 0.145.0, and the deprecated `thread/rollback` fallback on <= 0.144 or when the target turn has no usable id (see [agent-routing.md](./agent-routing.md#codex-rewind-and-0145-readiness)). `rewindFilesPreview.ts` maps the selected user message to turn diff summaries and per-file SHA ranges; the dialog lists every restored file, expands rows into `AdeDiffViewer`, and confirms the provider rewind without using browser-native confirm UI. | | `apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx`, `chatExecutionSummary.ts`, `chatSubagentIdentity.tsx`, `codex/CodexGoalCard.tsx` | Chat Info drawer content: Codex goal card, capped/collapsible plan and task sections, and capped Subagents/Background/Schedule rosters. Every subagent row shows a sentence-case model chip: a reported envelope `model` is ground truth, and a missing model falls back to the parent session label marked **inherited**. Running subagent and background durations derive from the wall clock and tick once per second; terminal rows retain their final compact duration. Terminal work moves into one **Completed** disclosure without reordering survivors; failed and pinned rows stay active; Clear hides only terminal Completed rows and Restore reverses it. Schedule pause/play remains in the Schedule header, recurring rows show last-run plus next-fire timing, and each active ADE-managed durable row exposes Cancel; provider-only/non-durable transcript rows stay visible without a false cancellation control. Spawned-chat snapshots carry a derived `childSessionId`; the derivation preserves the `chat:` task id and `spawnKind` when the canonical dotted lifecycle twin merges into the underscore event. Their roster rows show the child's live session title, put the runtime in the small kind chip, and navigate directly to the child instead of opening a provider transcript drawer. `chatSubagentIdentity.tsx` centralizes deterministic identity and exposes status-optional, size-configurable glyphs for both roster state and compact lineage cues. | | `apps/desktop/src/renderer/components/chat/ChatBuiltInBrowserPanel.tsx` | Renderer panel for the in-app browser. Renders the address bar, tabs strip, navigation controls, an inspect/select toolbar, and a `BuiltInBrowserStatus`-derived empty/error state, then asks the main process to position the underlying `WebContentsView` over the panel's bounding rect through `ade.builtInBrowser.setBounds`. Its trusted-renderer-only **Profile** panel shows global cookie counts/domains, cache size, last safe flush, and remembered site permissions, with per-row Remove and Clear all controls. Because native `WebContentsView` content sits above the renderer, the panel hides it while ADE overlays, dialogs, menus, or popovers overlap the browser surface so ADE chrome remains reachable. Mounted by `WorkSidebar` under the `browser` tab and (indirectly) by any renderer code that calls `openUrlInAdeBrowser()` — the helper opens the sidebar Browser tab and dispatches the URL into a fresh tab. Selections committed through inspect-mode hit-testing fan out via the `onAddContext` callback as `BuiltInBrowserContextItem` payloads. | -| `apps/desktop/src/renderer/components/work/WorkSurfaceHeader.tsx`, `ClaudeLoginPromptButton.tsx` | Shared Work surface header chrome for chat and CLI surfaces: title, lane chip, Claude cache badge, git toolbar, caller-provided trailing actions, and the dismissible Claude login CTA that starts `claude auth login` in a tracked PTY. The `WorkSurfaceTitle` sub-component plays a one-time CSS shimmer when the title transitions from a provider default (`Claude Chat`, `Codex Chat`, …) to a real auto-generated title while the surface stays mounted, and respects `prefers-reduced-motion`. `AgentChatPane` also reuses `ClaudeLoginPromptButton` as a sticky bar above the composer (keyed `composer-auth:`) while a Claude session is logged out, but only when the chat header pill is absent so the two never double up. The header also takes a `lifecycleSessionId` (the chat pane passes its selected session id) and renders `work/SessionLifecycleChips.tsx` for it — see [composer-and-ui.md › Header](composer-and-ui.md#header). | +| `apps/desktop/src/renderer/components/work/WorkSurfaceHeader.tsx`, `ClaudeLoginPromptButton.tsx` | Shared Work surface header chrome for chat and CLI surfaces: title, lane chip, Claude cache badge, git toolbar, caller-provided trailing actions, and the dismissible Claude login CTA that starts `claude auth login` in a tracked PTY. The `WorkSurfaceTitle` sub-component plays a one-time CSS shimmer when the title transitions from a provider default (`Claude Chat`, `Codex Chat`, …) to a real auto-generated title while the surface stays mounted, and respects `prefers-reduced-motion`. `AgentChatPane` also reuses `ClaudeLoginPromptButton` as a sticky bar above the composer (keyed `composer-auth:`) while a Claude session is logged out. Settled state is rendered by `ChatLifecycleBanner` as a compact pill above the composer; the header takes a `snoozeSessionId` for the remaining snooze affordance — see [composer-and-ui.md › Header](composer-and-ui.md#header). | | `apps/desktop/src/renderer/components/chat/AgentCliAuthCard.tsx` | Inline install / re-login card for missing or unauthenticated agent CLIs, rendered in the transcript from a decorated `error` event's `errorInfo.agentCli` payload. Copy chips + a tracked-PTY Run button (`window.ade.pty.create`) for the install / auth command. The logged-out (`category: "unauthenticated"`) variant is terracotta-toned for Claude (amber for other agents), retitles to "<Provider> is logged out", and adds an always-on **Retry turn** button that resends the last user message via the `CHAT_RETRY_AUTH_TURN_EVENT` (`ade:chat:retry-auth-turn`) window event; it collapses to a "Reconnected" confirmation when `AgentChatPane` fires `CHAT_AUTH_RECOVERED_EVENT` (`ade:chat:auth-recovered`) after a later turn succeeds. The "missing CLI" variant keeps the red-free amber install card. | | `apps/desktop/src/renderer/components/chat/ProviderFailureRecoveryCard.tsx` | Classifies terminal provider capacity and usage-limit errors into actionable transcript cards. The card explains that the thread remains safe, offers an explicit same-thread **Retry turn**, and opens the composer model picker through a one-shot request for **Choose model**; neither action is enabled while another turn is active. | | `apps/desktop/src/renderer/components/chat/chatTurnState.ts` | Shared renderer turn-state invariant used by cache hydration, history snapshots, live event flushes, and locked-session summary refreshes. A terminal `status`/`done` at the end of the transcript outranks an eventually consistent `status: "active"` session summary, so failed/interrupted turns restore an idle composer. Also resolves the user message associated with a failed turn, including Codex optimistic user rows that predate assignment of a provider `turnId`. | @@ -381,8 +381,8 @@ Controls and summaries project this runtime state rather than owning it: so the publisher re-reads the session summary on a bounded cadence. - Every unattended turn starts with a `Woke on schedule` divider. Desktop tracks the last viewed time per session in renderer `localStorage` and - offers a dismissible while-you-were-away strip with jump links to those - stable divider row keys. + offers a compact, dismissible while-you-were-away card above the composer; + its Review action jumps to the first stable divider row key. ## Key concepts @@ -1569,9 +1569,9 @@ Work sidebar can therefore render, without any extra fetch: unfiltered session-title index when available; clicking it opens the parent without selecting the child card. -`AgentChatPane` renders a type-tinted **View parent thread** header button for +`AgentChatPane` renders a type-tinted **Go to parent thread** header link for a spawned chat. The tooltip names the parent when its title is available, and -the button is the keyboard/assistive-technology route back to the parent. +the link is the keyboard/assistive-technology route back to the parent. A subagent that has not yet answered the takeover prompt also shows a non-blocking composer banner: **Take over** / **Keep reporting**. Take over demotes to peer; dismiss and Keep reporting persist diff --git a/docs/features/chat/composer-and-ui.md b/docs/features/chat/composer-and-ui.md index 7f6d00167..b5cf5ff7e 100644 --- a/docs/features/chat/composer-and-ui.md +++ b/docs/features/chat/composer-and-ui.md @@ -11,7 +11,7 @@ subagents, computer use). The pane derives all visible state from the | Path | Role | |---|---| -| `AgentChatPane.tsx` | Top-level pane; IPC wiring, session state, presentation profile resolution, lane navigation, parallel launch orchestration, mounting of sub-panels and composer. It persists a per-session `ade.chat.lastViewed.v1:` timestamp in renderer `localStorage`; scheduled turns that fired since that timestamp produce a dismissible while-you-were-away strip above the composer, with the latest outcome preview and jump requests for up to three wake dividers. Visible Work grid tiles flush user/lifecycle/live events immediately and poll-recover active transcripts so inactive-but-visible tiles stay current. Draft chats preserve user-touched model/reasoning/permission controls across late lane-session hydration, and composer text is keyed by session id or lane draft key so switching draft lanes does not reuse another draft's text. Accepts an optional `draftContextTargetId` prop so the Work sidebar can target an unsaved draft composer for context insertions (attachments, iOS/App Control/browser selections, draft text) even before a chat session exists; window event handlers match on either `sessionId` or `draftTargetId`. When auto-creating a lane the draft resolves the primary lane for the `onLaneChange` callback so the sidebar lane context stays in sync. Composer draft state (text, model, reasoning, attachments, context items) is persisted to `localStorage` under the `ade.chat.composerDraft.v1` key family and restored on scope change through `ComposerDraftStorageSnapshot`. Pending-steer Edit uses `cancelSteer({ requireQueued: true })`, then merges the queued text, file attachments, and context attachments into the captured composer draft; if the message already left the queue, the cancel fails and the draft is left unchanged. Draft launches are tracked through **root**-store-backed `DraftLaunchJob` state machines with multi-step progress (`creating-lane` -> `starting-session` -> `sending-prompt` -> `ready` / `failed`; auto-create names the lane deterministically up front and renames to the AI name in the background, so there is no blocking `naming-lane` phase); jobs live in the root store (not the per-project store) so an in-flight launch survives a remote project switch that tears down the originating project surface. The detached launch chain captures the originating `OpenProjectBinding`, passes it as a `pin` to branch/lane/chat/orchestration/PTY calls so a mid-launch project switch keeps targeting the originating runtime, pins rollback to that binding, and caps each step at 90 s (`withDraftLaunchTimeout`). The composer is cleared optimistically at job start, stale active rows gain a hide-status escape hatch, failed jobs expose Restore in the job strip and matching error banner, and the `DraftLaunchSnapshot` captures the full control state so the async launch uses frozen settings. It also owns the transcript-resilience rules described in [Transcript and turns](transcript-and-turns.md#history-snapshots-scroll-back-and-misses): `resolveChatHistoryMissAction` (a history miss never blanks a rendered transcript), `resolveSnapshotHistoryCursor` (`hasOlderHistory` is authoritative over `tailStartOffset`), the bounded silent retry ladder `OLDER_HISTORY_RETRY_DELAYS_MS = [800, 2400]`, the `syncPendingBySession` flag that surfaces as `data-chat-sync-pending` + a 2 px catch-up hairline under the header (a fading static rule, never a continuous animation; the fade is `motion-safe:`), and a minimal static cold-chat skeleton (`data-chat-cold-skeleton`) so a chat with no cached view reads as loading rather than empty. `resolveRenderedChatSessionId` picks the session to paint from the incoming props rather than the effect-synced `selectedSessionId`, which otherwise paints the outgoing chat's transcript for one frame after the pane is pointed elsewhere. The module-level view cache holds 8 entries / 128 MB total (32 MB per session, matching the resident ceiling) and stores a reference to the array the pane already holds; a **detached** view — an older transcript prefix whose live tail was dropped to stay under the resident cap after paging back — is skipped rather than evicted, so a later restore can never render an old slice as if it were current. The active-turn recovery loop is a **stall detector**, not the transport: it re-reads the transcript on a jittered `ACTIVE_TURN_RECOVERY_INTERVAL_MS` (10 s) tick and skips entirely when the live subscription delivered anything inside that window. Subscription ownership is handed to `chatSessionRetention.ts` when the pane hides. Left/right floating-pane reserve is applied only while a selected session surface renders those panes; an empty draft never reserves a hidden PR or Chat Actions pane, so its hero composer remains centered. | +| `AgentChatPane.tsx` | Top-level pane; IPC wiring, session state, presentation profile resolution, lane navigation, parallel launch orchestration, mounting of sub-panels and composer. It persists a per-session `ade.chat.lastViewed.v1:` timestamp in renderer `localStorage`; scheduled turns that fired since that timestamp produce a small dismissible while-you-were-away card floating above the composer; it shows the wake count, reviews the first wake divider, and omits raw turn output from the notice. Visible Work grid tiles flush user/lifecycle/live events immediately and poll-recover active transcripts so inactive-but-visible tiles stay current. Draft chats preserve user-touched model/reasoning/permission controls across late lane-session hydration, and composer text is keyed by session id or lane draft key so switching draft lanes does not reuse another draft's text. Accepts an optional `draftContextTargetId` prop so the Work sidebar can target an unsaved draft composer for context insertions (attachments, iOS/App Control/browser selections, draft text) even before a chat session exists; window event handlers match on either `sessionId` or `draftTargetId`. When auto-creating a lane the draft resolves the primary lane for the `onLaneChange` callback so the sidebar lane context stays in sync. Composer draft state (text, model, reasoning, attachments, context items) is persisted to `localStorage` under the `ade.chat.composerDraft.v1` key family and restored on scope change through `ComposerDraftStorageSnapshot`. Pending-steer Edit uses `cancelSteer({ requireQueued: true })`, then merges the queued text, file attachments, and context attachments into the captured composer draft; if the message already left the queue, the cancel fails and the draft is left unchanged. Draft launches are tracked through **root**-store-backed `DraftLaunchJob` state machines with multi-step progress (`creating-lane` -> `starting-session` -> `sending-prompt` -> `ready` / `failed`; auto-create names the lane deterministically up front and renames to the AI name in the background, so there is no blocking `naming-lane` phase); jobs live in the root store (not the per-project store) so an in-flight launch survives a remote project switch that tears down the originating project surface. The detached launch chain captures the originating `OpenProjectBinding`, passes it as a `pin` to branch/lane/chat/orchestration/PTY calls so a mid-launch project switch keeps targeting the originating runtime, pins rollback to that binding, and caps each step at 90 s (`withDraftLaunchTimeout`). The composer is cleared optimistically at job start, stale active rows gain a hide-status escape hatch, failed jobs expose Restore in the job strip and matching error banner, and the `DraftLaunchSnapshot` captures the full control state so the async launch uses frozen settings. It also owns the transcript-resilience rules described in [Transcript and turns](transcript-and-turns.md#history-snapshots-scroll-back-and-misses): `resolveChatHistoryMissAction` (a history miss never blanks a rendered transcript), `resolveSnapshotHistoryCursor` (`hasOlderHistory` is authoritative over `tailStartOffset`), the bounded silent retry ladder `OLDER_HISTORY_RETRY_DELAYS_MS = [800, 2400]`, the `syncPendingBySession` flag that surfaces as `data-chat-sync-pending` + a 2 px catch-up hairline under the header (a fading static rule, never a continuous animation; the fade is `motion-safe:`), and a minimal static cold-chat skeleton (`data-chat-cold-skeleton`) so a chat with no cached view reads as loading rather than empty. `resolveRenderedChatSessionId` picks the session to paint from the incoming props rather than the effect-synced `selectedSessionId`, which otherwise paints the outgoing chat's transcript for one frame after the pane is pointed elsewhere. The module-level view cache holds 8 entries / 128 MB total (32 MB per session, matching the resident ceiling) and stores a reference to the array the pane already holds; a **detached** view — an older transcript prefix whose live tail was dropped to stay under the resident cap after paging back — is skipped rather than evicted, so a later restore can never render an old slice as if it were current. The active-turn recovery loop is a **stall detector**, not the transport: it re-reads the transcript on a jittered `ACTIVE_TURN_RECOVERY_INTERVAL_MS` (10 s) tick and skips entirely when the live subscription delivered anything inside that window. Subscription ownership is handed to `chatSessionRetention.ts` when the pane hides. Left/right floating-pane reserve is applied only while a selected session surface renders those panes; an empty draft never reserves a hidden PR or Chat Actions pane, so its hero composer remains centered. | | `useDraftMachineRouting.ts`, `draftAttachmentTransfer.ts` | Draft machine selection and machine-safe attachment movement. Routing reconciles the machine restored by the current project/tab before the composer becomes sendable, resolves its `OpenProjectBinding`, and keeps lane selection scoped to that machine. On a user-requested machine change within one composer scope, `useDraftAttachmentTransfer` preserves portable image URLs and copies local/pasted image bytes from the attachment-owning runtime to the target runtime via pinned `getImageDataUrl` and `saveTempAttachment` calls. It removes non-image files and linked iOS/App Control/built-in-browser context because those machine-owned references are not portable. Pending transfer disables send. If copying fails, the source image references remain visible and sending stays blocked until the user returns to the source machine or removes the images. A project/tab scope change resets ownership only after machine selection has reconciled, so restoring a remote draft cannot be mistaken for an explicit local-to-remote switch. | | `apps/desktop/src/renderer/components/usage/ActivityModule.tsx`, `ActivityHeatmap.tsx`, `activityIntensity.ts` | Tabbed cross-client activity/tokens/code/clients module. `AgentChatPane` mounts the self-fetching `WorkActivityModule` (compact variant) beneath the empty Work draft composer when no app panel is open; the component persists the chosen tab and day/week/month/year range under `ade.activity.module.v1`. `ActivityHeatmap` owns the responsive seven-row grid, viewport fitting, and the intensity ramp, while `activityIntensity` provides the shared daily activity score, non-zero quartile buckets, and leading-inactive-day trimming used by the grid and summary counts. The score (`scoreActivityDays`) is series-relative: each of seven dimensions — tokens, sessions, interactions, commits, PRs, changed lines, changed files, with local and GitHub counterparts summed — is scaled against its own maximum across the visible series before the weighted sum, because the dimensions are not in the same units. A raw sum made every non-token term smaller than the rounding noise of daily token counts, so the "activity" heatmap was a token heatmap under another name. `isActiveDay` stays unweighted so one commit still colours a day. `describeActivityInsight` derives the single sentence rendered above the grid from the same scores — busiest-day record, week-over-week trend, or peak day, in that priority — so the callout and the grid can never disagree. Buckets are quartiles over the non-zero days only, GitHub-contribution-graph style: a linear value/max ramp is useless when one 35.9B-token day is normal, because that outlier flattens every other day into the same near-floor tone. The ramp itself is explicit light/dark pairs rather than one hue at five opacities — an opacity ramp of a single hue is only a lightness ramp, which inverts its ordering between a dark and a light card — so hue and saturation both climb with the level and the scale reads in either theme. It deliberately avoids `--color-accent`, which is violet in dark and green in light. Under `prefers-contrast: more` (`renderer/hooks/usePrefersMoreContrast.ts`) every tile also gains a hairline border so the steps stay separable on a forced-contrast display. A "Less → More" key renders alongside the grid so the ramp explains itself. | | `apps/desktop/src/renderer/lib/draftLaunchJobs.ts` | Pure helper for Work draft-launch job DTOs, terminal/stale-state detection, and pruning. The list keeps active rows ahead of terminal rows, fills remaining retained slots with terminal rows, and keeps at least one terminal row alongside active jobs. Also owns the durability constants/helpers: `DRAFT_LAUNCH_TIMEOUT_MS` (90 s) + `withDraftLaunchTimeout` (fails a step whose runtime call never settles; the underlying IPC is not cancellable, so it keeps running detached and the timeout only unwedges the renderer-side job) and `LAUNCH_PROJECT_CHANGED_MESSAGE` (the legacy/unpinned abort error used only when no originating project binding is available and the active project drifts mid-launch). | @@ -19,7 +19,7 @@ subagents, computer use). The pane derives all visible state from the | `apps/desktop/src/renderer/lib/aiDiscoveryCache.ts` | Runtime-binding-scoped AI integration-status and provider-model cache shared across renderer surfaces. Local and remote checkouts with the same project identity cannot share model/auth state. `getAiStatusCached` uses a 10-second freshness window and deduplicates concurrent `ade.ai.getStatus` requests; cache update/invalidation events let open ModelPickers react without polling or mounting their own background refresh loops. | | `CrossMachineHandoffModal.tsx`, `crossMachineHandoffPresentation.tsx` | Modal state and user flow for **Send to machine**. It takes a `runtimePin` naming the machine the *source* chat runs on (`null` = this tab's bound machine), and every source-side call is pinned to it: the lane list, `git.getSyncStatus`, `git.getOriginRemote`, `git.push`, `git.pull`, `agentChat.prepareCrossMachineHandoff`, `validateCrossMachineSource`, and `markCrossMachineHandoff`. Destination dispatch already routes by target id and is unaffected. The pin is held in a ref and **frozen once per operation** so every await inside one handoff reaches the same runtime — reading it fresh after an await could cross a lane-index change and split one handoff across two machines. It verifies the source lane on the pinned machine, follows live remote connection snapshots, lets the user pick brief or full-history fork (fork defaults on for fork-capable providers and constrains the model picker to the same provider), lets the user set the destination chat's model, reasoning effort, fast mode, and permission mode with the same shared pills the composer uses, handles existing-project versus confirmed-clone setup, offers a destination-run fast-forward when the target lane is clean and strictly behind the source commit, decodes destination responses at the renderer boundary, pins acceptance to the reviewed route kind, and exposes retryable source-marker failures after destination success. Source blockers render through `BlockedReasons` / `BlockedActionButton` instead of silently disabling Continue. The pure half — stage/mode types, `SourceCheck`, branch/route/repo-readiness copy, permission tone and icon maps, send-step labels, `CheckRow` — lives in `crossMachineHandoffPresentation.tsx` so it is assertable without mounting the stateful modal. Once destination acceptance is dispatched, a runtime timeout or connection interruption produces an amber unknown-outcome notice: the destination chat may still appear, the user should check that machine before retrying, and the modal never reports a truthful cancellation that the runtime did not perform. A fork that the destination can't accept (older ADE with no `forkHandoffSupport`, oversize history, or an unforkable provider file) surfaces a plain reason and a one-click **send as brief** that re-runs prepare + preflight; the insecure-route consent line is fork-aware (a fork discloses that the full history is sent exactly as recorded, a brief that only the summary is sent). | | `ChatRuntimeScope.tsx` | Which machine THIS chat is on, and what its lane looks like there — the single derivation every chat-scoped panel reads instead of a global store selector. `useChatRuntimeScope()` returns `{ pin, binding, laneId, lane, laneWorktreePath, rootPath, isRemote, machineName, online }` from context, `useChatRuntimeScopeForPin(pin, laneId, bindingOverride?)` derives the same from a pin passed as a prop (for surfaces mounted outside a chat pane), `useChatScopeDerivation({...})` answers it from a *session* for `AgentChatPane`, and `ChatRuntimeScopeProvider` carries the resolved scope down the panel/drawer subtree. `pin === null` means, and only means, "this chat lives on the tab's binding". ESLint bans `useAppStore` / `useRootAppStore` reads of `projectBinding` / `lanes`, `project.rootPath` reads, and `selectActiveProjectRoot` imports inside `components/chat/**` so no panel can quietly go back to reading the tab's machine. Full contract in the chat [README](README.md#source-file-map). | -| `AgentChatMessageList.tsx` | Virtualized message list. The virtualizer is **hand-rolled**, not `@tanstack/react-virtual`: a `measuredHeights` row-key → height `Map` feeds top/bottom spacer divs around the rendered window, and each rendered row is wrapped in `MeasuredEventRow`, whose `ResizeObserver` reports its real height through `handleMeasure` → `reconcileMeasuredScrollTop` so a height correction above the viewport does not shift what the reader is looking at. Renders transcript rows and turn dividers, including a `Woke on schedule` divider before every synthetic scheduled turn and inline `SubagentSpawnCard` / `SubagentResultCard` / `BackgroundJobLine` rows (from `SubagentActivityCards.tsx`) for real subagents and backgrounded shell commands, and accepts stable row-key jump requests from the while-you-were-away strip and the spawn/result jump affordances. Keeps sticky-bottom sessions pinned across streamed row growth and late virtual-height measurements. A Claude `queue_recovery: available` row renders one eight-second Undo card; later `restored`/`expired` rows settle the same recovery id so history replay cannot show a stale action. The last text block of a multi-block assistant turn exposes Copy turn, which joins only that turn's assistant text blocks with blank lines; legacy rows without a turn id and single-block turns keep only the normal block copy. Plan-approval rows with non-empty body text render a scrollable markdown block (capped at `360px`) beneath the header so the user can review plan content inline. Codex goal lifecycle rows use user-facing text such as `Goal set`, `Goal paused`, and `Goal cleared`. A stalled Codex turn renders a clickable Wait / Nudge / Retry / Resume recovery card wired to `agentChat.recoverCodexTurn`; terminal provider capacity/usage-limit errors render `ProviderFailureRecoveryCard` with same-thread retry and model-selection actions. User messages marked `metadata.hideFullPrompt` render and copy only their `displayText`, keeping internal handoff briefs out of the visible transcript details, and a handoff-brief user row shows a small brief chip. When a fork seeds pre-fork history into the new chat, the envelopes carry the `handoff_fork` provider origin and the list draws a single `Forked from the previous chat — full history above` divider (`computeForkHistoryDividerRowKey` pins it to the first live row after the seeded history) instead of one marker per seeded row. `WorkingIndicator` is the in-flight turn's status line — ` · working for `, plus a `taking longer than usual` marker past `LONG_RUNNING_TURN_SECONDS`. The activity half comes from `resolveWorkingIndicatorLabel`: `ACTIVITY_LABELS` is keyed against the `activity` union in `shared/types/chat.ts` so a new runtime value is a compile error rather than a raw `web_searching` on screen, and an `editing_file` activity is named with its target (`Editing laneService.ts`) by walking back to the most recent unfinished write entry in the turn — `activity` events carry the tool name, not the file. Its elapsed is written imperatively (`textContent` on a ref) rather than through state, so the once-per-second tick never commits a render on the message list. The line swaps a bare `` for an expander `