+{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
+
+
+
+ Review
+
+
+
+
+
+ );
+}
diff --git a/apps/desktop/src/renderer/components/chat/ChatLifecycleBanner.test.tsx b/apps/desktop/src/renderer/components/chat/ChatLifecycleBanner.test.tsx
index efd1310a9..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", () => ({
@@ -93,16 +94,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 +115,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 +141,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())]);
@@ -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 bde3c499f..d1b5ff6c2 100644
--- a/apps/desktop/src/renderer/components/chat/ChatLifecycleBanner.tsx
+++ b/apps/desktop/src/renderer/components/chat/ChatLifecycleBanner.tsx
@@ -1,47 +1,25 @@
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";
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 +29,25 @@ const VARIANT_CHROME: Record
-
-
-
-
-
- {title}
-
-
- {line}
-
-
+
+
+ {title}
+
+ ·
+
+ {detail}
+
{
// Both route through the shared Work-tab lifecycle actions rather than
- // calling `window.ade.sessions` directly, so this banner, the header
- // chip and the sidebar row menu perform the identical write (pin-aware
- // single-session call) and report failures the same way.
- void (snoozed ? wakeSessionNow(session) : unsettleSession(session));
+ // calling `window.ade.sessions` directly, so this pill, the snooze
+ // header chip, and the sidebar menu use the same write and failure path.
+ void (snoozed ? wakeSessionNow(session, runtimePin) : unsettleSession(session, runtimePin));
}}
>
{actionLabel}
diff --git a/apps/desktop/src/renderer/components/chat/chatTranscriptRows.test.ts b/apps/desktop/src/renderer/components/chat/chatTranscriptRows.test.ts
index 3a7dcc664..c39379648 100644
--- a/apps/desktop/src/renderer/components/chat/chatTranscriptRows.test.ts
+++ b/apps/desktop/src/renderer/components/chat/chatTranscriptRows.test.ts
@@ -1433,7 +1433,7 @@ describe("formatDoneTurnTokenLine", () => {
});
describe("chatTranscriptRows edge cases", () => {
- it("filters out step_boundary and activity events", () => {
+ it("filters out non-visual activity and token accounting events", () => {
const rows = collapseChatTranscriptEvents([
{
sessionId: "session-1",
@@ -1445,6 +1445,29 @@ describe("chatTranscriptRows edge cases", () => {
timestamp: "2026-03-17T10:00:01.000Z",
event: { type: "activity", activity: "reading", detail: "foo.ts", turnId: "turn-1" },
},
+ {
+ sessionId: "session-1",
+ 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 f2a75ef5e..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;
@@ -1825,9 +1827,15 @@ export function appendCollapsedChatTranscriptEvent(
return;
}
- // Codex token usage drives the chat-bottom token footer; inline transcript
- // rows would be duplicate noise.
- if (event.type === "codex_token_usage" || event.type === "codex_moderation_metadata") {
+ // Provider token usage drives the end-of-turn footer; inline transcript rows
+ // would be duplicate noise. Cursor's `tokens` event used to fall through to
+ // the generic renderer and draw a bare horizontal divider labelled "event"
+ // immediately above the footer that already contained the same numbers.
+ if (
+ event.type === "tokens"
+ || event.type === "codex_token_usage"
+ || event.type === "codex_moderation_metadata"
+ ) {
return;
}
diff --git a/apps/desktop/src/renderer/components/terminals/SessionListPane.test.tsx b/apps/desktop/src/renderer/components/terminals/SessionListPane.test.tsx
index 63278510f..90981aa8e 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,54 @@ 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("keeps the fallback expiry timer for a partial filing map", () => {
+ 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({
+ // A caller may know the local roster but not yet have a complete
+ // cross-machine map. The uncovered foreign row still needs its
+ // render-only deadline timer so it can leave the shelf on time.
+ effectiveFilingBuckets: new Map([["session-mobile", "running"]]),
+ workCollapsedSectionIds: OPEN_QUIET_SHELVES,
+ });
+
+ expect(shelfContains(container, "snoozed")).toBe(true);
+ act(() => {
+ vi.advanceTimersByTime(1_250);
+ });
+
+ 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: [
@@ -2518,6 +2599,47 @@ describe("SessionListPane singleton lanes and shelves", () => {
expect(screen.queryByText("Already done")).toBeNull();
});
+ it("files a settled chat and its still-running attached shell together in the settled shelf", () => {
+ const parent = makeSession({
+ id: "settled-parent-chat",
+ laneId: "lane-known",
+ laneName: "Known Lane",
+ title: "Settled parent chat",
+ status: "completed",
+ runtimeState: "exited",
+ endedAt: "2026-07-23T12:00:00.000Z",
+ settledAt: "2026-07-23T12:01:00.000Z",
+ });
+ const child = makeSession({
+ id: "attached-running-shell",
+ laneId: "lane-known",
+ laneName: "Known Lane",
+ toolType: "shell",
+ title: "Attached running shell",
+ ptyId: "pty-attached-running",
+ chatSessionId: parent.id,
+ status: "running",
+ runtimeState: "running",
+ endedAt: null,
+ settledAt: null,
+ });
+
+ const { container } = renderPane({
+ lanes: [makeLane()],
+ runningFiltered: [child],
+ settledFiltered: [parent],
+ allSessionsUnfiltered: [parent, child],
+ sessionsGroupedByLane: new Map([["lane-known", [parent, child]]]),
+ workCollapsedSectionIds: OPEN_QUIET_SHELVES,
+ });
+
+ const settledShelf = container.querySelector('[data-testid="shelf-body-settled"]');
+ expect(settledShelf).toBeTruthy();
+ expect(settledShelf?.contains(screen.getByText("Settled parent chat"))).toBe(true);
+ expect(settledShelf?.contains(screen.getByText("Attached running shell"))).toBe(true);
+ expect(screen.getByRole("button", { name: /1 shell/i })).toBeTruthy();
+ });
+
it("files a lane mixing both quiet kinds by the dominant one, still flat", () => {
const [settledA, settledB] = settledPair();
const asleep = makeSession({
diff --git a/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx b/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx
index 00133b7b9..9a00531bc 100644
--- a/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx
+++ b/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx
@@ -10,10 +10,12 @@ import type { SessionContextMenuLaneActions, SessionContextMenuOpenIn } from "./
import { boundMachineLanePrs, laneHasAnyPr, lanePrsForMachine, useLanePrsByLaneId } from "./useLanePrs";
import {
canonicalInputFromSummary,
- sessionFilingBucket,
+ 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,9 +334,9 @@ function partitionQuietSessions(sessions: readonly TerminalSessionSummary[]): {
const active: TerminalSessionSummary[] = [];
const snoozed: TerminalSessionSummary[] = [];
const settled: TerminalSessionSummary[] = [];
- const nowMs = Date.now();
+ const buckets = effectiveFilingBuckets ?? effectiveSessionFilingBuckets(sessions);
for (const session of sessions) {
- const bucket = sessionFilingBucket(session, nowMs);
+ const bucket = buckets.get(session.id) ?? null;
if (bucket === "snoozed") {
snoozed.push(session);
} else if (bucket === "settled") {
@@ -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,65 @@ 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
+ && foreignSessionsForFiling.every((session) => effectiveFilingBucketsProp.has(session.id))
+ ) 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,16 +1248,18 @@ 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 nowMs = Date.now();
const snoozed = new Set();
const settled = new Set();
const all = new Set();
+ const buckets = effectiveFilingBucketsProp
+ ?? effectiveSessionFilingBuckets(allSessionsUnfiltered);
for (const session of allSessionsUnfiltered) {
- const bucket = sessionFilingBucket(session, nowMs);
+ const bucket = buckets.get(session.id) ?? null;
if (bucket === "snoozed") {
snoozed.add(session.id);
all.add(session.id);
@@ -1196,9 +1269,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 +1441,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 +1472,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 +1488,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 +1505,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 +1514,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 +1526,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 +1537,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 +1557,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 +2255,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,
@@ -2400,8 +2492,8 @@ export const SessionListPane = React.memo(function SessionListPane({
layoutDependency={laneOrderSignature}
quietCounts={laneQuiet && collapsed
? {
- snoozed: list.filter((session) => snoozedIdSet.has(session.id)).length,
- settled: list.filter((session) => settledIdSet.has(session.id)).length,
+ snoozed: list.filter((session) => unfilteredQuietBuckets.snoozed.has(session.id)).length,
+ settled: list.filter((session) => unfilteredQuietBuckets.settled.has(session.id)).length,
}
: null}
onToggleCollapsed={() => {
@@ -2462,7 +2554,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 +2570,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 155824490..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,10 @@
/* @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 { showToast } from "../app/toast/toastStore";
-import { SessionLifecycleChips } from "./SessionLifecycleChips";
-
-vi.mock("../app/toast/toastStore", () => ({
- showToast: vi.fn(),
-}));
+import { SessionSnoozeChip } from "./SessionLifecycleChips";
const PROJECT_ROOT = "/tmp/project";
@@ -44,17 +39,47 @@ function seedSessions(sessions: TerminalSessionSummary[]): void {
project: { rootPath: PROJECT_ROOT } as never,
projectBinding: null,
sessionsCacheByProject: { [PROJECT_ROOT]: sessions },
+ crossMachineLanesByMachineId: {},
});
}
-describe("SessionLifecycleChips", () => {
+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,
@@ -63,15 +88,17 @@ describe("SessionLifecycleChips", () => {
});
afterEach(() => {
+ vi.useRealTimers();
cleanup();
useAppStore.setState({ sessionsCacheByProject: {} });
+ useAppStore.setState({ crossMachineLanesByMachineId: {} });
Reflect.deleteProperty(window, "ade");
vi.clearAllMocks();
});
it("renders nothing for a live chat", () => {
seedSessions([makeSession()]);
- const { container } = render( );
+ const { container } = render( );
expect(container.textContent).toBe("");
});
@@ -80,7 +107,7 @@ describe("SessionLifecycleChips", () => {
snoozedUntil: new Date(Date.now() + 3_600_000).toISOString(),
snoozedAt: new Date(Date.now() - 60_000).toISOString(),
})]);
- render( );
+ render( );
fireEvent.click(screen.getByTestId("chat-session-snoozed-chip"));
fireEvent.click(screen.getByRole("menuitem", { name: "Wake now" }));
@@ -88,57 +115,64 @@ describe("SessionLifecycleChips", () => {
await waitFor(() => expect(sessionsApi.wakeSession).toHaveBeenCalledWith("session-1", "manual"));
});
- it("does not render a settled chip for a clean process exit", () => {
+ it("does not render a header chip for settled sessions", () => {
seedSessions([makeSession({
- toolType: "shell",
status: "completed",
runtimeState: "exited",
endedAt: "2026-07-09T11:00:00.000Z",
- exitCode: 0,
- settledAt: null,
+ settledAt: "2026-07-09T11:01:00.000Z",
})]);
- render( );
+ const { container } = render( );
+ expect(container.firstChild).toBeNull();
expect(screen.queryByTestId("chat-session-settled-chip")).toBeNull();
- expect(sessionsApi.unsettle).not.toHaveBeenCalled();
- expect(sessionsApi.setSettleOverride).not.toHaveBeenCalled();
});
- it("clears a declared settle through the settle column", async () => {
- seedSessions([makeSession({
- status: "completed",
- runtimeState: "exited",
- endedAt: "2026-07-09T11:00:00.000Z",
- settledAt: "2026-07-09T11:01:00.000Z",
- })]);
- render( );
+ 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( );
- fireEvent.click(screen.getByTestId("chat-session-settled-chip"));
- fireEvent.click(screen.getByRole("menuitem", { name: "Unsettle" }));
+ 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.unsettle).toHaveBeenCalledWith("session-1"));
- expect(sessionsApi.setSettleOverride).not.toHaveBeenCalled();
+ await waitFor(() => expect(sessionsApi.wakeSession).toHaveBeenCalledWith(
+ foreign.id,
+ "manual",
+ runtimePin,
+ ));
});
- it("surfaces a failed unsettle instead of swallowing it", async () => {
- // Regression: the chip used to call `unsettle` with a bare `.catch(() => {})`,
- // so a rejected write left the settled chip in place with no feedback.
- const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
- sessionsApi.unsettle.mockRejectedValue(new Error("host refused"));
- seedSessions([makeSession({
- status: "completed",
- runtimeState: "exited",
- endedAt: "2026-07-09T11:00:00.000Z",
- settledAt: "2026-07-09T11:01:00.000Z",
+ 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(),
})]);
- render( );
+ const { container } = render( );
- fireEvent.click(screen.getByTestId("chat-session-settled-chip"));
- fireEvent.click(screen.getByRole("menuitem", { name: "Unsettle" }));
+ expect(screen.getByTestId("chat-session-snoozed-chip")).toBeTruthy();
+ act(() => {
+ vi.advanceTimersByTime(1_250);
+ });
- await waitFor(() => expect(showToast).toHaveBeenCalledWith(
- expect.objectContaining({ title: "Unsettle failed", message: "host refused", tone: "error" }),
- ));
- consoleError.mockRestore();
+ 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 69bfb2701..79ef82399 100644
--- a/apps/desktop/src/renderer/components/work/SessionLifecycleChips.tsx
+++ b/apps/desktop/src/renderer/components/work/SessionLifecycleChips.tsx
@@ -1,33 +1,28 @@
-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 { canonicalInputFromSummary, sessionCanonicalUiState } from "../../lib/terminalAttention";
-import { isSessionSnoozed, snoozeWakeLabel } from "../../lib/sessionSnooze";
-import {
- unsettleSession,
- wakeSessionNow,
-} from "../terminals/sessionLifecycleActions";
+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";
/**
- * Ambient lifecycle chips for a chat surface header. The chat pane had zero
- * lifecycle awareness: a settled or snoozed chat looked identical to a live one
- * once you were inside it.
+ * Ambient snooze chip for a chat surface header.
*
- * These are HEADER chips, not a strip above the composer — that slot belongs to
- * lane branch drift. State is resolved from the same derived helpers the Work
- * sidebar uses (`sessionCanonicalUiState` + `isSessionSnoozed`), so the chip and
- * the row can never disagree.
+ * Settled state lives only in the compact composer-adjacent pill; repeating it
+ * in the header added chrome without adding information. Snooze stays here
+ * because its wake deadline is useful away from the composer too.
*/
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(
@@ -37,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({
@@ -80,89 +99,49 @@ function ChipMenu({
);
}
-export function SessionLifecycleChips({
+export function SessionSnoozeChip({
sessionId,
className,
+ runtimePin = null,
}: {
sessionId: string | null | undefined;
className?: string;
+ runtimePin?: OpenProjectBinding | null;
}) {
const session = useSessionLifecycleSnapshot(sessionId);
- const [openChip, setOpenChip] = useState<"snoozed" | "settled" | null>(null);
+ const [menuOpen, setMenuOpen] = useState(false);
if (!session) return null;
const snoozed = isSessionSnoozed(session);
- const settled = sessionCanonicalUiState(canonicalInputFromSummary(session)).phase === "settled";
- if (!snoozed && !settled) return null;
+ if (!snoozed) return null;
const wakeLabel = snoozeWakeLabel(session.snoozedUntil);
return (
- <>
- {snoozed ? (
-
- setOpenChip((current) => (current === "snoozed" ? null : "snoozed"))}
- >
-
- snoozed
-
- {openChip === "snoozed" ? (
- setOpenChip(null)}
- items={[
- { key: "wake", label: "Wake now", onSelect: () => { void wakeSessionNow(session); } },
- ]}
- />
- ) : null}
-
- ) : null}
-
- {settled ? (
-
- setOpenChip((current) => (current === "settled" ? null : "settled"))}
- >
-
- 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}
-
+
+ setMenuOpen((current) => !current)}
+ >
+
+ snoozed
+
+ {menuOpen ? (
+ setMenuOpen(false)}
+ items={[
+ { 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 29202d028..db5c90256 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,10 @@ 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.
- */
+ /** Session id whose metadata-generation state controls the title shimmer. */
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. */
@@ -258,6 +256,7 @@ export function WorkSurfaceHeader({
showCacheBadge = false,
cacheIdleSinceAt,
lifecycleSessionId = null,
+ snoozeSessionId = null,
showGitToolbar = false,
prSessionId = null,
onTogglePrPane,
@@ -307,7 +306,7 @@ export function WorkSurfaceHeader({
/>
) : null}
{laneId ? : null}
- {lifecycleSessionId ? : 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 817cc74dc..e991998a1 100644
--- a/apps/desktop/src/renderer/lib/terminalAttention.ts
+++ b/apps/desktop/src/renderer/lib/terminalAttention.ts
@@ -297,6 +297,40 @@ 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(session.id) === "snoozed"
+ || buckets.get(parent.id) !== "settled"
+ || sessionNeedsYou(canonicalInputFromSummary(session))
+ ) continue;
+ buckets.set(session.id, "settled");
+ }
+
+ return buckets;
+}
+
export function sessionMatchesStatusFilter(
args: SessionCanonicalUiInput,
filter: SessionStatusFilter,
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 `` the instant the turn's first tool entry arrives, which remounts the timer node, so the ref is a **callback** ref: it repaints the counter in the same commit it attaches, and the ticker re-reads the ref every tick. An element captured once when the ticker started would be detached by that swap and the display would sit frozen at `0s` while the long-running marker still appeared. `ChatInfoHostContext` also lives here — a boolean context reporting whether the *owning host* listens for `ade:chat:open-info`. `AgentChatPane` provides `true` (it owns the chat actions pane); `PersonalChatsPage` mounts the same transcript without it, so the `BackgroundJobLine` `open` button is absent there rather than dispatching into nothing. It must stay a context rather than a module-level registry: `App` renders every `ProjectSurface` and only toggles `active`, so each `AgentChatPane` stays mounted while Personal Chats is open, and a global "is any host alive" flag would read true on exactly the surface that has no pane. |
+| `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 compact while-you-were-away card 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 `` the instant the turn's first tool entry arrives, which remounts the timer node, so the ref is a **callback** ref: it repaints the counter in the same commit it attaches, and the ticker re-reads the ref every tick. An element captured once when the ticker started would be detached by that swap and the display would sit frozen at `0s` while the long-running marker still appeared. `ChatInfoHostContext` also lives here — a boolean context reporting whether the *owning host* listens for `ade:chat:open-info`. `AgentChatPane` provides `true` (it owns the chat actions pane); `PersonalChatsPage` mounts the same transcript without it, so the `BackgroundJobLine` `open` button is absent there rather than dispatching into nothing. It must stay a context rather than a module-level registry: `App` renders every `ProjectSurface` and only toggles `active`, so each `AgentChatPane` stays mounted while Personal Chats is open, and a global "is any host alive" flag would read true on exactly the surface that has no pane. |
| `AgentChatComposer.tsx`, `ComposerPromptStash.tsx`, `ComposerSmartLinkMenu.tsx`, `smartLinkChipMark.ts` | Text input, attachments, model selector, compact title-only permission controls (per-provider `PermissionModePickerOption` tables fed into the shared `components/shared/PermissionModePicker`, which the composer owns the option data for but not the control), slash commands, desktop prompt stashes, smart-link chips (`smartLinkChipMark.ts` returns the inline `currentColor` SVG brand mark each chip renders), **chat-context chips** (select assistant output → Add to chat; the chip label is `Chat context`, serializes an `` block so the agent sees the highlighted passage, and click/backspace copy or remove it like a smart-link chip), pending-input answering (including Codex MCP form/URL elicitations), voice-dictation target registration, and parallel model-slot controls. A running chat's toolbar also shows a read-only amber tower plus the owning machine name beside the model and thinking controls; it identifies where the chat executes and directs moves to Chat actions → Handoff → Continue on another machine. A draft omits that label because its launch shelf owns the machine choice. `ComposerPromptStash` keeps its command surface mounted when the Appearance preference hides the bookmark, so Cmd/Ctrl+S remains available, but the visible bookmark stays out of the toolbar while both the composer and stash list are empty. Its menu is rendered in a viewport-clamped body portal with a bounded, scrolling list so composer overflow and short windows cannot crop it. A save first copies up to ten attached images into the owning project runtime, commits the text plus image references, then clears only the unchanged composer snapshot; restore reapplies both text and images before consuming the stash. The list renders an image thumbnail when the active runtime owns the bytes. On another synced runtime, the row retains its text and image count, labels the images as living on another machine, and refuses restore until the composer is connected to the origin runtime. Machine-bound context and non-image files are not stashed. Completed URLs are non-editable inline chips whose `data-composer-chip-text` preserves the literal URL during serialization; clicking or keyboard-activating a chip opens the Copy link / Remove link menu, and character deletion removes the whole URL token. Every chip also carries a kind-naming `data-composer-chip` attribute, and a scoped `selectionchange` effect marks intersecting chips with `data-composer-chip-selected` so the native selection paints continuously across them (overlay styling lives in `apps/desktop/src/renderer/index.css`). During an active Claude turn, its split Send control selects without dispatching among inline, after-turn, and interrupt delivery; the primary button and Enter execute the selected mode. A separate Claude split Stop control selects **Stop & clear queue** or **Stop only**, persists that choice per chat, and dismisses its custom popover immediately after selection. Staged rows expose send-during-turn, interrupt, cancel, and Edit-back-to-composer actions. It forwards one-shot open requests to the shared ModelPicker so transcript recovery cards can open model selection without synthetic DOM events; the picker acknowledges each request so remounts do not reopen it. Launch-prompt clipboard reminder text is controlled by `launchPromptClipboardNoticeEnabled`, separate from the `launchPromptClipboardEnabled` copy behavior. For orchestration model-selection pending inputs it decodes the agent briefing metadata (`workDescription`, `filesHint`, `dependsOn`) before rendering the selection card. It holds the last `@`/`/` popover dismissal in a ref (it gates the next open, never a render) and has **three** distinct close semantics: `closeCommandMenu()` closes and forgets the dismissal, for a trigger that is resolved (a selection was made, the trigger is gone, the composer locked or reset); `dismissCommandMenu(trigger)` closes and keeps it closed while the user extends this query, used for Escape (an explicit dismissal — typing the rest of the token must not bring the menu back), for Enter/Tab with no matching row, and for the menu's own dead-query report; and `closeCommandMenuKeepingDismissal()` is the third and the one that is easy to write by accident — this trigger cannot open a menu right now (it is already a confirmed token, or still covered by an earlier dismissal), but nothing is resolved and nothing new was dismissed, so clearing the dismissal here would reopen the menu the user just escaped and recording one would suppress the menu for a trigger the user never dismissed. |
| `apps/desktop/src/main/services/chat/promptStashService.ts` | Runtime-backed prompt-stash persistence. Exact prompt text, up to ten image references, their origin sync-site id, provider/model labels, and creation time are stored in the PK-only, CRR-compatible `prompt_stashes` table; newest-first retention is capped at 20 entries. Text and metadata converge across runtimes. HTTP(S) image references remain portable, while local-image bytes stay on the originating runtime: off-origin readers receive the image count but no absolute paths and cannot consume the stash. Origin-owned image files referenced by live stashes are protected from the normal seven-day temporary-attachment cleanup. |
| `ProviderFailureRecoveryCard.tsx` | Friendly recovery surface for terminal provider capacity and usage-limit failures. Shows human-readable error identity and guidance, then offers **Retry turn** and **Choose model** only after the failed turn has released the composer. |
@@ -28,7 +28,7 @@ subagents, computer use). The pane derives all visible state from the
| `VoiceDictationButton.tsx`, `microphonePermissionGuidance.ts`, `apps/desktop/src/renderer/services/globalVoiceRecorder.ts`, `apps/desktop/src/renderer/components/voice/*`, `apps/desktop/src/main/services/transcription/microphoneAccess.ts` | Desktop dictation UI, platform permission boundary, and recorder. The module-level recorder owns mic capture across navigation, writes live state to the root app store, transcribes via `window.ade.transcription`, inserts cleaned text into the registered composer, and always copies the cleaned transcript to the clipboard. Main asks Electron for the platform microphone status; Windows denied/restricted states return explicit Privacy & security > Microphone guidance, while unknown/not-determined states are left to Chromium's origin permission flow. The header indicator and composer pill render the same recording state. |
| `apps/desktop/src/main/services/transcription/*` | Electron main-process transcription service. Writes captured 16 kHz mono PCM to WAV, runs bundled whisper.cpp `base.en`, parses the JSON sidecar, and applies deterministic glossary cleanup. |
| `apps/desktop/resources/voice/voice-glossary.json`, `apps/desktop/resources/whisper/README.md` | Shared dictation glossary and release notes for materialized whisper resources. The large model and binary are generated by `materialize-whisper-resources.mjs` and ignored by git. |
-| `apps/desktop/src/renderer/components/work/SessionLifecycleChips.tsx` | Ambient `settled` / `snoozed` chips for the chat surface header, mounted by `WorkSurfaceHeader` through its `lifecycleSessionId` prop (`AgentChatPane` passes the selected session id). `useSessionLifecycleSnapshot(sessionId)` reads the session row out of the per-project session cache the Work tab already mirrors into the app store, so there is no extra IPC; the phase/snooze derivations are the shared `sessionCanonicalUiState` + `isSessionSnoozed` / `snoozeWakeLabel` helpers the Work sidebar uses. Chip menus call `wakeSessionNow` / `setSessionSettleOverride` from `renderer/components/terminals/sessionLifecycleActions.ts`, falling back to `window.ade.sessions.unsettle` for a declared settle. |
+| `apps/desktop/src/renderer/components/work/SessionLifecycleChips.tsx` | Ambient snooze chip for the chat surface header, mounted by `WorkSurfaceHeader` through its `snoozeSessionId` prop (`AgentChatPane` passes the selected session id). Settled state is rendered by `ChatLifecycleBanner` as a compact pill above the composer. `useSessionLifecycleSnapshot(sessionId)` reads the row from the local per-project session cache the Work tab already mirrors into the app store, then falls back to the root cross-machine lane snapshot for a foreign chat; a bounded render-only deadline timer repaints an open snoozed row when it expires, with no extra IPC or lifecycle write. The snooze menu calls `wakeSessionNow` from `renderer/components/terminals/sessionLifecycleActions.ts`. |
| `ChatSurfaceShell.tsx` | Floating chat header, body, footer layout. Backdrop-blur glass-morphism styling. |
| `ChatComposerShell.tsx` | Input container chrome reused by the composer. |
| `ChatAttachmentTray.tsx` | Inline file/image attachment tray inside the composer. Image attachments render an inline thumbnail, open a full-size lightbox on click, and expose a copy-to-clipboard button that ships the image bytes via `window.ade.app.writeClipboardImage` so the user can paste them into another app. Pasted images can pass a seeded preview URL from the composer while the temp file is being saved; tray-only image refs fall back to `window.ade.app.getImageDataUrl`. Non-image attachments fall back to the file glyph. |
@@ -216,22 +216,17 @@ that could not work without it.
the lane in the Lanes tab via the app store.
- CTO and resolver surfaces override the title and chips through
`ChatSurfacePresentation` (`assistantLabel`, `accentColor`, `chips`).
-- Ambient lifecycle chips. `AgentChatPane` passes the selected session id to
- `WorkSurfaceHeader` as `lifecycleSessionId`, which mounts
- `renderer/components/work/SessionLifecycleChips.tsx`. A settled or snoozed
- chat would otherwise look identical to a live one once you are inside it, so
- the header shows a `settled` and/or `snoozed` chip whose menus call
- `wakeSessionNow` / `setSessionSettleOverride` (or `sessions.unsettle` for a
- declared settle) from
- `renderer/components/terminals/sessionLifecycleActions.ts`. State comes from
- the same derived helpers the Work sidebar uses (`sessionCanonicalUiState` +
- `isSessionSnoozed` / `snoozeWakeLabel`) and
- `useSessionLifecycleSnapshot(sessionId)` reads the `terminal_sessions` row out
- of the per-project session cache the Work tab already mirrors into the app
- store — no extra IPC, and a chip can never disagree with its sidebar row. The
- chips live in the **header** deliberately: the slot directly above the
- composer belongs to lane branch drift, where `AgentChatPane` renders
- ` ` and arms it
+- Ambient lifecycle affordances. `AgentChatPane` passes the selected session id
+ to `WorkSurfaceHeader` as `snoozeSessionId`, which mounts the optional
+ `SessionSnoozeChip` from `renderer/components/work/SessionLifecycleChips.tsx`.
+ Settled state is rendered once as the compact `ChatLifecycleBanner` pill
+ floating above the composer; it is not duplicated in the header. The snooze
+ menu calls `wakeSessionNow`, and the banner's Un-settle action uses the shared
+ lifecycle action path. Both read the same local per-project session cache,
+ with a root cross-machine snapshot fallback for a foreign chat, and the same
+ canonical helpers as the Work sidebar, so the visible state stays consistent.
+ The slot remains above the composer by design: `AgentChatPane` also renders
+ ` ` there and arms it
(`armLaneBranchDriftWarning`) on submit so a turn about to run against a
worktree whose HEAD wandered off the lane's branch warns first. See
[Terminals and sessions](../terminals-and-sessions/README.md) and
diff --git a/docs/features/chat/transcript-and-turns.md b/docs/features/chat/transcript-and-turns.md
index 959ef80f0..6bb5a7d4c 100644
--- a/docs/features/chat/transcript-and-turns.md
+++ b/docs/features/chat/transcript-and-turns.md
@@ -251,7 +251,7 @@ implements a two-layer transform:
`user_message` carrying `metadata.scheduledWake`, the transform inserts a
`scheduled_wake_divider` keyed
`scheduled-wake::` with fire time, reason, and late
- state; the while-you-were-away strip scrolls to these stable keys.
+ state; the compact while-you-were-away card scrolls to these stable keys.
- `subagent_started` / `subagent_progress` / `subagent_result`
events collapse per agent (keyed by `agentId ?? taskId`) into two
stable render rows — a `subagent_spawn_anchor` at the start
diff --git a/docs/features/sync-and-multi-device/README.md b/docs/features/sync-and-multi-device/README.md
index 018842f72..0cd07f032 100644
--- a/docs/features/sync-and-multi-device/README.md
+++ b/docs/features/sync-and-multi-device/README.md
@@ -1202,7 +1202,11 @@ Cross-machine Work union:
`SessionListPane.tsx`, and
`apps/desktop/src/renderer/lib/terminalAttention.ts` — route chat-created
ownership metadata into the local or foreign optimistic path and render both
- through the shared `sessionFilingBucket` lifecycle-plus-snooze contract.
+ through the relationship-aware `effectiveSessionFilingBuckets` wrapper around
+ the shared `sessionFilingBucket` lifecycle-plus-snooze contract. Open chat and
+ lifecycle controls read the local cache first, then the root cross-machine
+ snapshot for a foreign row, and keep their filing fresh with bounded
+ render-only deadline timers.
- `apps/desktop/src/renderer/components/terminals/useWorkMachineRouter.ts` —
per-session machine routing for the union. A CLI or shell session on another
machine opens **in place** with its owning `OpenProjectBinding` carried as a
diff --git a/docs/features/terminals-and-sessions/README.md b/docs/features/terminals-and-sessions/README.md
index 9bbae39d1..51dec6d6a 100644
--- a/docs/features/terminals-and-sessions/README.md
+++ b/docs/features/terminals-and-sessions/README.md
@@ -451,7 +451,7 @@ Shared types and IPC:
exported helpers; nothing here reads or writes a canonical phase.
- `apps/desktop/src/renderer/components/terminals/sessionLifecycleActions.ts` —
one place for the Work tab's snooze / wake / settle-override writes, so the
- sidebar row menu, the row context menu, and the chat header chips can never
+ sidebar row menu, the row context menu, and the chat header snooze affordance can never
disagree about what an action does or the copy it confirms with. Snooze
computes the deadline client-side and hands it over as a concrete ISO instant
(expiry is derived from it everywhere, so no scheduler is involved) and offers
@@ -489,16 +489,16 @@ Shared types and IPC:
supports clickable PR/parent-thread rows, cancels on scroll/resize, clamps to
the viewport, and uses a reduced-motion-aware fade/slide.
- `apps/desktop/src/renderer/components/work/SessionLifecycleChips.tsx` —
- ambient settled/snoozed chips for a chat surface header, mounted by
- `WorkSurfaceHeader` through its `lifecycleSessionId` prop. A chat pane
- otherwise has no lifecycle awareness at all: a settled or snoozed chat looks
- identical to a live one once you are inside it. State is resolved from the
- same derived helpers the Work sidebar uses (`sessionCanonicalUiState` +
- `isSessionSnoozed`), so a chip and its sidebar row can never disagree.
- `useSessionLifecycleSnapshot(sessionId)` reads the row out of the per-project
- session cache the Work tab already mirrors into the app store, so there is no
- extra IPC. These are header chips; the slot above the composer belongs to lane
- branch drift (`LaneBranchDriftStrip`).
+ the optional `SessionSnoozeChip` for a chat surface header, mounted by
+ `WorkSurfaceHeader` through its `snoozeSessionId` prop. Settled state is shown
+ once by the compact `ChatLifecycleBanner` pill floating above the composer,
+ rather than repeated in the header. Both surfaces read the same local
+ per-project session cache, with a root cross-machine snapshot fallback for a
+ foreign chat, and the same canonical helpers as the Work sidebar; the snooze
+ menu calls `wakeSessionNow`, while the banner offers Un-settle. A bounded
+ render-only deadline timer repaints an open foreign/local snapshot when its
+ snooze expires. The slot above the composer also hosts lane branch drift
+ (`LaneBranchDriftStrip`).
- `apps/ade-cli/src/sessionSnoozeDuration.ts` — snooze duration grammar shared
by the `ade session snooze` planner in `cli.ts` and `ade code`'s
`/session snooze`, extracted so there is exactly one answer to "what does
@@ -778,8 +778,9 @@ Renderer surfaces:
restores the normal lane header and compact quiet rows. Quiet expansion uses
the inverted `lane-open:` marker, which is removed when active work
returns so the next quiet spell collapses automatically. The classification
- runs through `sessionFilingBucket`, the shared canonical-lifecycle-plus-snooze
- filing helper, whose `needs_you` precedence is
+ runs through `effectiveSessionFilingBuckets`, the relationship-aware wrapper
+ around the shared canonical-lifecycle-plus-snooze `sessionFilingBucket`, whose
+ `needs_you` precedence is
load-bearing: filtering or snoozing must never fold a row that is waiting on
the user into the quiet header.
Renders a bulk action bar at the bottom when sessions are multi-selected
@@ -855,7 +856,7 @@ Renderer surfaces:
never permits a cross-tier move. The funnel also has Status and Tool
multi-select chips (OR within a row), Has PR, and Dirty lane filters (AND
across rows). Their shared pure matcher files status through
- `sessionFilingBucket`; the Has PR result reuses the coalesced PR snapshot
+ `effectiveSessionFilingBuckets` (falling back to `sessionFilingBucket`); the Has PR result reuses the coalesced PR snapshot
that serves lane badges, and a filtered empty state identifies and clears the
active chips.
- `apps/desktop/src/renderer/components/terminals/LaneMachineMarker.tsx` — the
@@ -1210,8 +1211,8 @@ Renderer surfaces:
`focusSession`, and `openSessionTab` so the launch happens silently
without stealing the user's current focus.
It also owns the quiet-tier partitioning and ordering. `snoozedFiltered` and
- `buildWorkTabGroupModel` both file through `sessionFilingBucket`, where
- `"snoozed"` is a
+ `buildWorkTabGroupModel` both file through `effectiveSessionFilingBuckets`
+ (falling back to `sessionFilingBucket`), where `"snoozed"` is a
partition of the grouping rather than a canonical bucket
(`running`, `awaiting-input`, `ended`, `snoozed`, `settled`). Ordering inside
the quiet tails deliberately diverges from the list's default `startedAt`
@@ -1777,7 +1778,8 @@ does not fail on desktop; it surfaces as changeset-apply errors on the phone.
through `isSessionFiledAsSnoozed(session, phase)`, which yields to a
`needs_you` phase, so a snoozed row blocked on the user is never hidden even
under an "Until I'm asked" (~100 year) deadline. The desktop flat list and
- `buildWorkTabGroupModel` consume that rule through `sessionFilingBucket`;
+ `buildWorkTabGroupModel` consume that rule through
+ `effectiveSessionFilingBuckets` (whose base rule is `sessionFilingBucket`);
the `ade code` row marker
(`tuiClient/sessionLifecycle.ts`), and the iOS `workSessionGroups` snoozed
tail all use it; `isSessionSnoozed` stays the raw read for row chrome.
@@ -1912,7 +1914,8 @@ in-memory reset stays separate.
- **Two quiet tiers, not one** — Settled is a lifecycle phase; Snoozed is a
visibility overlay that files a row without changing what its status dot says.
They remain distinct inputs, combined for desktop filing by
- `sessionFilingBucket` (canonical lifecycle plus
+ `effectiveSessionFilingBuckets` (whose base rule is `sessionFilingBucket`,
+ canonical lifecycle plus
`isSessionFiledAsSnoozed`), and rendered as two separate tails; every
surface — desktop, iOS, `ade code`, hosted web, `ade` CLI, CTO tools — has
both. Nothing about session lifecycle is desktop-only.
@@ -2090,16 +2093,18 @@ degrades to "no ADE prompt" rather than a failed launch.
doing and every count, badge, and capsule downstream inherits the lie. Snooze
only changes where a surface *files* the row, through
`isSessionFiledAsSnoozed` (wrapped with canonical lifecycle as
- `sessionFilingBucket` in the desktop renderer). The shared helper yields to a
+ `sessionFilingBucket`, then relationship-aware filing in the desktop
+ renderer). The shared helper yields to a
`needs_you` phase,
which is what makes "Until I'm asked" true for tracked CLI rows at all: their
needs-input state is purely derived, so no early-wake event ever fires for
them and filing is the only thing that can un-hide them.
- **There is no snooze scheduler.** Expiry is derived by comparing
- `snoozed_until` to now, everywhere. The Work hook's timer only nudges a
- re-render; it is not the source of truth, and adding a watchdog that mutates
- rows on expiry would create a second, divergent answer on every surface that
- is not running it. Similarly, only `markLastTurnFailed` applies the
+ `snoozed_until` to now, everywhere. The Work hook, open chat, command palette,
+ and standalone foreign-row pane use bounded render-only timers to nudge a
+ re-render; none is the source of truth or mutates a row. A watchdog that
+ writes on expiry would create a second, divergent answer on every surface
+ that is not running it. Similarly, only `markLastTurnFailed` applies the
strictly-newer-than-`snoozed_at` comparison — drop it and the error the user
snoozed on top of instantly re-wakes the row, making snooze a no-op.
- **TUI-marker needs-you rides on `attentionSource: "provider_structured"`, and
diff --git a/docs/features/terminals-and-sessions/ui-surfaces.md b/docs/features/terminals-and-sessions/ui-surfaces.md
index 822fa2149..445f3d74f 100644
--- a/docs/features/terminals-and-sessions/ui-surfaces.md
+++ b/docs/features/terminals-and-sessions/ui-surfaces.md
@@ -144,7 +144,8 @@ from the project tab's binding; a grouped header owns the glyph so its child
cards do not repeat it. The singleton/header transition participates in the
same layout animation as lane reordering.
-Filing comes from `sessionFilingBucket`, which combines canonical lifecycle
+Filing comes from `effectiveSessionFilingBuckets` (wrapping the base
+`sessionFilingBucket` rule), which combines canonical lifecycle
with the snooze visibility overlay in one shared answer. Snooze still yields to
a `needs_you` phase, so a snoozed row that is blocked on the user stays in its
normal section. Ordering inside the quiet tiers
@@ -168,7 +169,7 @@ layout animation rather than remeasuring on every session tick.
The same funnel also owns the persisted session chips. Status (Your move,
Running, Ended, Settled, Snoozed) and Tool choices are ORed within their own
axis; the Status, Tool, Has PR, and Dirty-lane axes are ANDed together. The
-status chip uses the same `sessionFilingBucket` result as the sidebar, Has PR
+status chip uses the same effective filing result as the sidebar, Has PR
uses the coalesced PR snapshot that powers lane-header badges, and Dirty reads
the already-loaded lane status. Chips apply before all three organization
modes. A remote lane has no local PR snapshot, so Has PR fails closed there;
@@ -206,7 +207,7 @@ lane refresh. ADE never deletes a session or Git data from this recovery row.
Foreign-machine lane rows follow the same filing contract instead of using a
parallel card-only renderer. `partitionQuietSessions` splits each row with
-`sessionFilingBucket`; active cards render normally, while snoozed and settled
+`effectiveSessionFilingBuckets`; active cards render normally, while snoozed and settled
cards use the same collapsed quiet tails as local lanes. A fully quiet foreign
lane starts as the same minimal header with inline counts and uses
`lane-open::` for explicit expansion. When active work
@@ -1213,12 +1214,16 @@ nothing when no delta is available.
- Do not derive anything from the snooze columns except *where a row is filed*.
Snooze is a visibility overlay: `canonicalSessionState()` never reads it, the
status dot never changes, and the counts, badges, and Dock badge stay
- truthful. Use `sessionFilingBucket` for renderer grouping and the raw
+ truthful. Use `effectiveSessionFilingBuckets` for renderer grouping (and
+ `sessionFilingBucket` for the base single-row rule) and the raw
`isSessionSnoozed` for row chrome.
-- Do not schedule anything for snooze expiry. Expiry is derived by comparing
- `snoozedUntil` to now. The Work hook's single timer only bumps a counter to
- force a re-derive; a watchdog that mutated rows on expiry would give a second,
- divergent answer on every surface not running it.
+- Do not schedule lifecycle writes for snooze expiry. Expiry is derived by
+ comparing `snoozedUntil` to now. Render-only deadline timers may bump a
+ counter to repaint an open surface: the Work hook covers the full roster,
+ while an open chat, the command palette, and the standalone foreign-row pane
+ arm their own bounded timer when they need one. None mutates session state, so
+ there is still one source of truth rather than a watchdog that can diverge
+ from surfaces not currently mounted.
- Nothing *derives* a settle. A clean process exit leaves a row `ended`, never
`settled` (`sessionCanonicalState.ts`), so every settled row has either a
`settledAt` or a `"settled"` override and plain Unsettle clears both. The