From 1e57088641bb8fb83a50b498ec612db14656779c Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 30 Jul 2026 19:51:27 -0400 Subject: [PATCH 1/2] ship: refine Work session lifecycle and sidebar --- apps/ade-cli/README.md | 5 - apps/ade-cli/src/adeRpcServer.test.ts | 40 +- apps/ade-cli/src/adeRpcServer.ts | 8 +- apps/ade-cli/src/cli.test.ts | 74 +- apps/ade-cli/src/cli.ts | 164 +- .../push/pushPublisherService.test.ts | 59 +- .../src/services/push/pushPublisherService.ts | 53 +- .../src/tuiClient/__tests__/adeApi.test.ts | 4 +- .../__tests__/sessionLifecycle.test.tsx | 51 +- apps/ade-cli/src/tuiClient/adeApi.ts | 8 +- apps/ade-cli/src/tuiClient/app.tsx | 27 +- apps/ade-cli/src/tuiClient/commands.ts | 2 +- .../ade-cli/src/tuiClient/sessionLifecycle.ts | 33 +- apps/desktop/resources/ade-cli-help.txt | 26 +- .../ade-cli-control-plane/SKILL.md | 44 +- .../main/services/adeActions/registry.test.ts | 100 +- .../src/main/services/adeActions/registry.ts | 87 +- .../services/ai/tools/ctoOperatorTools.ts | 4 +- .../services/ai/tools/systemPrompt.test.ts | 4 +- .../chat/cursorSdkSystemPrompt.test.ts | 5 +- .../src/main/services/lanes/laneService.ts | 28 +- .../main/services/sessions/sessionService.ts | 5 +- .../main/services/usage/usageStatsStore.ts | 4 +- .../usage/usageTrackingService.test.ts | 2 +- apps/desktop/src/preload/preload.test.ts | 2 +- apps/desktop/src/preload/preload.ts | 2 +- .../components/app/CommandPalette.test.tsx | 519 ++++- .../components/app/CommandPalette.tsx | 460 +++-- .../components/app/commandPaletteSearch.tsx | 30 +- .../components/app/commandPaletteThreads.tsx | 460 +++++ .../components/attention/AttentionCenter.css | 32 +- .../components/attention/AttentionCenter.tsx | 6 +- .../attention/HeaderAttentionControl.css | 8 + .../attention/HeaderAttentionControl.test.tsx | 63 + .../attention/attentionHeaderSummary.ts | 32 +- .../attention/attentionPresentation.test.ts | 170 ++ .../attention/attentionPresentation.ts | 167 +- .../components/chat/AgentChatPane.tsx | 11 + .../chat/ChatLifecycleBanner.test.tsx | 188 ++ .../components/chat/ChatLifecycleBanner.tsx | 160 ++ .../components/chat/ChatSurfaceShell.tsx | 4 +- .../components/lanes/LaneContextMenu.test.tsx | 136 ++ .../components/lanes/LaneContextMenu.tsx | 564 +---- .../components/lanes/laneColorPalette.ts | 9 + .../components/lanes/laneContextMenuItems.tsx | 537 +++++ .../components/lanes/laneDesignTokens.ts | 34 + .../components/lanes/laneUtils.test.ts | 20 +- .../terminals/LaneActionsSubmenu.tsx | 92 + .../components/terminals/LanePrBadge.tsx | 53 + .../components/terminals/SessionCard.test.tsx | 1474 ++++++++++--- .../components/terminals/SessionCard.tsx | 1328 +++++++----- .../terminals/SessionContextMenu.test.tsx | 315 ++- .../terminals/SessionContextMenu.tsx | 377 ++-- .../components/terminals/SessionHoverCard.tsx | 345 ++++ .../terminals/SessionInfoPopover.tsx | 73 +- .../terminals/SessionListPane.test.tsx | 1715 ++++++++++++++- .../components/terminals/SessionListPane.tsx | 1830 ++++++++++++----- .../terminals/SessionSnoozeControl.tsx | 94 +- .../terminals/SessionStatusSlot.tsx | 257 +++ .../terminals/TerminalsPage.test.tsx | 64 + .../components/terminals/TerminalsPage.tsx | 80 +- .../terminals/sessionLifecycleActions.ts | 25 + .../terminals/useWorkLaneContextMenu.tsx | 112 +- .../components/terminals/useWorkSessions.ts | 15 +- .../renderer/components/ui/MenuSubmenu.tsx | 305 +++ .../work/WorkSurfaceHeader.test.tsx | 7 + .../components/work/WorkSurfaceHeader.tsx | 5 +- apps/desktop/src/renderer/index.css | 36 +- .../src/renderer/lib/sessionSnooze.test.ts | 191 ++ .../desktop/src/renderer/lib/sessionSnooze.ts | 149 +- .../renderer/lib/terminalAttention.test.ts | 82 +- .../src/renderer/lib/terminalAttention.ts | 133 +- .../webclient/shell/sessionLifecycleChrome.ts | 5 +- .../desktop/src/shared/adeCliGuidance.test.ts | 15 +- apps/desktop/src/shared/adeCliGuidance.ts | 16 +- apps/desktop/src/shared/laneColorPalette.ts | 43 +- .../src/shared/sessionCanonicalState.ts | 21 +- .../src/shared/sessionStatusPresentation.ts | 197 ++ .../Shared/ADEAgentActivityAttributes.swift | 50 +- apps/ios/ADE/Shared/ADESharedModels.swift | 7 +- apps/ios/ADE/Shared/ADESharedTheme.swift | 26 +- .../AttentionDrawerModel.swift | 39 +- .../AttentionDrawerSheet.swift | 19 +- .../ADE/Views/Work/WorkRootComponents.swift | 6 +- .../Work/WorkSessionCanonicalState.swift | 45 +- .../ADE/Views/Work/WorkSessionGrouping.swift | 2 +- apps/ios/ADETests/ADETests.swift | 71 + .../ADETests/AttentionDrawerModelTests.swift | 93 +- .../WorkSessionCanonicalStateTests.swift | 43 + .../ADEWidgets/ADEAgentActivityWidget.swift | 49 +- apps/ios/ADEWidgets/ADELockScreenWidget.swift | 43 +- docs/ARCHITECTURE.md | 4 +- docs/features/ade-code/README.md | 10 +- docs/features/agents/README.md | 26 +- docs/features/cto/README.md | 4 +- docs/features/lanes/README.md | 2 +- .../sync-and-multi-device/ios-companion.md | 14 + .../features/terminals-and-sessions/README.md | 160 +- .../terminals-and-sessions/ui-surfaces.md | 179 +- docs/features/web-client/README.md | 3 +- 100 files changed, 11777 insertions(+), 3023 deletions(-) create mode 100644 apps/desktop/src/renderer/components/app/commandPaletteThreads.tsx create mode 100644 apps/desktop/src/renderer/components/attention/attentionPresentation.test.ts create mode 100644 apps/desktop/src/renderer/components/chat/ChatLifecycleBanner.test.tsx create mode 100644 apps/desktop/src/renderer/components/chat/ChatLifecycleBanner.tsx create mode 100644 apps/desktop/src/renderer/components/lanes/LaneContextMenu.test.tsx create mode 100644 apps/desktop/src/renderer/components/lanes/laneContextMenuItems.tsx create mode 100644 apps/desktop/src/renderer/components/terminals/LaneActionsSubmenu.tsx create mode 100644 apps/desktop/src/renderer/components/terminals/LanePrBadge.tsx create mode 100644 apps/desktop/src/renderer/components/terminals/SessionHoverCard.tsx create mode 100644 apps/desktop/src/renderer/components/terminals/SessionStatusSlot.tsx create mode 100644 apps/desktop/src/renderer/components/ui/MenuSubmenu.tsx create mode 100644 apps/desktop/src/shared/sessionStatusPresentation.ts diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index 597b42f0c..121eb3f3e 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -420,16 +420,11 @@ ade chat message session-id --kind auto --text "status/context" ade chat steer session-id --text "active-turn context" ade chat note "running e2e shard 2/4" # update the caller's Work sidebar status; add --session to target explicitly ade chat ask "Which account should I use?" # escalate a blocking question; add --session to target explicitly -ade chat settle --outcome "opened PR #841, CI green" # settle only when runtime lifecycle checks pass; exits 1 with exact blockers otherwise -ade chat unsettle # return the caller to the active lifecycle; add --session to target explicitly ade session show session-id --text # settle/snooze state, and why a snoozed row came back ade session snooze session-id --for 1h # 30m|1h|4h|1d|1.5h; a bare number means minutes; relative durations cap at 30d ade session snooze session-id --until 2026-07-26T18:00:00Z # explicit ISO-8601 deadline (must be in the future) ade session snooze session-id --until-asked # open-ended, matching the desktop/iOS "Until I'm asked" preset: only a hand-raise brings it back ade session wake session-id --reason manual # timer|needs_you|error|turn_complete|manual -ade session settle session-id --outcome "CI green" # same as `ade chat settle`, but works for CLI/terminal sessions too -ade session settle session-id --keep-active # pin active instead; the only way to hold a clean-exit row out of the quiet tier -ade session unsettle session-id ade session clear-woke session-id # drop the "woke early" marker after visiting the row ade session actions --text # raw session service actions ade chat schedules session-id --pause # pause this agent session's durable wakeups/cron/loops (omit flag to inspect, --resume to re-arm) diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index 486b907f4..b0acb2dcb 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -1555,19 +1555,6 @@ describe("adeRpcServer", () => { "Waiting for release choice", ), }, - { - action: "settleSelfSession", - args: { outcome: "Shipped" }, - assert: () => expect(runtime.sessionService.settleSession).toHaveBeenCalledWith( - "chat-1", - { outcome: "Shipped", source: "agent_explicit" }, - ), - }, - { - action: "unsettleSelfSession", - args: {}, - assert: () => expect(runtime.sessionService.unsettleSession).toHaveBeenCalledWith("chat-1"), - }, ]; for (const lifecycle of lifecycleCalls) { const result = await callTool(handler, "run_ade_action", { @@ -1590,12 +1577,23 @@ describe("adeRpcServer", () => { "Cross-session write", ); - const manualSettleDenied = await callTool(handler, "run_ade_action", { - domain: "session", - action: "settleSession", - args: { sessionId: "chat-1", outcome: "Bypass blockers" }, - }); - expect(manualSettleDenied.isError).toBe(true); + // Settlement is user- and PR-merge-driven only (2026-07). A session-bound + // caller gets no settle writer at all: the caller-scoped `*SelfSession` + // pair was deleted, and every survivor is CTO-only while binding caps the + // caller below cto. + for (const settleAttempt of [ + { action: "settleSession", args: { sessionId: "chat-1", outcome: "Bypass blockers" } }, + { action: "unsettleSession", args: { sessionId: "chat-1" } }, + { action: "settleSelfSession", args: { sessionId: "chat-1", outcome: "Shipped" } }, + { action: "unsettleSelfSession", args: { sessionId: "chat-1" } }, + { action: "settleSessions", args: { sessionIds: ["chat-1"] } }, + { action: "setSettleOverride", args: { sessionId: "chat-1", override: "settled" } }, + ]) { + const denialResult = await callTool(handler, "run_ade_action", settleAttempt); + expect(denialResult.isError).toBe(true); + } + expect(runtime.sessionService.settleSession).not.toHaveBeenCalled(); + expect(runtime.sessionService.unsettleSession).not.toHaveBeenCalled(); }); }); @@ -2236,7 +2234,7 @@ describe("adeRpcServer", () => { expect(finalArg).toContain("clean up processes you start"); expect(finalArg).toContain("ade chat note"); expect(finalArg).toContain("ade chat ask"); - expect(finalArg).toContain("ade chat settle"); + expect(finalArg).toContain("You cannot settle or unsettle a session"); expect(finalArg.endsWith("Implement API wiring")).toBe(true); expect(response.structuredContent.startupCommand).toContain("claude"); expect(response.structuredContent.startupCommand).toContain("--model"); @@ -2268,7 +2266,7 @@ describe("adeRpcServer", () => { const finalArg = createCall.args?.at(-1) ?? ""; expect(finalArg).toContain("ade chat note"); expect(finalArg).toContain("ade chat ask"); - expect(finalArg).toContain("ade chat settle"); + expect(finalArg).toContain("You cannot settle or unsettle a session"); expect(createCall.args).not.toContain("--full-auto"); expect(createCall.startupCommand).toContain("--sandbox workspace-write --ask-for-approval on-request"); expect(createCall.startupCommand).not.toContain("--full-auto"); diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index 240b6bac2..fb3252145 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -2675,8 +2675,12 @@ const SCOPED_CHAT_ACTIONS = new Set([ "setScheduledWorkPaused", "requestSessionAttention", "setSessionStatusNote", - "settleSelfSession", - "unsettleSelfSession", + // `settleSelfSession` / `unsettleSelfSession` used to be scoped here so a + // bound agent could only settle its OWN row. Both actions were removed in + // 2026-07: "is this work finished" is a subjective judgment agents are + // unreliable at, so the surviving settle writers (`session.settleSession`, + // `session.unsettleSession`, the bulk pair, `session.setSettleOverride`) are + // all CTO-only and refuse a session-bound agent outright — no scoping needed. "interrupt", "interruptWithQueueMode", "restoreCancelledQueue", diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index e8d2788a9..dac133398 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -3060,16 +3060,6 @@ describe("ADE CLI", () => { action: "setSessionStatusNote", args: { note: "running e2e shard 2/4" }, }, - { - command: ["settle", "--outcome", "opened PR #841, CI green"], - action: "settleSelfSession", - args: { outcome: "opened PR #841, CI green" }, - }, - { - command: ["unsettle"], - action: "unsettleSelfSession", - args: {}, - }, ]; for (const testCase of cases) { @@ -3083,41 +3073,6 @@ describe("ADE CLI", () => { }, }); } - const settlePlan = expectExecutePlan(buildCliPlan([ - "chat", - "settle", - "--outcome", - "done", - ])); - expect(settlePlan.formatter).toBe("session-settlement"); - expect(settlePlan.exitCodeFromResult?.({ ok: false, blockers: [] })).toBe(1); - expect(settlePlan.exitCodeFromResult?.({ ok: true })).toBe(0); - expect(() => buildCliPlan(["chat", "settle"])) - .toThrow(/outcome is required/i); - expect(() => buildCliPlan(["chat", "settle", "--outcome", ""])) - .toThrow(/outcome is required/i); - - const blockedText = formatOutput({ - ok: false, - sessionId: "session-x", - blockers: [ - { - code: "pending_input", - message: "Resolve the pending input before settling.", - }, - { - code: "scheduled_work_active", - message: "Cancel or complete scheduled work before settling.", - }, - ], - }, { text: true } as any, settlePlan.formatter); - expect(blockedText).toContain("Session session-x was not settled."); - expect(blockedText).toContain( - "- pending_input: Resolve the pending input before settling.", - ); - expect(blockedText).toContain( - "- scheduled_work_active: Cancel or complete scheduled work before settling.", - ); const clearNote = expectExecutePlan(buildCliPlan(["chat", "note", ""])); expect(clearNote.steps[0]?.params).toMatchObject({ @@ -3171,10 +3126,9 @@ describe("ADE CLI", () => { if (help.kind === "help") { expect(help.text).toContain("ade chat note"); expect(help.text).toContain("ade chat ask"); - expect(help.text).toContain("ade chat settle"); - expect(help.text).toContain("ade chat unsettle"); - expect(help.text).toContain("runtime lifecycle checks"); - expect(help.text).toContain("prints exact blockers"); + // Settling is user-/PR-merge-driven only; the help must say so rather + // than advertise a command that no longer exists. + expect(help.text).toContain("'chat settle' / 'chat unsettle' were removed"); expect(help.text).toContain("--session "); } }); @@ -3182,8 +3136,6 @@ describe("ADE CLI", () => { it.each([ ["ask", ["q"], "requestSessionAttention", { message: "q" }], ["note", ["working"], "setSessionStatusNote", { note: "working" }], - ["settle", ["--outcome", "done"], "settleSelfSession", { outcome: "done" }], - ["unsettle", [], "unsettleSelfSession", {}], ])( "passes --session through for chat %s", (subcommand, commandArgs, action, expectedArgs) => { @@ -3319,15 +3271,6 @@ describe("ADE CLI", () => { sessionId: "session-x", reason: "needs_you", }], - [["settle", "session-x", "--outcome", "done"], "settleSelfSession", { - sessionId: "session-x", - outcome: "done", - }], - [["settle", "session-x", "--keep-active"], "setSettleOverride", { - sessionId: "session-x", - override: "active", - }], - [["unsettle", "session-x"], "unsettleSelfSession", { sessionId: "session-x" }], [["clear-woke", "session-x"], "clearWokeMarker", { sessionId: "session-x" }], [["show", "session-x"], "get", { sessionId: "session-x" }], ])("plans ade session %s", (commandArgs, action, expectedArgs) => { @@ -3351,9 +3294,9 @@ describe("ADE CLI", () => { const previous = process.env.ADE_CHAT_SESSION_ID; process.env.ADE_CHAT_SESSION_ID = "session-env"; try { - const envPlan = expectExecutePlan(buildCliPlan(["session", "unsettle"])); + const envPlan = expectExecutePlan(buildCliPlan(["session", "clear-woke"])); expect(envPlan.steps[0]?.params).toMatchObject({ - arguments: { action: "unsettleSelfSession", args: { sessionId: "session-env" } }, + arguments: { action: "clearWokeMarker", args: { sessionId: "session-env" } }, }); } finally { if (previous === undefined) delete process.env.ADE_CHAT_SESSION_ID; @@ -3382,11 +3325,14 @@ describe("ADE CLI", () => { expect(help.text).toContain("ade session snooze --for 1h"); expect(help.text).toContain("ade session wake "); expect(help.text).toContain("--until-asked"); - expect(help.text).toContain("--keep-active"); + // Settle is gone from this family and the help says why. + expect(help.text).not.toContain("ade session settle"); + expect(help.text).toContain("'settle' and 'unsettle' were removed"); } const top = buildCliPlan([]); if (top.kind === "help") { - expect(top.text).toContain("ade session snooze | wake | settle | unsettle"); + expect(top.text).toContain("ade session show | snooze | wake | clear-woke"); + expect(top.text).not.toContain("ade session snooze | wake | settle | unsettle"); } }); }); diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 53d9ccba3..6db9edb9a 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -232,7 +232,6 @@ type FormatterId = | "chat-read" | "session-lifecycle" | "lane-drift" - | "session-settlement" | "scheduled-work-create" | "tests-runs" | "proof-list" @@ -622,10 +621,9 @@ const TOP_LEVEL_HELP = `${ADE_BANNER} $ ade terminal list | resume | read | write | signal Control an attached session terminal $ ade history list | show | commits | export Inspect ADE operation timeline and lane commits - $ ade chat list | create | send | ask | note - settle | unsettle | interrupt + $ ade chat list | create | send | ask | note | interrupt Work with ADE agent chats - $ ade session snooze | wake | settle | unsettle File a session's lifecycle (snooze until a deadline) + $ ade session show | snooze | wake | clear-woke File a session's lifecycle (snooze until a deadline) $ ade linear attach | comment | set-state | issue | graphql Read and write attached Linear issues $ ade github app-auth login | status | clear Authorize the machine ADE GitHub App (device flow) @@ -1669,6 +1667,12 @@ const HELP_BY_COMMAND: Record = { the session id as a positional, accepts --session , and falls back to $ADE_CHAT_SESSION_ID so an agent can file its own session with no id. + Settling is NOT here: 'settle' and 'unsettle' were removed because deciding + that work is finished is the user's call. A row leaves the active list when + the user settles it in ADE or when its PR merges. Snooze is the agent-safe + way to quiet a row you are waiting on — it hides the row without claiming + the work is done, and a hand-raise wakes it early. + $ ade session show --text Print settle/snooze state and the wake reason $ ade session snooze --for 1h Snooze until now + 1h (30m, 1h, 4h, 1d, 1.5h; bare number = minutes) $ ade session snooze --until 2026-07-26T18:00:00Z @@ -1676,9 +1680,6 @@ const HELP_BY_COMMAND: Record = { $ ade session snooze --until-asked Snooze open-ended (the desktop/iOS "Until I'm asked" preset): no clock deadline, so only a hand-raise brings the row back $ ade session wake Clear the snooze now (--reason timer|needs_you|error|turn_complete|manual) - $ ade session settle --outcome "CI green" Mark the session complete - $ ade session settle --keep-active Pin the session active instead (beats the derived clean-exit settle) - $ ade session unsettle Return the session to the active lifecycle $ ade session clear-woke Drop the "woke early" marker after visiting the row $ ade session actions --text List raw session service actions @@ -1711,10 +1712,9 @@ const HELP_BY_COMMAND: Record = { $ ade chat send --text "next step" Send a message; steers automatically if the turn is active $ ade chat note "running e2e shard 2/4" Update this session's Work sidebar status line $ ade chat ask "Which account should I use?" Escalate a blocking question to the user - $ ade chat settle --outcome "PR #841, CI green" Settle only after runtime lifecycle checks pass - Exits 1 and prints exact blockers when rejected - $ ade chat unsettle Return this session to the active lifecycle - These four commands default to the caller and accept --session + 'note' and 'ask' default to the caller and accept --session . + 'chat settle' / 'chat unsettle' were removed: only the user (or a + merged PR) settles a session — report your outcome with 'chat note'. $ ade chat steer --personal --text "focus on the tradeoffs" $ ade chat models --personal --provider codex $ ade chat update --personal --title "Trip planning" @@ -6824,12 +6824,12 @@ function buildTerminalPlan(args: string[]): CliPlan { /** * `ade session …` — the session-lifecycle surface. Unlike `ade chat`, these - * commands are about *filing* a session rather than talking to it: settle it, - * pin it active, or snooze it out of the attention surfaces until a deadline. + * commands are about *filing* a session rather than talking to it: snooze it + * out of the attention surfaces until a deadline, wake it, or read its state. * * Every subcommand takes the session id as a positional, accepts `--session` * as an alias, and falls back to $ADE_CHAT_SESSION_ID so an agent can file its - * own session with no id at all — the same defaulting `ade chat settle` uses. + * own session with no id at all. */ function buildSessionPlan(args: string[]): CliPlan { const sub = firstPositional(args) ?? "show"; @@ -6911,77 +6911,6 @@ function buildSessionPlan(args: string[]): CliPlan { }; } - // `--keep-active` is the explicit keep-active pin (settle_override = - // 'active'). It is the only way to hold a clean-exit row in the active tier, - // because those rows derive their settle and have no settled_at to clear. - const keepActive = readFlag(args, ["--keep-active", "--pin-active"]); - - if (sub === "settle") { - if (keepActive) { - return { - kind: "execute", - label: "session settle --keep-active", - formatter: "session-lifecycle", - steps: [ - actionStep( - "result", - "session", - "setSettleOverride", - collectGenericObjectArgs(args, { sessionId, override: "active" }), - ), - ], - }; - } - const outcome = readValue(args, ["--outcome"]); - return { - kind: "execute", - label: "session settle", - formatter: "session-lifecycle", - steps: [ - actionStep( - "result", - "session", - "settleSelfSession", - collectGenericObjectArgs(args, { - sessionId, - ...(outcome !== null ? { outcome } : {}), - }), - ), - ], - }; - } - - if (sub === "unsettle") { - if (keepActive) { - return { - kind: "execute", - label: "session unsettle --keep-active", - formatter: "session-lifecycle", - steps: [ - actionStep( - "result", - "session", - "setSettleOverride", - collectGenericObjectArgs(args, { sessionId, override: "active" }), - ), - ], - }; - } - return { - kind: "execute", - label: "session unsettle", - formatter: "session-lifecycle", - steps: [ - actionStep( - "result", - "session", - "unsettleSelfSession", - collectGenericObjectArgs(args, { sessionId }), - ), - ], - }; - } - if (sub === "clear-woke" || sub === "clear-wake") { return { kind: "execute", @@ -6999,7 +6928,7 @@ function buildSessionPlan(args: string[]): CliPlan { } throw new CliUsageError( - `Unknown session subcommand '${sub}'. Try: show, snooze, wake, settle, unsettle, clear-woke.`, + `Unknown session subcommand '${sub}'. Try: show, snooze, wake, clear-woke.`, ); } @@ -7035,11 +6964,9 @@ function buildChatPlan(args: string[]): CliPlan { && (args[0] === "list" || args[0] === "create" || args[0] === "cancel") ? firstStandalonePositional(args) : null; - const selfLifecycleSub = - sub === "ask" || - sub === "note" || - sub === "settle" || - sub === "unsettle"; + // `ask` / `note` take free text, not a session positional — they default to + // the caller's own $ADE_CHAT_SESSION_ID and accept --session . + const selfLifecycleSub = sub === "ask" || sub === "note"; const explicitSessionId = readValue(args, ["--session", "--session-id"]); const sessionId = explicitSessionId ?? @@ -7085,43 +7012,6 @@ function buildChatPlan(args: string[]): CliPlan { ], }; } - if (sub === "settle") { - const outcome = requireValue( - readValue(args, ["--outcome"]), - "outcome", - ); - return { - kind: "execute", - label: "chat settle", - formatter: "session-settlement", - steps: [ - actionStep( - "result", - "session", - "settleSelfSession", - withSession({ - outcome, - }), - ), - ], - exitCodeFromResult: (result) => - isRecord(result) && result.ok === false ? 1 : 0, - }; - } - if (sub === "unsettle") { - return { - kind: "execute", - label: "chat unsettle", - steps: [ - actionStep( - "result", - "session", - "unsettleSelfSession", - withSession(), - ), - ], - }; - } if (sub === "list" || sub === "ls") { const includeArchived = readFlag(args, ["--archived", "--include-archived"]); const excludeArchived = readFlag(args, [ @@ -19084,24 +18974,6 @@ function formatTextOutput( return formatSessionLifecycle(value); case "lane-drift": return formatLaneDrift(value); - case "session-settlement": { - if (!isRecord(value)) return JSON.stringify(value, null, 2); - const sessionId = asString(value.sessionId) ?? "unknown"; - if (value.ok === true) { - return `Session ${sessionId} settled.`; - } - const blockers = Array.isArray(value.blockers) - ? value.blockers.filter(isRecord) - : []; - return [ - `Session ${sessionId} was not settled.`, - ...blockers.map((blocker) => { - const code = asString(blocker.code) ?? "blocked"; - const message = asString(blocker.message) ?? "Settlement is blocked."; - return `- ${code}: ${message}`; - }), - ].join("\n"); - } case "scheduled-work-create": { const result = isRecord(value) && isRecord(value.result) ? value.result : value; const item = isRecord(result) && isRecord(result.item) ? result.item : {}; diff --git a/apps/ade-cli/src/services/push/pushPublisherService.test.ts b/apps/ade-cli/src/services/push/pushPublisherService.test.ts index 3d870dabf..7f0b9cf02 100644 --- a/apps/ade-cli/src/services/push/pushPublisherService.test.ts +++ b/apps/ade-cli/src/services/push/pushPublisherService.test.ts @@ -1856,6 +1856,45 @@ describe("createPushPublisherService flush", () => { publisher.dispose(); }); + it("retires a user-stopped CLI run without publishing a failure", async () => { + const { publisher, publish, cliSessions } = makeHarness(); + cliSessions.set("cli-stopped", { + title: "codex in work", + toolType: "codex", + chatSessionId: null, + }); + await publisher.start(); + + publisher.handleCliRuntimeSignal("scope-1", { + laneId: "work", + sessionId: "cli-stopped", + runtimeState: "running", + }); + await vi.advanceTimersByTimeAsync(2_500); + expect(publisher._debug.runs.get("cli-stopped")?.phase).toBe("running"); + + publisher._debug.onPtyExit("scope-1", { + sessionId: "cli-stopped", + ptyId: "pty-stopped", + laneId: "work", + exitCode: 143, + }); + await vi.advanceTimersByTimeAsync(2_500); + + expect(publisher._debug.runs.has("cli-stopped")).toBe(false); + const lastPayload = publish.mock.calls.at(-1)?.[0]; + expect(lastPayload.liveActivity?.[0]?.contentState?.runs ?? []).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: "cli-stopped", phase: "failed" }), + ]), + ); + expect((lastPayload.notifications ?? []).filter( + (item: { title?: string }) => item.title === "CLI session ended", + )).toHaveLength(0); + + publisher.dispose(); + }); + it("publishes explicit CLI attention requests through the gated question path", async () => { const { publisher, publish } = makeHarness(); await publisher.start(); @@ -2589,14 +2628,18 @@ describe("createPushRelayClient", () => { const snapshot = client.getAttentionSnapshot(); await Promise.resolve(); currentAccountUserId = "account-b"; - resolveResponse(Response.json({ - contractVersion: 1, - streamId: "account-a", - revision: 1, - generatedAt: "2026-07-29T00:00:00.000Z", - items: [], - tombstones: [], - })); + resolveResponse({ + ok: true, + status: 200, + json: async () => ({ + contractVersion: 1, + streamId: "account-a", + revision: 1, + generatedAt: "2026-07-29T00:00:00.000Z", + items: [], + tombstones: [], + }), + } as Response); await expect(snapshot).rejects.toThrow(/account changed/i); }); diff --git a/apps/ade-cli/src/services/push/pushPublisherService.ts b/apps/ade-cli/src/services/push/pushPublisherService.ts index 6b4b73582..63719b6c7 100644 --- a/apps/ade-cli/src/services/push/pushPublisherService.ts +++ b/apps/ade-cli/src/services/push/pushPublisherService.ts @@ -14,7 +14,8 @@ import { type AttentionPresence, type AttentionSnapshot, } from "../../../../desktop/src/shared/types/attention"; -import type { PtyExitEvent } from "../../../../desktop/src/shared/types/sessions"; +import type { PtyExitEvent, TerminalSessionStatus } from "../../../../desktop/src/shared/types/sessions"; +import { canonicalSessionState } from "../../../../desktop/src/shared/sessionCanonicalState"; import type { PrNotificationKind } from "../../../../desktop/src/shared/types/prs"; import type { PushDeviceRegistration, @@ -51,6 +52,29 @@ const DETAIL_MAX_CHARS = 160; /** Fixed lock-screen copy for failed runs — never leak error text to the widget. */ const FAILED_DETAIL = "Run failed"; +/** + * Does a tracked CLI exit code describe a user-chosen stop rather than a + * failure? Derived, not re-invented: `ptyService.statusFromExit` maps 130 + * (SIGINT / Ctrl-C) and 143 (SIGTERM / the Stop button) to the `disposed` + * terminal status, and `canonicalSessionState()` projects `disposed` to the + * `stopped` phase — which `sessionStatusPresentation.ts` renders neutral, + * explicitly not an alarm. This function asks the canonical module the same + * question rather than pattern-matching exit codes at the notification layer. + * + * The exit-code → status step is the one bit that must be mirrored: it lives in + * `apps/desktop/src/main/services/pty/ptyService.ts` (`statusFromExit`), which + * is Electron-main code this package cannot import. Keep the two in lockstep. + */ +function isUserStoppedExit(exitCode: number | null | undefined): boolean { + if (exitCode == null) return false; + const status: TerminalSessionStatus = exitCode === 0 + ? "completed" + : exitCode === 130 || exitCode === 143 + ? "disposed" + : "failed"; + return canonicalSessionState({ status, exitCode }).phase === "stopped"; +} + const RUNNING_TTL_MS = 2 * 60 * 60 * 1000; // 2h for running/starting const WAITING_TTL_MS = 24 * 60 * 60 * 1000; // 24h for waiting_for_* const PR_LIVE_ACTIVITY_TTL_MS = 45 * 60 * 1000; // keep recent PR status visible, then age it out @@ -666,7 +690,11 @@ export function createPushPublisherService(deps: PushPublisherDeps) { : phase === "failed" ? `${subject} failed` : phase === "completed" - ? `${subject} finished` + // "Done", not "finished": the shared status vocabulary in + // apps/desktop/src/shared/sessionStatusPresentation.ts labels the + // ready/idle tier "Done" on every surface, and a push title that + // disagrees with the row the user taps through to is a bug. + ? `${subject} is done` : `${subject} is working`, preview, privacyPreview: phase === "needs_you" @@ -674,7 +702,7 @@ export function createPushPublisherService(deps: PushPublisherDeps) { : phase === "failed" ? "An ADE agent run failed." : phase === "completed" - ? "An ADE agent finished." + ? "An ADE agent is done." : "An ADE agent is working.", detail: run.detail ? sanitizeAttentionPreview(run.detail, 1_000) : null, recentActivity: run.detail ? [sanitizeAttentionPreview(run.detail)] : [], @@ -1045,7 +1073,9 @@ export function createPushPublisherService(deps: PushPublisherDeps) { const mostRecentPr = allPrActivities.sort((left, right) => right.updatedAt - left.updatedAt)[0]; const startAlert = { title: activeCount > 0 - ? (activeCount === 1 && mostRecent ? `${runSubject(mostRecent)} is running` : `${activeCount} agent runs active`) + // "is working" matches the attention-item title built above and the + // shared `running` label in sessionStatusPresentation.ts. + ? (activeCount === 1 && mostRecent ? `${runSubject(mostRecent)} is working` : `${activeCount} agent runs active`) : (prActivityCount === 1 && mostRecentPr ? `PR #${mostRecentPr.prNumber} updated` : `${prActivityCount} pull requests updated`), body: activeCount > 0 ? (mostRecent ? laneTitleLine(mostRecent) : null) : mostRecentPr?.title ?? null, }; @@ -1633,6 +1663,15 @@ export function createPushPublisherService(deps: PushPublisherDeps) { }; const onPtyExit = (scopeKey: string, event: PtyExitEvent & { laneId: string }): void => { + // A user-chosen stop is neither a successful completion nor a failure. + // The Live Activity wire format has no stopped phase, so retire the row + // instead of lying with either terminal state. + if (isUserStoppedExit(event.exitCode)) { + runs.delete(event.sessionId); + recentRuns.delete(event.sessionId); + scheduleFlush(false); + return; + } // A tracked CLI run in the aggregate reaches its terminal phase here — the // runtime signal stream never reports exit codes. const run = runs.get(event.sessionId); @@ -1642,7 +1681,11 @@ export function createPushPublisherService(deps: PushPublisherDeps) { recentRuns.set(run.sessionId, { ...run }); scheduleFlush(false); } - // Keep it simple: only surface non-clean exits of tracked CLI sessions. + // Only surface non-clean exits of tracked CLI sessions — and only ones the + // user did not cause. A SIGINT/SIGTERM exit is `stopped`, not `failed`: the + // user pressed Ctrl-C or Stop, so they already know, and pushing an alert + // about an outcome they chose is exactly what teaches people to ignore + // alerts. Genuine non-zero failures still notify. if (event.exitCode == null || event.exitCode === 0) return; const resolveLaneName = scopes.get(scopeKey)?.resolveLaneName; const lane = resolveLaneName?.(event.laneId) ?? event.laneId ?? null; diff --git a/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts b/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts index 7600d5a0b..688117212 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts @@ -175,7 +175,9 @@ describe("session lifecycle parity", () => { outcome: "PR merged", dismissPendingInput: true, }], - ["session", "unsettleSelfSession", { sessionId: "session-1" }], + // The `*SelfSession` (agent self-settle) pair is gone; `ade code` is a + // user surface and uses the cto-gated actions. + ["session", "unsettleSession", { sessionId: "session-1" }], ]); }); diff --git a/apps/ade-cli/src/tuiClient/__tests__/sessionLifecycle.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/sessionLifecycle.test.tsx index 2b049c54b..8a25e3a88 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/sessionLifecycle.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/sessionLifecycle.test.tsx @@ -7,12 +7,12 @@ import { Drawer } from "../components/Drawer"; import { BUILTIN_COMMANDS, paletteCommands, parseCommand } from "../commands"; import type { TuiChatSessionSummary } from "../adeApi"; import { - SNOOZE_CHOICES, clearWokeMarkerOnVisit, isSessionFiledAsSnoozed, isSessionSnoozed, resolveSessionTarget, resolveSnoozeChoice, + resolveSnoozeChoices, resolveSnoozeFreeText, sessionLifecycleCommandFor, sessionLifecycleMarker, @@ -197,19 +197,54 @@ describe("per-session targeting", () => { }); describe("duration entry", () => { - it("offers the shared four options in the shared order", () => { - expect(SNOOZE_CHOICES.map((choice) => choice.label)).toEqual([ - "1 hour", - "Until this evening", - "Until tomorrow 9am", + /** Local-time anchor, so the time-of-day presets are deterministic. */ + function localMs(y: number, m: number, d: number, hh: number, mm = 0): number { + return new Date(y, m - 1, d, hh, mm, 0, 0).getTime(); + } + + it("offers the shared presets in the shared order, open-ended last", () => { + expect(resolveSnoozeChoices(localMs(2026, 4, 8, 10)).map((choice) => choice.label)).toEqual([ + "In 1 hour", + "This evening", + "Tomorrow", + "Next week", "Until I'm asked", ]); }); + it("carries the desktop's time column so the palette rows read the same", () => { + const choices = resolveSnoozeChoices(localMs(2026, 4, 8, 10)); + expect(choices.find((choice) => choice.key === "tomorrow")!.whenLabel).toMatch(/9/); + expect(choices.find((choice) => choice.key === "next-week")!.whenLabel).toMatch(/Mon/); + expect(choices.find((choice) => choice.key === "asked")!.whenLabel).toBe("on a hand-raise"); + }); + + it("re-evaluates evening suppression against NOW, not against import time", () => { + // The regression this guards: `SNOOZE_CHOICES` used to be a module-level + // const derived once at import. An `ade code` session launched at 2pm would + // still offer "This evening" at 11pm — pointing at a deadline that has + // already passed — for as long as the process lived. + expect(resolveSnoozeChoices(localMs(2026, 4, 8, 14)).map((choice) => choice.key)) + .toContain("evening"); + expect(resolveSnoozeChoices(localMs(2026, 4, 8, 23)).map((choice) => choice.key)) + .not.toContain("evening"); + // …and the open-ended row survives the reshuffle, still last. + const lateChoices = resolveSnoozeChoices(localMs(2026, 4, 8, 23)); + expect(lateChoices[lateChoices.length - 1]!.key).toBe("asked"); + }); + + it("resolves the next-week choice to the coming Monday morning", () => { + const resolved = resolveSnoozeChoice("next-week", localMs(2026, 4, 8, 10)); + expect(resolved.ok).toBe(true); + if (!resolved.ok) throw new Error(`Expected next-week to resolve, got ${resolved.message}`); + expect(Date.parse(resolved.untilIso)).toBe(localMs(2026, 4, 13, 9)); + expect(resolved.confirmation).toBe("Snoozed until next Monday."); + }); + it("resolves a menu choice to a concrete future deadline", () => { const resolved = resolveSnoozeChoice("hour", NOW); expect(resolved.ok).toBe(true); - if (!resolved.ok) return; + if (!resolved.ok) throw new Error(`Expected hour to resolve, got ${resolved.message}`); expect(Date.parse(resolved.untilIso)).toBe(NOW + 60 * 60_000); expect(resolved.confirmation).toBe("Snoozed for 1 hour."); }); @@ -217,7 +252,7 @@ describe("duration entry", () => { it("accepts free text through the shared parser and reuses the shared wake copy", () => { const resolved = resolveSnoozeFreeText("3h", NOW); expect(resolved.ok).toBe(true); - if (!resolved.ok) return; + if (!resolved.ok) throw new Error(`Expected free text to resolve, got ${resolved.message}`); expect(Date.parse(resolved.untilIso)).toBe(NOW + 3 * 60 * 60_000); expect(resolved.confirmation).toBe("Snoozed · wakes in 3h."); }); diff --git a/apps/ade-cli/src/tuiClient/adeApi.ts b/apps/ade-cli/src/tuiClient/adeApi.ts index 22edd5487..8e27067ea 100644 --- a/apps/ade-cli/src/tuiClient/adeApi.ts +++ b/apps/ade-cli/src/tuiClient/adeApi.ts @@ -282,11 +282,17 @@ export async function settleSession( }); } +/** + * `ade code` is a USER surface (it connects at cto role), which is why it may + * still settle and unsettle rows: the agent-facing `settleSelfSession` / + * `unsettleSelfSession` pair was removed in 2026-07, and both survivors here + * are cto-gated in the ADE action registry. + */ export async function unsettleSession( connection: AdeCodeConnection, sessionId: string, ): Promise { - await connection.action("session", "unsettleSelfSession", { sessionId }); + await connection.action("session", "unsettleSession", { sessionId }); } /** diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index b6be16cc5..200fc89d4 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -139,9 +139,9 @@ import { aggregateChatBlocks, derivePendingSteers, type AggregatedBlock } from " import { deriveChatInfoSnapshot } from "./chatInfo"; import { BUILTIN_COMMANDS, paletteCommands, parseCommand } from "./commands"; import { - SNOOZE_CHOICES, resolveSessionTarget, resolveSnoozeChoice, + resolveSnoozeChoices, resolveSnoozeFreeText, clearWokeMarkerOnVisit as clearSessionWokeMarkerOnVisit, sessionLifecycleCommandFor, @@ -9395,22 +9395,27 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, /** * Duration entry for `/session snooze` with no duration given. Uses the same * right-pane list + arrow/enter selection every other TUI chooser uses - * (`/switch`, `/secrets`, `/chats`), with the shared four options in the - * shared order; free text stays available by typing the duration on the - * command line instead. + * (`/switch`, `/secrets`, `/chats`), with the shared presets in the shared + * order; free text stays available by typing the duration on the command line + * instead. + * + * Resolved on every open, never hoisted: the shared resolver suppresses + * time-of-day presets that have gone stale, and a long-lived `ade code` + * session must see that decision made against NOW, not against launch time. */ const openSnoozeDurationPalette = useCallback((sessionId: string, sessionLabel: string): void => { pendingSnoozeSessionIdRef.current = sessionId; setRightSelectionIndex(0); + const choices = resolveSnoozeChoices(); setRightPane({ kind: "list", title: `Snooze · ${sessionLabel}`, rows: [ - ...SNOOZE_CHOICES.map((choice) => choice.label), + ...choices.map((choice) => `${choice.label} · ${choice.whenLabel}`), "", "Free text: /session snooze [id] 45m · 1.5h · 2d", ], - action: { kind: "snooze-duration", ids: [...SNOOZE_CHOICES.map((choice) => choice.key), "", ""] }, + action: { kind: "snooze-duration", ids: [...choices.map((choice) => choice.key), "", ""] }, }); }, []); @@ -10840,7 +10845,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, " /session wake [id] bring a snoozed row back now", " /session settle [id] [outcome] file the row as done", " /session unsettle [id] undo a settle", - " /session keep-active [id] pin the row active so it never settles on its own", + " /session keep-active [id] pin the row active against a later settle", "", "Run /session snooze with no duration to pick one from the list.", ].join("\n"), @@ -10900,9 +10905,11 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, await unsettleSession(conn, target.sessionId); addNotice(`Removed the session's settled state.${scope}`, "success"); } else { - // keep-active: the tri-state override's "active" pin. It is the only - // way to hold a clean-exit row out of the quiet tier, because those - // rows derive their settle and have no settledAt to clear. + // keep-active: the tri-state override's "active" pin. It suppresses + // the settled tier for a row even if something later writes + // settled_at (e.g. the PR-merge policy), so the user can hold a row + // in the active list. Nothing DERIVES a settle — a clean process + // exit is "ended", never "settled" (see sessionCanonicalState.ts). await setSessionSettleOverride(conn, target.sessionId, "active"); addNotice(`Pinned the session active.${scope}`, "success"); } diff --git a/apps/ade-cli/src/tuiClient/commands.ts b/apps/ade-cli/src/tuiClient/commands.ts index a83168958..b697c3559 100644 --- a/apps/ade-cli/src/tuiClient/commands.ts +++ b/apps/ade-cli/src/tuiClient/commands.ts @@ -74,7 +74,7 @@ export const BUILTIN_COMMANDS: BuiltinCommand[] = [ { name: "/session wake", description: "Wake a snoozed session back into the attention list", placement: "right", argumentHint: "[session-id]", category: "Chats" }, { name: "/session settle", description: "Mark a session settled", placement: "right", argumentHint: "[session-id] [outcome]", category: "Chats" }, { name: "/session unsettle", description: "Remove a session's settled state", placement: "right", argumentHint: "[session-id]", category: "Chats" }, - { name: "/session keep-active", description: "Pin a session active so it never settles on its own", placement: "right", argumentHint: "[session-id]", category: "Chats" }, + { name: "/session keep-active", description: "Pin a session active against a later settle", placement: "right", argumentHint: "[session-id]", category: "Chats" }, { name: "/tag", description: "Tag the active Claude chat", placement: "right", argumentHint: "", providers: ["claude"], category: "Model" }, { name: "/output-style", description: "List or select the active Claude output style", placement: "right", argumentHint: "[style]", providers: ["claude"], category: "Model" }, { name: "/plugin", description: "List, reload, or manage Claude plugins", placement: "right", argumentHint: "[reload|native args]", providers: ["claude"], category: "Model" }, diff --git a/apps/ade-cli/src/tuiClient/sessionLifecycle.ts b/apps/ade-cli/src/tuiClient/sessionLifecycle.ts index 9d58e6387..1f4c59171 100644 --- a/apps/ade-cli/src/tuiClient/sessionLifecycle.ts +++ b/apps/ade-cli/src/tuiClient/sessionLifecycle.ts @@ -1,5 +1,5 @@ import { - SNOOZE_DURATION_OPTIONS, + resolveSnoozePresets, sessionWokeMarker, snoozeConfirmationLabel, snoozeDeadlineIso, @@ -133,14 +133,31 @@ export function resolveSessionTarget(args: { // Duration choices // --------------------------------------------------------------------------- -export type SnoozeChoice = { key: SnoozeDurationKey; label: string }; +export type SnoozeChoice = { + key: SnoozeDurationKey; + label: string; + /** The palette's time column ("9:00 AM", "Mon 9:00 AM", "on a hand-raise"). */ + whenLabel: string; +}; -/** The palette rows, in the shared fixed order (shortest window first, - * open-ended last). Free-text durations bypass this list entirely. */ -export const SNOOZE_CHOICES: readonly SnoozeChoice[] = SNOOZE_DURATION_OPTIONS.map((option) => ({ - key: option.key, - label: option.label, -})); +/** + * The palette rows, in the shared order (shortest window first, open-ended + * last). Free-text durations bypass this list entirely. + * + * A FUNCTION, not a module-level constant, and that is the whole point: the + * shared resolver drops "This evening" once 6pm is within an hour or already + * past, and a list derived once at import time would freeze that decision at + * process start. `ade code` sessions run for hours — one opened at 2pm would + * still be offering "This evening" at 11pm, pointing at a deadline in the past, + * which is exactly the bug the suppression exists to prevent. + */ +export function resolveSnoozeChoices(nowMs: number = Date.now()): readonly SnoozeChoice[] { + return resolveSnoozePresets(nowMs).map((preset) => ({ + key: preset.key, + label: preset.label, + whenLabel: preset.whenLabel, + })); +} export type SnoozeResolution = | { ok: true; untilIso: string; confirmation: string } diff --git a/apps/desktop/resources/ade-cli-help.txt b/apps/desktop/resources/ade-cli-help.txt index 1e94ba6c5..a0a255179 100644 --- a/apps/desktop/resources/ade-cli-help.txt +++ b/apps/desktop/resources/ade-cli-help.txt @@ -48,9 +48,9 @@ _ ____ _____ $ ade terminal list | resume | read | write | signal Control an attached session terminal $ ade history list | show | commits | export Inspect ADE operation timeline and lane commits - $ ade chat list | create | send | ask | note - settle | unsettle | interrupt + $ ade chat list | create | send | ask | note | interrupt Work with ADE agent chats + $ ade session show | snooze | wake | clear-woke File a session's lifecycle (snooze until a deadline) $ ade linear attach | comment | set-state | issue | graphql Read and write attached Linear issues $ ade github app-auth login | status | clear Authorize the machine ADE GitHub App (device flow) @@ -185,9 +185,9 @@ _ ____ _____ $ ade terminal list | resume | read | write | signal Control an attached session terminal $ ade history list | show | commits | export Inspect ADE operation timeline and lane commits - $ ade chat list | create | send | ask | note - settle | unsettle | interrupt + $ ade chat list | create | send | ask | note | interrupt Work with ADE agent chats + $ ade session show | snooze | wake | clear-woke File a session's lifecycle (snooze until a deadline) $ ade linear attach | comment | set-state | issue | graphql Read and write attached Linear issues $ ade github app-auth login | status | clear Authorize the machine ADE GitHub App (device flow) @@ -585,9 +585,9 @@ _ ____ _____ $ ade chat send --text "next step" Send a message; steers automatically if the turn is active $ ade chat note "running e2e shard 2/4" Update this session's Work sidebar status line $ ade chat ask "Which account should I use?" Escalate a blocking question to the user - $ ade chat settle --outcome "PR #841, CI green" Mark this session complete - $ ade chat unsettle Return this session to the active lifecycle - These four commands default to the caller and accept --session + 'note' and 'ask' default to the caller and accept --session . + 'chat settle' / 'chat unsettle' were removed: only the user (or a + merged PR) settles a session — report your outcome with 'chat note'. $ ade chat steer --personal --text "focus on the tradeoffs" $ ade chat models --personal --provider codex $ ade chat update --personal --title "Trip planning" @@ -744,9 +744,9 @@ _ ____ _____ $ ade terminal list | resume | read | write | signal Control an attached session terminal $ ade history list | show | commits | export Inspect ADE operation timeline and lane commits - $ ade chat list | create | send | ask | note - settle | unsettle | interrupt + $ ade chat list | create | send | ask | note | interrupt Work with ADE agent chats + $ ade session show | snooze | wake | clear-woke File a session's lifecycle (snooze until a deadline) $ ade linear attach | comment | set-state | issue | graphql Read and write attached Linear issues $ ade github app-auth login | status | clear Authorize the machine ADE GitHub App (device flow) @@ -944,9 +944,9 @@ _ ____ _____ $ ade terminal list | resume | read | write | signal Control an attached session terminal $ ade history list | show | commits | export Inspect ADE operation timeline and lane commits - $ ade chat list | create | send | ask | note - settle | unsettle | interrupt + $ ade chat list | create | send | ask | note | interrupt Work with ADE agent chats + $ ade session show | snooze | wake | clear-woke File a session's lifecycle (snooze until a deadline) $ ade linear attach | comment | set-state | issue | graphql Read and write attached Linear issues $ ade github app-auth login | status | clear Authorize the machine ADE GitHub App (device flow) @@ -1359,9 +1359,9 @@ _ ____ _____ $ ade terminal list | resume | read | write | signal Control an attached session terminal $ ade history list | show | commits | export Inspect ADE operation timeline and lane commits - $ ade chat list | create | send | ask | note - settle | unsettle | interrupt + $ ade chat list | create | send | ask | note | interrupt Work with ADE agent chats + $ ade session show | snooze | wake | clear-woke File a session's lifecycle (snooze until a deadline) $ ade linear attach | comment | set-state | issue | graphql Read and write attached Linear issues $ ade github app-auth login | status | clear Authorize the machine ADE GitHub App (device flow) diff --git a/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md b/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md index 40ace2caa..048b8702f 100644 --- a/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md +++ b/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md @@ -172,21 +172,30 @@ background a raw CLI and then guess at its state: the full command line and exclude yourself (`pgrep -f "codex exec" | grep -v $$`), never the bare program name. -### Session lifecycle: settle, snooze, wake +### Session lifecycle: snooze, wake (settling is not yours) -The work-session lifecycle is reachable from every surface, including yours. The -typed family takes the session id as a positional, also accepts `--session`, and -falls back to `ADE_CHAT_SESSION_ID` when you omit it — so you can drive your own -session or another one. +**You cannot settle a session.** `ade chat settle`, `ade chat unsettle`, +`ade session settle`, and `ade session unsettle` were removed: whether work is +actually finished is a subjective judgment, and a chat that settles itself +disappears from the user's active list on your say-so. A row leaves the active +list only when the user settles it in ADE, or when its PR merges and the +deterministic `autoSettleLaneSessionsOnPrMerge` policy files the lane's +sessions. Running a removed command fails with that explanation. + +What to do instead when you finish: say so in your final message, and use +`ade chat note ""` to leave a durable status line on the Work +row. If you are blocked, `ade chat ask ""` raises the row's hand. + +Snooze is the lifecycle verb you *do* own. The typed family takes the session id +as a positional, also accepts `--session`, and falls back to +`ADE_CHAT_SESSION_ID` when you omit it. ```bash ade session show --text # lifecycle state incl. wake reason ade session snooze --for 1h # also 30m, 4h, 1d (cap 30d) ade session snooze --until # mutually exclusive with --for +ade session snooze --until-asked # no deadline; only a hand-raise returns it ade session wake [--reason ] -ade session settle [--outcome "..."] -ade session settle --keep-active # pin: beats a derived clean-exit settle -ade session unsettle ade session clear-woke ``` @@ -195,18 +204,21 @@ Semantics that hold on every surface: - **Snooze is a visibility overlay, not a lifecycle state.** It never changes a session's canonical phase; it only files the row in a quiet tier. Timer expiry is derived by comparing `snoozedUntil` to now — nothing schedules a wakeup. + This is why snooze is safe for you and settling is not: snooze quiets a row + you are waiting on without claiming the work is done. - **A snoozed session hand-raises early** when it needs approval or input, when it hits an error *newer than* the snooze, or when a running turn completes. The row then carries a woke marker plus the reason (`needs approval`, `errored`, `turn finished`, `snooze ended`). -- **Settle override is tri-state** (`settled` / `active` / cleared). `--keep-active` - sets the `active` pin, which is how you un-settle a session that settled itself - on a clean exit and therefore has no settle timestamp to clear. - -Generic action-domain equivalents: `session.snoozeSession`, `session.snoozeSessions`, -`session.wakeSession`, `session.wakeSessions`, `session.setSettleOverride`, -`session.clearWokeMarker`, alongside the existing `session.settleSessions` / -`session.unsettleSessions`. +- **Nothing derives a settle.** A clean process exit means the CLI ended, not + that the work is done — such a row is `ended`, never `settled`. + +Generic action-domain equivalents you can call: `session.snoozeSession`, +`session.snoozeSessions`, `session.wakeSession`, `session.wakeSessions`, +`session.clearWokeMarker`. The settle writers (`session.settleSession`, +`session.unsettleSession`, `session.settleSessions`, +`session.unsettleSessions`, `session.setSettleOverride`) are CTO-only and will +refuse your calls. ### Lane branch drift diff --git a/apps/desktop/src/main/services/adeActions/registry.test.ts b/apps/desktop/src/main/services/adeActions/registry.test.ts index c887753a1..8cedca477 100644 --- a/apps/desktop/src/main/services/adeActions/registry.test.ts +++ b/apps/desktop/src/main/services/adeActions/registry.test.ts @@ -75,18 +75,36 @@ describe("isAllowedAdeAction", () => { expect(isAllowedAdeAction("session", "requestSessionAttention")).toBe(true); expect(isAllowedAdeAction("session", "setSessionStatusNote")).toBe(true); expect(isAllowedAdeAction("session", "settleSession")).toBe(true); - expect(isAllowedAdeAction("session", "settleSelfSession")).toBe(true); - expect(isAllowedAdeAction("session", "unsettleSelfSession")).toBe(true); + expect(isAllowedAdeAction("session", "unsettleSession")).toBe(true); expect(isCtoOnlyAdeAction("session", "settleSession")).toBe(true); + expect(isCtoOnlyAdeAction("session", "unsettleSession")).toBe(true); expect(isCtoOnlyAdeAction("session", "settleSessions")).toBe(true); expect(isCtoOnlyAdeAction("session", "unsettleSessions")).toBe(true); expect(isCtoOnlyAdeAction("session", "updateLifecycleSettings")).toBe(true); - expect(isCtoOnlyAdeAction("session", "settleSelfSession")).toBe(false); - expect(isAutomationAllowedAdeAction("session", "settleSelfSession")).toBe(true); expect(isAutomationAllowedAdeAction("session", "settleSession")).toBe(false); expect(isAutomationAllowedAdeAction("session", "updateLifecycleSettings")).toBe(false); }); + // Regression guard for the 2026-07 removal of agent self-settlement: "is this + // work done" is a subjective call agents are unreliable at, so no settle + // writer may be reachable by a session-bound agent (role `agent`) or an + // automation. Only cto-role human surfaces and the deterministic PR-merge + // policy settle rows. + it("gives agents no way to settle or unsettle a session", () => { + expect(isAllowedAdeAction("session", "settleSelfSession")).toBe(false); + expect(isAllowedAdeAction("session", "unsettleSelfSession")).toBe(false); + for (const action of [ + "settleSession", + "unsettleSession", + "settleSessions", + "unsettleSessions", + "setSettleOverride", + ]) { + expect(isCtoOnlyAdeAction("session", action)).toBe(true); + expect(isAutomationAllowedAdeAction("session", action)).toBe(false); + } + }); + it("exposes snooze/wake/settle-override and lane branch drift to generic actions", () => { expect(isAllowedAdeAction("session", "snoozeSession")).toBe(true); expect(isAllowedAdeAction("session", "snoozeSessions")).toBe(true); @@ -1447,7 +1465,7 @@ describe("runtime session actions", () => { expect(runtime.sessionDeltaService?.getSessionDelta).toHaveBeenCalledWith("session-1"); }); - it("enforces caller settlement blockers without requiring agentChatService", async () => { + it("exposes caller note/ask writes but no caller-scoped settle action", async () => { const requestAttention = vi.fn(() => true); const setStatusNote = vi.fn(() => true); const settleSession = vi.fn(() => true); @@ -1484,22 +1502,20 @@ describe("runtime session actions", () => { const sessionActions = getAdeActionDomainServices(runtime).session as { requestSessionAttention: (args: { sessionId: string; message: string }) => unknown; setSessionStatusNote: (args: { sessionId: string; note: string }) => unknown; - settleSelfSession: (args: { - sessionId: string; - outcome?: string; - dismissPendingInput?: boolean; - }) => unknown; - unsettleSelfSession: (args: { sessionId: string }) => unknown; + unsettleSession: (args: { sessionId: string }) => unknown; } & Record; - expect(listAllowedAdeActionNames("session", sessionActions)).toEqual( + const allowed = listAllowedAdeActionNames("session", sessionActions); + expect(allowed).toEqual( expect.arrayContaining([ "requestSessionAttention", "setSessionStatusNote", - "settleSelfSession", - "unsettleSelfSession", ]), ); + // The caller-scoped settle pair is gone for good — an agent declares its + // outcome in prose, it does not file its own row into the quiet tier. + expect(allowed).not.toContain("settleSelfSession"); + expect(allowed).not.toContain("unsettleSelfSession"); expect(sessionActions.requestSessionAttention({ sessionId: "session-1", message: "Which account should I use?", @@ -1519,23 +1535,13 @@ describe("runtime session actions", () => { .toEqual({ ok: true, sessionId: "session-1" }); expect(setStatusNote).toHaveBeenCalledWith("session-1", null); - await expect(sessionActions.settleSelfSession({ - sessionId: "session-1", - outcome: "PR #841 merged", - dismissPendingInput: true, - })) - .resolves.toEqual({ - ok: false, - sessionId: "session-1", - blockers: [{ - code: "pending_input", - message: "Resolve the pending input before settling.", - }], - }); - expect(setSessionRuntimeState).not.toHaveBeenLastCalledWith("session-1", "idle"); + expect(sessionActions.settleSelfSession).toBeUndefined(); + expect(sessionActions.unsettleSelfSession).toBeUndefined(); expect(settleSession).not.toHaveBeenCalled(); - expect(sessionActions.unsettleSelfSession({ sessionId: "session-1" })) + // The user-driven single-row unsettle (desktop row menu on a remote-bound + // project, `ade code`'s /session unsettle) survives under a cto-gated name. + expect(sessionActions.unsettleSession({ sessionId: "session-1" })) .toEqual({ ok: true, sessionId: "session-1" }); expect(unsettleSession).toHaveBeenCalledWith("session-1"); }); @@ -1717,42 +1723,6 @@ describe("runtime session actions", () => { ); }); - it("returns structured runtime blockers instead of settling an unfinished agent session", async () => { - const blockers = [{ - code: "unfinished_goal" as const, - message: "Mark the active Codex goal complete or clear it before settling.", - }]; - const getSettlementBlockers = vi.fn(async () => blockers); - const settleSession = vi.fn(() => true); - const runtime = { - sessionService: { - get: vi.fn(() => ({ id: "chat-1", toolType: "codex-chat" })), - list: vi.fn(), - settleSession, - }, - agentChatService: { - getSettlementBlockers, - }, - } as unknown as Parameters[0]; - const sessionActions = getAdeActionDomainServices(runtime).session as { - settleSelfSession: (args: { - sessionId: string; - outcome: string; - }) => Promise; - }; - - await expect(sessionActions.settleSelfSession({ - sessionId: "chat-1", - outcome: "Done", - })).resolves.toEqual({ - ok: false, - sessionId: "chat-1", - blockers, - }); - expect(getSettlementBlockers).toHaveBeenCalledWith("chat-1"); - expect(settleSession).not.toHaveBeenCalled(); - }); - it("does not pretend a native CLI prompt was dismissed while its process is still blocked", async () => { const settleSession = vi.fn(() => true); const setSessionRuntimeState = vi.fn(() => true); diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index c30c9cd67..647948915 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -25,7 +25,6 @@ import type { AutomationSaveDraftRequest, AutomationSaveDraftResult, } from "../../../shared/types/automations"; -import type { AgentSessionSettlementResult } from "../../../shared/types/sessions"; import type { AttentionPreferences, AttentionPresence, @@ -232,7 +231,22 @@ export const ADE_ACTION_CTO_ONLY: Partial { - const record = readObjectActionArg(args, "session.settleSelfSession"); - const sessionId = requireNonEmptyString(record.sessionId, "sessionId"); - const outcome = typeof record.outcome === "string" ? record.outcome.trim() : ""; - if (!outcome) { - throw new Error("Agent settlement requires a concrete non-empty outcome."); - } - const session = sessionService.get(sessionId); - if (!session) throw new Error(`Session '${sessionId}' was not found.`); - const blockers = runtime.agentChatService - ? await runtime.agentChatService.getSettlementBlockers(sessionId) - : [ - ...(session.attentionRequestedAt || session.pendingInputItemId - ? [{ code: "pending_input", message: "Resolve the pending input before settling." } as const] - : []), - ...(session.lastTurnFailedAt - ? [{ code: "turn_failed", message: "The latest turn failed; complete or recover it before settling." } as const] - : []), - ]; - if (blockers.length > 0) { - return { - ok: false, - sessionId, - blockers, - } satisfies AgentSessionSettlementResult; - } - if (!await settleTerminalSession({ - sessionId, - opts: { outcome, source: "agent_explicit" }, - sessionService, - agentChatService: runtime.agentChatService, - ptyService: runtime.ptyService, - })) { - throw new Error(`Session '${sessionId}' was not found.`); - } - return { ok: true, sessionId } satisfies AgentSessionSettlementResult; - }, + // ----------------------------------------------------------------------- + // There is deliberately NO `settleSelfSession` / `unsettleSelfSession` + // pair here any more (removed 2026-07). An agent used to be able to file + // its own Work row into the quiet tier via `ade chat settle` — but whether + // work is genuinely finished is a subjective judgment, and agents are + // unreliable at making it. A chat that settles itself vanishes from the + // user's active list on the agent's say-so, which is exactly the wrong + // default. Settlement now has only two writers: + // 1. a human, through the desktop rows/bulk actions or the `ade code` + // TUI (both connect at cto role, hence the CTO-only gate above), and + // 2. the deterministic PR-merge policy in + // services/prs/prMergeAutoSettlementService.ts, which calls + // sessionService directly and never touches this bridge. + // If you are about to re-add a caller-scoped settle action, don't: the + // product decision is that agents declare outcomes in prose, not by + // mutating lifecycle columns. + // ----------------------------------------------------------------------- settleSession: async (args?: unknown) => { const record = readObjectActionArg(args, "session.settleSession"); const sessionId = requireNonEmptyString(record.sessionId, "sessionId"); @@ -1998,8 +1991,13 @@ function buildSessionDomainService(runtime: AdeRuntime): OpaqueService | null { } return { ok: true, sessionId }; }, - unsettleSelfSession: (args?: unknown) => { - const record = readObjectActionArg(args, "session.unsettleSelfSession"); + // Single-row unsettle for the same user-driven surfaces as `settleSession` + // (desktop row menu on a remote-bound project, `ade code`'s + // `/session unsettle`). It is the undo for a user's own settle, so it is + // gated identically — an agent lifting a settle the user deliberately took + // is the same category of problem as an agent taking one. + unsettleSession: (args?: unknown) => { + const record = readObjectActionArg(args, "session.unsettleSession"); const sessionId = requireNonEmptyString(record.sessionId, "sessionId"); if (!sessionService.unsettleSession(sessionId)) { throw new Error(`Session '${sessionId}' was not found.`); @@ -2007,8 +2005,7 @@ function buildSessionDomainService(runtime: AdeRuntime): OpaqueService | null { return { ok: true, sessionId }; }, // Bulk settle/unsettle for renderer surfaces on remote-bound projects - // (mirrors deleteSession's generic trust posture; the *SelfSession pair - // stays caller-scoped for env-bound agents). + // (mirrors deleteSession's generic trust posture). settleSessions: (args?: unknown) => { const record = readObjectActionArg(args, "session.settleSessions"); const sessionIds = Array.isArray(record.sessionIds) diff --git a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts index 7dc93faf2..b978eaa97 100644 --- a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts +++ b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts @@ -570,8 +570,8 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record { expect(result).toContain("verify it before ending the turn"); expect(result).toContain('ade chat note "running e2e shard 2/4"'); expect(result).toContain('ade chat ask ""'); - expect(result).toContain("ade chat settle --outcome"); + // Agents cannot settle; the prompt says so instead of teaching a command. + expect(result).toContain("You cannot settle or unsettle a session"); + expect(result).not.toContain("ade chat settle --outcome"); }); it("describes the Codex CLI runtime", () => { diff --git a/apps/desktop/src/main/services/chat/cursorSdkSystemPrompt.test.ts b/apps/desktop/src/main/services/chat/cursorSdkSystemPrompt.test.ts index 5b71a1f30..d56e2d415 100644 --- a/apps/desktop/src/main/services/chat/cursorSdkSystemPrompt.test.ts +++ b/apps/desktop/src/main/services/chat/cursorSdkSystemPrompt.test.ts @@ -56,7 +56,10 @@ describe("buildCursorSdkSystemPrompt", () => { const out = buildCursorSdkSystemPrompt({ runtime: "local" }); expect(out.text).toContain('ade chat note "running e2e shard 2/4"'); expect(out.text).toContain('ade chat ask ""'); - expect(out.text).toContain("ade chat settle --outcome"); + // Settlement is user- and PR-merge-driven only; the prompt must never hand + // a Cursor agent a settle command again. + expect(out.text).toContain("You cannot settle or unsettle a session"); + expect(out.text).not.toContain("ade chat settle --outcome"); expect(out.sections.find((section) => section.name === "session-status")).toEqual( expect.objectContaining({ truncated: false }), ); diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index 8e5a6d105..d2eef0fdc 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -9,7 +9,7 @@ import { isWithinDir, normalizeBranchName, resolvePathWithinRoot } from "../shar import { fetchRemoteTrackingBranch, resolveQueueRebaseOverride, type QueueRebaseOverride } from "../shared/queueRebase"; import { detectConflictKind } from "../git/gitConflictState"; import { shouldLaneTrackParent } from "../../../shared/laneBaseResolution"; -import { allocateLaneColor } from "../../../shared/laneColorPalette"; +import { PRIMARY_LANE_COLOR, allocateLaneColor } from "../../../shared/laneColorPalette"; import { linearIssueBranchName, sanitizeLinearIssueBranchName } from "../../../shared/linearIssueBranch"; import { finalizeLaneLinearIssue, @@ -2255,11 +2255,24 @@ export function createLaneService({ }; const ensurePrimaryLane = async (): Promise => { - const existing = db.get<{ id: string }>( - "select id from lanes where project_id = ? and lane_type = 'primary' and status != 'archived' limit 1", + const existing = db.get<{ id: string; color: string | null }>( + "select id, color from lanes where project_id = ? and lane_type = 'primary' and status != 'archived' limit 1", [projectId] ); - if (existing?.id) return; + if (existing?.id) { + // Backfill: primaries created before the colour was reserved stored NULL. + // The Work sidebar normalises that at render time, but the Lanes tab, the + // lane combobox, and iOS all read `lane.color` raw — so without this the + // same lane is purple on one surface and grey on another. Repairing at + // the source is what makes "Primary is always ADE purple" true everywhere + // rather than true in one pane. Idempotent, so it costs nothing on the + // overwhelmingly common already-correct path. + if (!existing.color) { + db.run("update lanes set color = ? where id = ?", [PRIMARY_LANE_COLOR, existing.id]); + invalidateLaneListCache(); + } + return; + } const laneId = randomUUID(); const now = new Date().toISOString(); @@ -2270,9 +2283,12 @@ export function createLaneService({ id, project_id, name, description, lane_type, base_ref, branch_ref, worktree_path, attached_root_path, is_edit_protected, parent_lane_id, color, icon, tags_json, status, created_at, archived_at ) - values(?, ?, ?, ?, 'primary', ?, ?, ?, null, 1, null, null, null, null, 'active', ?, null) + values(?, ?, ?, ?, 'primary', ?, ?, ?, null, 1, null, ?, null, null, 'active', ?, null) `, - [laneId, projectId, "Primary", "Main repository workspace", defaultBaseRef, branchRef, primaryWorktreePath, now] + // Primary does NOT draw from the allocation pool: its colour is reserved + // ADE purple in every project on every machine, which is what makes it the + // one lane colour worth memorising (see `PRIMARY_LANE_COLOR`). + [laneId, projectId, "Primary", "Main repository workspace", defaultBaseRef, branchRef, primaryWorktreePath, PRIMARY_LANE_COLOR, now] ); invalidateLaneListCache(); }; diff --git a/apps/desktop/src/main/services/sessions/sessionService.ts b/apps/desktop/src/main/services/sessions/sessionService.ts index c26c149ff..e43bfc6f3 100644 --- a/apps/desktop/src/main/services/sessions/sessionService.ts +++ b/apps/desktop/src/main/services/sessions/sessionService.ts @@ -1299,8 +1299,9 @@ export function createSessionService({ db }: { db: AdeDb }) { * derived preview line is blank or unchanged (spinners, repeated status * lines), so the stale-session detector does not treat live work as idle. * PTY-only. Ordinary shell output un-settles by default; tracked agent - * CLIs opt out because their own `ade chat settle` command and final line - * are still PTY output. Their next input explicitly clears the markers. + * CLIs opt out because an agent's trailing output would otherwise undo a + * settle the user just took on the row. Their next input explicitly clears + * the markers. */ touchSessionActivity( sessionId: string, diff --git a/apps/desktop/src/main/services/usage/usageStatsStore.ts b/apps/desktop/src/main/services/usage/usageStatsStore.ts index 543afdd0a..7f4d6d635 100644 --- a/apps/desktop/src/main/services/usage/usageStatsStore.ts +++ b/apps/desktop/src/main/services/usage/usageStatsStore.ts @@ -215,8 +215,8 @@ export function usageActionFromRpcDomain(domain: string, action: string): string return `git.${aliases[action] ?? action}`; } if (domain === "session") { - if (action === "settleSelfSession" || action === "settleSessions") return "work.settleSession"; - if (action === "unsettleSelfSession" || action === "unsettleSessions") return "work.unsettleSession"; + if (action === "settleSession" || action === "settleSessions") return "work.settleSession"; + if (action === "unsettleSession" || action === "unsettleSessions") return "work.unsettleSession"; // Bulk and single snooze/wake collapse to one usage action each: the count // that matters is "how often was work filed away", not the batch shape. if (action === "snoozeSession" || action === "snoozeSessions") return "work.snoozeSession"; diff --git a/apps/desktop/src/main/services/usage/usageTrackingService.test.ts b/apps/desktop/src/main/services/usage/usageTrackingService.test.ts index 5aa237c18..1930d3b55 100644 --- a/apps/desktop/src/main/services/usage/usageTrackingService.test.ts +++ b/apps/desktop/src/main/services/usage/usageTrackingService.test.ts @@ -4754,7 +4754,7 @@ describe("ADE database usage aggregation", () => { expect(usageActionFromIpcChannel("ade.sessions.settle")).toBe("work.settleSession"); expect(usageActionFromIpcChannel("ade.sessions.settleMany")).toBe("work.settleSession"); expect(usageActionFromIpcChannel("ade.sessions.unsettleMany")).toBe("work.unsettleSession"); - expect(usageActionFromRpcDomain("session", "settleSelfSession")).toBe("work.settleSession"); + expect(usageActionFromRpcDomain("session", "settleSession")).toBe("work.settleSession"); expect(usageActionFromRpcDomain("session", "unsettleSessions")).toBe("work.unsettleSession"); expect(isMeaningfulUsageAction("work.settleSession")).toBe(true); expect(isMeaningfulUsageAction("work.unsettleSession")).toBe(true); diff --git a/apps/desktop/src/preload/preload.test.ts b/apps/desktop/src/preload/preload.test.ts index 414bbe4fc..022738aeb 100644 --- a/apps/desktop/src/preload/preload.test.ts +++ b/apps/desktop/src/preload/preload.test.ts @@ -6875,7 +6875,7 @@ describe("per-chat runtime routing", () => { "snoozeSession", "wakeSession", "settleSession", - "unsettleSelfSession", + "unsettleSession", "setSettleOverride", "clearWokeMarker", "deleteSession", diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 69ccf0dc0..d76551283 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -5801,7 +5801,7 @@ contextBridge.exposeInMainWorld("ade", { await callPinnedOrBoundRuntimeActionOr( pin, "session", - "unsettleSelfSession", + "unsettleSession", { args: { sessionId } }, () => ipcRenderer.invoke(IPC.sessionsUnsettle, { sessionId }), ); diff --git a/apps/desktop/src/renderer/components/app/CommandPalette.test.tsx b/apps/desktop/src/renderer/components/app/CommandPalette.test.tsx index 499c9daff..e60cf06f9 100644 --- a/apps/desktop/src/renderer/components/app/CommandPalette.test.tsx +++ b/apps/desktop/src/renderer/components/app/CommandPalette.test.tsx @@ -9,11 +9,23 @@ import { screen, waitFor, } from "@testing-library/react"; -import { MemoryRouter } from "react-router-dom"; +import { MemoryRouter, useLocation } from "react-router-dom"; import { CommandPalette } from "./CommandPalette"; import { PROJECT_BROWSER_CLOSE_EVENT } from "../../lib/projectBrowserEvents"; +import { + SESSION_TONE_DOT_CLASS, + SESSION_TONE_TEXT_CLASS, +} from "../../../shared/sessionStatusPresentation"; import { useAppStore } from "../../state/appStore"; +/** Surfaces the router location so navigation assertions read the real URL. */ +function LocationProbe() { + const location = useLocation(); + return ( +
{`${location.pathname}${location.search}`}
+ ); +} + function deferred() { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; @@ -24,10 +36,12 @@ function deferred() { return { promise, resolve, reject }; } +const PROJECT_ROOT = "/Users/admin/Projects/ADE"; + function seedStore(overrides: Record = {}) { useAppStore.setState({ project: { - rootPath: "/Users/admin/Projects/ADE", + rootPath: PROJECT_ROOT, displayName: "ADE", baseRef: "main", }, @@ -35,10 +49,129 @@ function seedStore(overrides: Record = {}) { selectedLaneId: null, selectLane: vi.fn(), switchProjectToPath: vi.fn(async () => {}), + // Reset explicitly: `setState` merges, so a thread test's sessions would + // otherwise leak into every later test's palette. + sessionsCacheByProject: {}, + workViewByProject: {}, + crossMachineLanesByMachineId: {}, ...overrides, } as any); } +function makeLane(overrides: Record = {}) { + return { + id: "lane-1", + name: "redesign", + laneType: "worktree", + baseRef: "main", + branchRef: "t3code/redesign-new-chat-ui", + worktreePath: "/tmp/wt/redesign", + parentLaneId: null, + childCount: 0, + stackDepth: 0, + parentStatus: null, + isEditProtected: false, + status: "active", + color: null, + icon: null, + tags: [], + createdAt: new Date().toISOString(), + ...overrides, + }; +} + +function makeSession(overrides: Record = {}) { + return { + id: "session-1", + laneId: "lane-1", + laneName: "redesign", + ptyId: null, + tracked: true, + pinned: false, + goal: null, + toolType: "claude", + title: "Redesign ADE Chat and Prompt UI", + status: "running", + runtimeState: "running", + startedAt: new Date().toISOString(), + lastActivityAt: new Date().toISOString(), + endedAt: null, + exitCode: null, + transcriptPath: "", + headShaStart: null, + headShaEnd: null, + lastOutputPreview: null, + summary: null, + resumeCommand: null, + ...overrides, + }; +} + +const REMOTE_BINDING = { + kind: "remote" as const, + key: "remote:target-studio:project-remote-ade", + targetId: "target-studio", + runtimeName: "Mac Studio", + projectId: "project-remote-ade", + rootPath: "/remote/ADE", + displayName: "ADE", +}; + +/** + * One machine's slice of the cross-machine Work union + * (`CrossMachineMachineLanes`). The palette reads these straight off the root + * store — the same slices the Work sidebar renders — so seeding the store is + * the whole setup; there is no foreign-session IPC to mock. + */ +function makeForeignMachine(overrides: Record = {}) { + return { + machineId: "target-studio", + machineName: "Mac Studio", + targetId: "target-studio", + projectId: "project-remote-ade", + binding: REMOTE_BINDING, + online: true, + lanes: [ + makeLane({ + id: "lane-remote", + name: "release", + branchRef: "t3code/release-notes", + }), + ], + sessions: [ + makeSession({ + id: "session-remote-1", + title: "Ship the release notes", + laneId: "lane-remote", + laneName: "release", + }), + ], + lastSyncedAtMs: Date.now(), + error: null, + ...overrides, + }; +} + +/** Seed the palette with threads sourced from the Work tab's per-project cache. */ +function seedThreads( + sessions: Array>, + options: { + lanes?: Array>; + activeItemId?: string | null; + /** Foreign machine slices, keyed by machine id, as the union stores them. */ + machines?: Record>; + } = {}, +) { + seedStore({ + lanes: options.lanes ?? [makeLane()], + sessionsCacheByProject: { [PROJECT_ROOT]: sessions }, + workViewByProject: { + [PROJECT_ROOT]: { activeItemId: options.activeItemId ?? null }, + }, + crossMachineLanesByMachineId: options.machines ?? {}, + }); +} + describe("CommandPalette", () => { const browseDirectories = vi.fn(); const chooseDirectory = vi.fn(); @@ -506,4 +639,386 @@ describe("CommandPalette", () => { }), ).toBeNull(); }); + + describe("threads", () => { + it("lists recent threads with their lane context and marks the current one", async () => { + seedThreads( + [ + makeSession(), + makeSession({ + id: "session-2", + title: "Redesign ADE Sidebar UI", + laneId: "lane-2", + laneName: "sidebar", + status: "completed", + runtimeState: "exited", + exitCode: 0, + toolType: "shell", + lastActivityAt: new Date(Date.now() - 5 * 60_000).toISOString(), + }), + ], + { + lanes: [ + makeLane(), + makeLane({ + id: "lane-2", + name: "sidebar", + branchRef: "t3code/refine-ade-chat-sidebar", + }), + ], + activeItemId: "session-2", + }, + ); + + render( + + + , + ); + + expect(await screen.findByText("Recent threads")).toBeTruthy(); + expect(screen.getByText("Redesign ADE Chat and Prompt UI")).toBeTruthy(); + // Project · branch, with the active session annotated in place. + expect( + screen.getByText("ADE · #t3code/refine-ade-chat-sidebar"), + ).toBeTruthy(); + expect(screen.getByText(/Current thread/)).toBeTruthy(); + }); + + it("matches threads on branch and lane name, not just title", async () => { + seedThreads( + [ + makeSession({ id: "session-1", title: "Alpha" }), + makeSession({ + id: "session-2", + title: "Beta", + laneId: "lane-2", + laneName: "payments", + }), + ], + { + lanes: [ + makeLane({ id: "lane-1", branchRef: "t3code/pty-foundation" }), + makeLane({ + id: "lane-2", + name: "payments", + branchRef: "feat/stripe", + }), + ], + }, + ); + + render( + + + , + ); + + const input = screen.getByPlaceholderText( + "Search commands, projects, and threads…", + ); + + // Branch-only hit: "pty-foundation" appears in neither title nor lane name. + fireEvent.change(input, { target: { value: "pty-foundation" } }); + await waitFor(() => { + expect(screen.getByText("Alpha")).toBeTruthy(); + }); + expect(screen.queryByText("Beta")).toBeNull(); + + // Lane-name-only hit. + fireEvent.change(input, { target: { value: "payments" } }); + await waitFor(() => { + expect(screen.getByText("Beta")).toBeTruthy(); + }); + expect(screen.queryByText("Alpha")).toBeNull(); + }); + + it("renders the status indicator from the shared tone classes", async () => { + seedThreads([makeSession()]); + + render( + + + , + ); + + const status = await screen.findByTestId("thread-status-session-1"); + expect(status.textContent).toContain("Working"); + // The whole point of the redesign: one hue table. A running session is + // blue, and both the word and the dot come from the shared maps. + expect(status.className).toContain(SESSION_TONE_TEXT_CLASS.blue); + const dot = status.querySelector("span"); + expect(dot?.className).toContain(SESSION_TONE_DOT_CLASS.blue); + }); + + it("omits the status indicator for calm threads", async () => { + seedThreads([ + makeSession({ + status: "completed", + runtimeState: "exited", + exitCode: 0, + toolType: "shell", + settledAt: new Date().toISOString(), + }), + ]); + + render( + + + , + ); + + expect(await screen.findByText("Recent threads")).toBeTruthy(); + expect(screen.queryByTestId("thread-status-session-1")).toBeNull(); + }); + + it("navigates to the Work tab and focuses the session when a thread is picked", async () => { + const onOpenChange = vi.fn(); + const selectEvents: Array<{ + sessionId: string; + laneId?: string; + binding?: unknown; + }> = []; + const listener = (event: Event) => { + selectEvents.push((event as CustomEvent).detail); + }; + window.addEventListener("ade:work:select-session", listener); + seedThreads([makeSession()]); + + render( + + + + , + ); + + fireEvent.click( + await screen.findByText("Redesign ADE Chat and Prompt UI"), + ); + + await waitFor(() => { + expect(screen.getByTestId("location").textContent).toBe( + "/work?sessionId=session-1&laneId=lane-1", + ); + }); + expect(selectEvents).toEqual([ + { sessionId: "session-1", laneId: "lane-1" }, + ]); + // A local thread carries no binding: the Work tab focuses it against the + // already-current project, so the deeplink in the URL is the whole route. + expect(selectEvents[0]?.binding).toBeUndefined(); + expect(onOpenChange).toHaveBeenCalledWith(false); + window.removeEventListener("ade:work:select-session", listener); + }); + + it("caps the thread group and says how many matches are hidden", async () => { + // Age BOTH timestamps: `recencyRank` takes the max of them, so leaving + // `startedAt` at the default "now" would make all 12 rows equally recent + // and hand the order to sub-millisecond loop timing. + const base = Date.now(); + seedThreads( + Array.from({ length: 12 }, (_, i) => { + const iso = new Date(base - i * 60_000).toISOString(); + return makeSession({ + id: `session-${i}`, + title: `Thread ${i}`, + startedAt: iso, + lastActivityAt: iso, + }); + }), + ); + + render( + + + , + ); + + expect(await screen.findByText("Recent threads")).toBeTruthy(); + expect( + screen.getByText(/Showing 8 of 12 threads/), + ).toBeTruthy(); + // Newest first, oldest past the cap dropped. + expect(screen.getByText("Thread 0")).toBeTruthy(); + expect(screen.queryByText("Thread 8")).toBeNull(); + }); + + it("renders the persistent keyboard hint footer", async () => { + seedStore(); + + render( + + + , + ); + + expect(await screen.findByText("Navigate")).toBeTruthy(); + expect(screen.getByText("Select")).toBeTruthy(); + expect(screen.getByText("Close")).toBeTruthy(); + }); + + // The Work sidebar is a union across every connected machine, always. The + // palette is that sidebar's search, so a thread the user can see one pane + // over must be findable here — anything less answers "no results" for work + // that is plainly on screen. + describe("across machines", () => { + it("lists a thread from another machine and marks it with that machine", async () => { + seedThreads([makeSession()], { + machines: { "target-studio": makeForeignMachine() }, + }); + + render( + + + , + ); + + expect(await screen.findByText("Ship the release notes")).toBeTruthy(); + // Local and foreign rows share one group — the machine is a property of + // the row, not a separate section. + expect(screen.getByText("Redesign ADE Chat and Prompt UI")).toBeTruthy(); + + const row = document.querySelector( + '[data-thread-id="session-remote-1"]', + ); + expect(row?.dataset.machineId).toBe("target-studio"); + // Identity, not status: the marker lives in the context line, so the + // row's status slot is still free for the session's own state. + const marker = row?.querySelector("[data-machine-marker-mode]"); + expect(marker?.textContent).toContain("Mac Studio"); + expect( + screen.getByTestId("thread-status-session-remote-1").contains(marker!), + ).toBe(false); + // The foreign lane resolves through its OWN machine's lane list, not + // the locally-bound project's. + expect(screen.getByText("ADE · #t3code/release-notes")).toBeTruthy(); + }); + + it("matches a thread on its machine name", async () => { + seedThreads([makeSession()], { + machines: { "target-studio": makeForeignMachine() }, + }); + + render( + + + , + ); + + const input = screen.getByPlaceholderText( + "Search commands, projects, and threads…", + ); + // "studio" appears in neither title, lane name, nor branch. + fireEvent.change(input, { target: { value: "studio" } }); + + await waitFor(() => { + expect(screen.getByText("Ship the release notes")).toBeTruthy(); + }); + expect(screen.queryByText("Redesign ADE Chat and Prompt UI")).toBeNull(); + }); + + it("keeps an offline machine's threads findable but receded", async () => { + seedThreads([], { + machines: { + "target-studio": makeForeignMachine({ online: false }), + }, + }); + + render( + + + , + ); + + expect(await screen.findByText("Ship the release notes")).toBeTruthy(); + + const row = document.querySelector( + '[data-thread-id="session-remote-1"]', + ); + // Same attribute the sidebar puts on a receded cross-machine group. + expect(row?.dataset.dimmed).toBe("true"); + expect(row?.dataset.machineOnline).toBe("false"); + // The status is the LAST THING REPORTED, not a live claim — a dropped + // machine's chat still reads "Working" — so it must not look as + // trustworthy as a live row. + const status = screen.getByTestId("thread-status-session-remote-1"); + expect(status.textContent).toContain("Working"); + expect(status.closest("[data-dimmed]")).toBe(row); + }); + + it("routes a foreign thread through its binding instead of a stale deeplink", async () => { + const onOpenChange = vi.fn(); + const selectEvents: Array<{ + sessionId: string; + laneId?: string; + binding?: unknown; + }> = []; + const listener = (event: Event) => { + selectEvents.push((event as CustomEvent).detail); + }; + window.addEventListener("ade:work:select-session", listener); + seedThreads([], { + machines: { "target-studio": makeForeignMachine() }, + }); + + render( + + + + , + ); + + fireEvent.click(await screen.findByText("Ship the release notes")); + + expect(selectEvents).toEqual([ + { + sessionId: "session-remote-1", + laneId: "lane-remote", + binding: REMOTE_BINDING, + }, + ]); + // The tab still switches to Work, but WITHOUT the session deeplink: the + // project switch the listener starts is async, and `useWorkSessions` + // would resolve `?sessionId=` against the still-current project a tick + // from now, find nothing, and strip it. The binding is the durable + // route; the listener focuses once the switch resolves. + await waitFor(() => { + expect(screen.getByTestId("location").textContent).toBe("/work"); + }); + expect(onOpenChange).toHaveBeenCalledWith(false); + window.removeEventListener("ade:work:select-session", listener); + }); + + it("never lists a session twice when the union overlaps the local list", async () => { + // While the tab is bound remotely, that machine's slice and the local + // session list are the same rows. "Never show one session twice" is the + // invariant; the local entry wins so it keeps the local routing path. + seedThreads([makeSession()], { + machines: { + "target-studio": makeForeignMachine({ + sessions: [makeSession(), makeSession({ id: "session-remote-1" })], + }), + }, + }); + + render( + + + , + ); + + await screen.findByText("Recent threads"); + expect( + document.querySelectorAll('[data-thread-id="session-1"]'), + ).toHaveLength(1); + // The deduped row keeps the LOCAL identity — no machine marker, no + // binding — so picking it takes the synchronous path. + expect( + document + .querySelector('[data-thread-id="session-1"]') + ?.dataset.machineId, + ).toBeUndefined(); + }); + }); + }); }); diff --git a/apps/desktop/src/renderer/components/app/CommandPalette.tsx b/apps/desktop/src/renderer/components/app/CommandPalette.tsx index c70decca5..9967f6ac0 100644 --- a/apps/desktop/src/renderer/components/app/CommandPalette.tsx +++ b/apps/desktop/src/renderer/components/app/CommandPalette.tsx @@ -30,6 +30,7 @@ import type { ProjectIcon, RemoteRuntimeConnectionSnapshot, RemoteRuntimeConnectionStatus, + TerminalSessionSummary, WorktreeParentRef, } from "../../../shared/types"; import type { SearchResultItem } from "../../../shared/types/search"; @@ -43,9 +44,21 @@ import { relativeFilePathForResult, useUniversalSearch, } from "./commandPaletteSearch"; +import { + THREAD_RESULT_LIMIT, + ThreadOverflowNote, + ThreadResultRow, + rankThreads, + useThreadIndex, + type ThreadIndexEntry, +} from "./commandPaletteThreads"; import { fadeScale } from "../../lib/motion"; import { PROJECT_BROWSER_CLOSE_EVENT } from "../../lib/projectBrowserEvents"; -import { useAppStore } from "../../state/appStore"; +import { + selectActiveProjectStateKey, + useAppStore, + useRootAppStore, +} from "../../state/appStore"; import { cn } from "../ui/cn"; import { setPendingSessionAnchor } from "../terminals/pendingSessionAnchors"; import { readStoredPrsRoute } from "../prs/prsRouteState"; @@ -241,6 +254,9 @@ function rememberProjectIcon(rootPath: string, icon: ProjectIcon): void { } } +/** Stable empty array so the "no sessions yet" case never re-memoizes the index. */ +const EMPTY_SESSIONS: TerminalSessionSummary[] = []; + function pathLabel(input: string | null | undefined): string { if (!input) return ""; const segments = input.split(/[\\/]/).filter(Boolean); @@ -266,6 +282,30 @@ export function CommandPalette({ const switchRemoteProject = useAppStore((s) => s.switchRemoteProject); const hasActiveProject = Boolean(project?.rootPath); + // Thread results are read straight out of the Work tab's per-project session + // cache — no new IPC. The Work tab is keep-alive mounted and mirrors its + // fetched list into `sessionsCacheByProject`, so by the time the palette can + // be opened this is the same list the sidebar is rendering. Selecting the + // array (not the whole map) keeps the palette from re-rendering when an + // unrelated project's cache is refreshed. + const threadSessions = + useAppStore((s) => { + const key = selectActiveProjectStateKey(s); + return key ? (s.sessionsCacheByProject[key] ?? null) : null; + }) ?? EMPTY_SESSIONS; + const activeSessionId = useAppStore((s) => { + const key = selectActiveProjectStateKey(s); + return key ? (s.workViewByProject[key]?.activeItemId ?? null) : null; + }); + // The Work sidebar is a union across every connected machine, always. The + // palette is that sidebar's search, so it reads the same union — otherwise it + // would report "no matches" for a thread visible one pane over. Read straight + // off the root store rather than through `useCrossMachineLaneUnion`: that hook + // also *starts* the cross-machine sync and fires a `project.listRecent` on + // mount, and the palette is mounted for the whole session. Whatever the Work + // tab's sync has populated is what we search. + const foreignMachines = useRootAppStore((s) => s.crossMachineLanesByMachineId); + const [mode, setMode] = useState("default"); const [actionOutcome, setActionOutcome] = useState(null); @@ -792,16 +832,42 @@ export function CommandPalette({ const canEntitySearch = open && mode === "default" && hasActiveProject && trimmedQuery.length > 0; + // Built once per session/lane list, not per keystroke — the palette can be + // opened against hundreds of sessions and the lowercasing is the expensive + // half of the match. + const threadIndex = useThreadIndex(threadSessions, lanes, foreignMachines); + const threadMatches = useMemo( + () => + open && mode === "default" ? rankThreads(threadIndex, trimmedQuery) : [], + [mode, open, threadIndex, trimmedQuery], + ); + const visibleThreads = useMemo( + () => threadMatches.slice(0, THREAD_RESULT_LIMIT), + [threadMatches], + ); + const visibleThreadIds = useMemo( + () => new Set(visibleThreads.map((match) => match.entry.session.id)), + [visibleThreads], + ); + const { loading: searchLoading, sections: entitySections, flatEntities, expandedKinds, toggleExpandKind, - } = useUniversalSearch(trimmedQuery, canEntitySearch); - + } = useUniversalSearch(trimmedQuery, canEntitySearch, { + excludeSessionIds: visibleThreadIds, + }); + + // Flat keyboard index layout: threads, then commands, then entity results. + // Threads lead because the palette is the Work sidebar's search now — with an + // empty query the thing you most likely came here for is a chat you were just + // in, not a command. When a query matches no thread the section disappears + // and commands lead on their own, so the first row is always a live target. + const threadCount = visibleThreads.length; const commandCount = filtered.length; - const totalFlat = commandCount + flatEntities.length; + const totalFlat = threadCount + commandCount + flatEntities.length; const browseRows = useMemo(() => { if (!browseResult) return []; @@ -1106,6 +1172,52 @@ export function CommandPalette({ [onOpenChange], ); + /** + * Open a thread. Same two-step as the `chat`/`terminal` search results below: + * the Work tab is keep-alive mounted, so the event focuses the session and + * the navigate is only what switches the visible tab. + */ + const activateThread = useCallback( + (entry: ThreadIndexEntry) => { + const { session, binding } = entry; + // A foreign thread lives on another machine's project, so focusing it by + // id alone would land on whatever session happens to share that id here — + // or nothing. The binding rides along in the event detail and the Work + // tab switches projects before focusing (see the `ade:work:select-session` + // listener in TerminalsPage). Local threads pass `undefined` and take the + // original synchronous path. + window.dispatchEvent( + new CustomEvent("ade:work:select-session", { + detail: { + sessionId: session.id, + laneId: session.laneId || undefined, + binding: binding ?? undefined, + }, + }), + ); + // Switch the visible tab either way — the Work tab is where a thread is + // read, and the palette can be opened from anywhere. What differs is the + // query string: a foreign thread's `sessionId`/`laneId` name rows in the + // OTHER project, and `useWorkSessions`' URL-param effect resolves those + // against the still-current project one tick from now, finds nothing, and + // strips them — so the deeplink would be consumed and discarded before the + // switch ever lands. The event's binding is the durable route; the + // listener focuses once `switchProjectToPath`/`switchRemoteProject` + // resolves. + navigate( + binding + ? "/work" + : `/work?sessionId=${encodeURIComponent(session.id)}${ + session.laneId + ? `&laneId=${encodeURIComponent(session.laneId)}` + : "" + }`, + ); + onOpenChange(false); + }, + [navigate, onOpenChange], + ); + const activateResult = useCallback( (item: SearchResultItem) => { switch (item.kind) { @@ -1233,23 +1345,31 @@ export function CommandPalette({ const activateFlat = useCallback( (index: number) => { - if (index < commandCount) { - const command = filtered[index]; + if (index < threadCount) { + const match = visibleThreads[index]; + if (match) activateThread(match.entry); + return; + } + if (index < threadCount + commandCount) { + const command = filtered[index - threadCount]; if (command) runCommand(command); return; } - const entity = flatEntities[index - commandCount]; + const entity = flatEntities[index - threadCount - commandCount]; if (!entity) return; if (entity.type === "result") activateResult(entity.item); else toggleExpandKind(entity.kind); }, [ activateResult, + activateThread, commandCount, filtered, flatEntities, runCommand, + threadCount, toggleExpandKind, + visibleThreads, ], ); @@ -1488,7 +1608,7 @@ export function CommandPalette({ ? activeRemoteTargetId ? `Browse ${browseMachineName} by path…` : "Paste a path, type to filter, or drop a folder anywhere…" - : "Search commands..."; + : "Search commands, projects, and threads…"; const handleProjectActionSuccess = useCallback( ( @@ -2135,142 +2255,186 @@ export function CommandPalette({ ) : ( -
- {totalFlat === 0 ? ( - trimmedQuery.length > 0 && searchLoading ? ( -
- - Searching… -
- ) : trimmedQuery.length > 0 ? ( -
- No matches — try kind:chat, lane:<name>, since:7d, - or "exact phrase" -
+ <> +
+ {totalFlat === 0 ? ( + trimmedQuery.length > 0 && searchLoading ? ( +
+ + Searching… +
+ ) : trimmedQuery.length > 0 ? ( +
+ No matches — try kind:chat, lane:<name>, since:7d, + or "exact phrase" +
+ ) : ( +
+ No matches. +
+ ) ) : ( -
- No matches. -
- ) - ) : ( -
    - {(() => { - let flatIndex = 0; - const commandNodes = grouped.map((group) => ( -
  • -
    - {group.label} -
    -
      - {group.items.map((command) => { - const index = flatIndex++; - const isSelected = index === selectedIdx; - return ( -
    • - -
    • - ); - })} -
    -
  • - )); - - const entityNodes = entitySections.map((section) => { - const expanded = expandedKinds.has(section.kind); - const visible = expanded - ? section.rows - : section.rows.slice(0, ENTITY_SECTION_PREVIEW); - const showMore = - !expanded && - section.rows.length > ENTITY_SECTION_PREVIEW; - const hiddenCount = - section.total - ENTITY_SECTION_PREVIEW; - return ( -
  • +
      + {(() => { + let flatIndex = 0; + const threadNodes = + visibleThreads.length > 0 + ? [ +
    • +
      + Recent threads +
      +
        + {visibleThreads.map((match) => { + const index = flatIndex++; + return ( + + ); + })} + +
      +
    • , + ] + : []; + const commandNodes = grouped.map((group) => ( +
    • - {section.label} + {group.label}
        - {visible.map((item) => { + {group.items.map((command) => { const index = flatIndex++; + const isSelected = index === selectedIdx; return ( - +
      • + +
      • ); })} - {showMore - ? (() => { - const index = flatIndex++; - return ( - - ); - })() - : null}
    • - ); - }); - - return [...commandNodes, ...entityNodes]; - })()} -
    - )} -
+ )); + + const entityNodes = entitySections.map((section) => { + const expanded = expandedKinds.has(section.kind); + const visible = expanded + ? section.rows + : section.rows.slice(0, ENTITY_SECTION_PREVIEW); + const showMore = + !expanded && + section.rows.length > ENTITY_SECTION_PREVIEW; + const hiddenCount = + section.total - ENTITY_SECTION_PREVIEW; + return ( +
  • +
    + {section.label} +
    +
      + {visible.map((item) => { + const index = flatIndex++; + return ( + + ); + })} + {showMore + ? (() => { + const index = flatIndex++; + return ( + + ); + })() + : null} +
    +
  • + ); + }); + + return [ + ...threadNodes, + ...commandNodes, + ...entityNodes, + ]; + })()} + + )} +
    + + )} @@ -2281,6 +2445,34 @@ export function CommandPalette({ ); } +/** + * Persistent keyboard legend under the results. Deliberately quiet — hairline + * separator, muted text, same `` chip the project browser's footer and the + * per-row shortcut chips already use, so the palette has exactly one key-cap + * treatment. + */ +function PaletteFooterHints() { + return ( +
    + + ↑↓ + + Navigate + + ↵ + + Select + + Esc + + Close +
    + ); +} + function ProjectLocationChooser({ remoteLocations, onChoose, diff --git a/apps/desktop/src/renderer/components/app/commandPaletteSearch.tsx b/apps/desktop/src/renderer/components/app/commandPaletteSearch.tsx index 94c332cac..dd9d92de8 100644 --- a/apps/desktop/src/renderer/components/app/commandPaletteSearch.tsx +++ b/apps/desktop/src/renderer/components/app/commandPaletteSearch.tsx @@ -271,6 +271,18 @@ export type UseUniversalSearchResult = { toggleExpandKind: (kind: SearchDocKind) => void; }; +export type UseUniversalSearchOptions = { + /** + * Session ids already rendered by the palette's local "Recent threads" group. + * A thread that matches on its title almost always matches on its content + * too, so without this the same chat appears twice — once instantly from the + * local index, once again a beat later under "Chats". Excluding them here + * (rather than at the render site) keeps `flatEntities` the single source of + * truth for the keyboard index. + */ + excludeSessionIds?: ReadonlySet; +}; + /** * Owns the debounced `window.ade.search.query` call, its generation guard, * loading state, kind-grouped sections, and the expand-per-kind state. `query` @@ -280,7 +292,9 @@ export type UseUniversalSearchResult = { export function useUniversalSearch( query: string, enabled: boolean, + options: UseUniversalSearchOptions = {}, ): UseUniversalSearchResult { + const excludeSessionIds = options.excludeSessionIds; const [results, setResults] = useState(null); const [totalByKind, setTotalByKind] = useState< Partial> @@ -339,7 +353,18 @@ export function useUniversalSearch( const sections = useMemo(() => { if (!results || results.length === 0) return []; const byKind = new Map(); + // Count what the exclusion removed per kind so each section's "show N more" + // total stays honest against the rows actually rendered. + const excludedByKind = new Map(); for (const item of results) { + if ( + excludeSessionIds && + item.sessionId && + excludeSessionIds.has(item.sessionId) + ) { + excludedByKind.set(item.kind, (excludedByKind.get(item.kind) ?? 0) + 1); + continue; + } const bucket = byKind.get(item.kind); if (bucket) bucket.push(item); else byKind.set(item.kind, [item]); @@ -348,15 +373,16 @@ export function useUniversalSearch( for (const kind of ENTITY_KIND_ORDER) { const rows = byKind.get(kind); if (!rows || rows.length === 0) continue; + const reportedTotal = totalByKind[kind] ?? rows.length; next.push({ kind, label: ENTITY_KIND_LABEL[kind], rows, - total: totalByKind[kind] ?? rows.length, + total: Math.max(rows.length, reportedTotal - (excludedByKind.get(kind) ?? 0)), }); } return next; - }, [results, totalByKind]); + }, [excludeSessionIds, results, totalByKind]); // Flattened, keyboard-navigable sequence of entity rows + show-more rows, // continuing after the command items in the shared flat index. diff --git a/apps/desktop/src/renderer/components/app/commandPaletteThreads.tsx b/apps/desktop/src/renderer/components/app/commandPaletteThreads.tsx new file mode 100644 index 000000000..afc7a92ee --- /dev/null +++ b/apps/desktop/src/renderer/components/app/commandPaletteThreads.tsx @@ -0,0 +1,460 @@ +/** + * Thread (session) results for the command palette. + * + * The palette is now the Work sidebar's search — the sidebar's inline input was + * replaced by a button that opens this dialog — so the palette has to answer + * "which chat was I in?" as well as it answers "which command do I want?". + * This module owns the searchable index over `TerminalSessionSummary`, the + * ranking, and the row. `CommandPalette.tsx` keeps the flat keyboard index and + * the navigation. + * + * Status presentation deliberately goes through `sessionStatusDisplay` + + * `SESSION_TONE_*_CLASS` rather than a local color map: see + * `shared/sessionStatusPresentation.ts` for why exactly one hue table exists. + */ +import React, { useMemo } from "react"; +import { ChatCircle, Terminal } from "@phosphor-icons/react"; +import type { + LaneSummary, + OpenProjectBinding, + TerminalSessionSummary, +} from "../../../shared/types"; +import type { CrossMachineMachineLanes } from "../../state/appStore"; +import type { CrossMachineLaneMarker } from "../../state/crossMachineLanes"; +import { LaneMachineMarker } from "../terminals/LaneMachineMarker"; +import { + SESSION_TONE_DOT_CLASS, + SESSION_TONE_TEXT_CLASS, + type SessionStatusPresentation, +} from "../../../shared/sessionStatusPresentation"; +import { relativeTimeCompact } from "../../lib/format"; +import { isChatToolType } from "../../lib/sessions"; +import { + isSessionSnoozed, + sessionWokeMarker, + snoozeWakeLabel, +} from "../../lib/sessionSnooze"; +import { + canonicalInputFromSummary, + sessionStatusDisplay, +} from "../../lib/terminalAttention"; +import { cn } from "../ui/cn"; +import { highlightTitle } from "./commandPaletteSearch"; + +/** + * Rows rendered in the Recent threads group. The palette can be opened against + * a project with hundreds of sessions loaded; rendering all of them would push + * every command off-screen and make the flat keyboard index unusable. The cap + * is surfaced to the user by `ThreadOverflowNote` rather than silently applied. + */ +export const THREAD_RESULT_LIMIT = 8; + +export type ThreadIndexEntry = { + session: TerminalSessionSummary; + /** Lane/worktree label — the "project · branch" line's first half. */ + laneName: string; + /** Lane branch ref, when the lane is known. Shown with a leading `#`. */ + branch: string | null; + /** + * Cross-machine identity. Null for threads on the machine this tab is bound + * to; set for every foreign row, which is marked and (when the machine is + * unreachable) receded rather than hidden. + */ + machineId: string | null; + machineName: string | null; + machineOnline: boolean; + /** Routing target for a foreign thread — the machine's project binding. */ + binding: OpenProjectBinding | null; + /** Pre-lowercased haystacks, built once per session list (not per keystroke). */ + titleLower: string; + laneNameLower: string; + branchLower: string; + machineNameLower: string; + /** Recency rank (ms). Ties in relevance fall back to "most recently touched". */ + recencyMs: number; +}; + +function timestampMs(value: string | null | undefined): number { + if (!value) return 0; + const ms = new Date(value).getTime(); + return Number.isFinite(ms) ? ms : 0; +} + +/** + * Most-recent-activity rank. `lastActivityAt` is the honest "when did something + * happen here" column; `startedAt` only says when the row was created, which + * would bury a long-lived chat you were just talking in under a terminal you + * opened and abandoned. + */ +function recencyRank(session: TerminalSessionSummary): number { + return Math.max( + timestampMs(session.lastActivityAt), + timestampMs(session.settledAt), + timestampMs(session.startedAt), + ); +} + +function makeEntry(args: { + session: TerminalSessionSummary; + lane: LaneSummary | null; + machineId: string | null; + machineName: string | null; + machineOnline: boolean; + binding: OpenProjectBinding | null; +}): ThreadIndexEntry { + const laneName = args.lane?.name ?? args.session.laneName ?? ""; + const branch = args.lane?.branchRef?.trim() || null; + return { + session: args.session, + laneName, + branch, + machineId: args.machineId, + machineName: args.machineName, + machineOnline: args.machineOnline, + binding: args.binding, + titleLower: (args.session.title ?? "").toLowerCase(), + laneNameLower: laneName.toLowerCase(), + branchLower: (branch ?? "").toLowerCase(), + machineNameLower: (args.machineName ?? "").toLowerCase(), + recencyMs: recencyRank(args.session), + }; +} + +/** + * Build the searchable thread index. Memoize this against the session/lane + * arrays — it lowercases every field, which is exactly the work we must not + * redo on each keystroke. + * + * The index is a UNION across every connected machine, matching the Work + * sidebar. That is not a nicety: the sidebar shows foreign work unconditionally, + * so a palette that searched only the bound machine would answer "no results" + * for a thread the user can see one pane over — a worse failure than having no + * thread search at all. Local rows win ties by session id, so the currently + * bound machine's sessions are never listed twice. + */ +export function buildThreadIndex( + sessions: readonly TerminalSessionSummary[], + lanes: readonly LaneSummary[], + foreignMachines: Readonly> = {}, +): ThreadIndexEntry[] { + const laneById = new Map(lanes.map((lane) => [lane.id, lane] as const)); + const seen = new Set(); + const entries: ThreadIndexEntry[] = []; + + for (const session of sessions) { + seen.add(session.id); + entries.push( + makeEntry({ + session, + lane: laneById.get(session.laneId) ?? null, + machineId: null, + machineName: null, + machineOnline: true, + binding: null, + }), + ); + } + + for (const machine of Object.values(foreignMachines)) { + const foreignLaneById = new Map( + (machine.lanes ?? []).map((lane) => [lane.id, lane] as const), + ); + for (const session of machine.sessions ?? []) { + // Id dedupe rather than a binding comparison: when the tab is bound to a + // remote, that machine's slice and the local list are the same sessions, + // and "never show one session twice" is the invariant we actually want. + if (seen.has(session.id)) continue; + seen.add(session.id); + entries.push( + makeEntry({ + session, + lane: foreignLaneById.get(session.laneId) ?? null, + machineId: machine.machineId, + machineName: machine.machineName, + machineOnline: machine.online, + binding: machine.binding ?? null, + }), + ); + } + } + + entries.sort((left, right) => right.recencyMs - left.recencyMs); + return entries; +} + +/** + * Per-term score for one entry. Returns 0 when the term matches nothing, which + * makes the caller's AND semantics fall out naturally: every typed word has to + * land somewhere on the row. + * + * The tiers encode where a hit is most likely to be what you meant: the title + * you read in the sidebar beats the lane you filed it under, which beats the + * branch you probably don't remember verbatim. A prefix hit outranks a mid-word + * hit within each field so typing "red" surfaces "Redesign…" above + * "…considered". + */ +function scoreTerm(entry: ThreadIndexEntry, term: string): number { + const titleIdx = entry.titleLower.indexOf(term); + if (titleIdx === 0) return 100; + if (titleIdx > 0) { + // Word-boundary hits read as deliberate; mid-word hits are usually noise. + const preceding = entry.titleLower[titleIdx - 1] ?? ""; + return /[\s\-_/.]/.test(preceding) ? 80 : 60; + } + const laneIdx = entry.laneNameLower.indexOf(term); + if (laneIdx === 0) return 50; + if (laneIdx > 0) return 40; + const branchIdx = entry.branchLower.indexOf(term); + if (branchIdx === 0) return 30; + if (branchIdx > 0) return 25; + // Machine name ranks last: "Mac Studio" narrows a set, it rarely identifies + // the one thread you want, so it must never outrank a title hit. + const machineIdx = entry.machineNameLower.indexOf(term); + if (machineIdx === 0) return 20; + if (machineIdx > 0) return 15; + return 0; +} + +export type ThreadMatch = { + entry: ThreadIndexEntry; + score: number; +}; + +/** + * Rank the index against a query. An empty query returns the index as-is (it is + * already recency-sorted), which is what "Recent threads" means with nothing + * typed. Callers slice to `THREAD_RESULT_LIMIT`; the full length is kept so the + * "showing N of M" affordance can tell the truth. + */ +export function rankThreads( + index: readonly ThreadIndexEntry[], + query: string, +): ThreadMatch[] { + const needle = query.trim().toLowerCase(); + if (!needle) return index.map((entry) => ({ entry, score: 0 })); + const terms = needle.split(/\s+/).filter(Boolean); + const matches: ThreadMatch[] = []; + for (const entry of index) { + let total = 0; + let matchedEveryTerm = true; + for (const term of terms) { + const score = scoreTerm(entry, term); + if (score === 0) { + matchedEveryTerm = false; + break; + } + total += score; + } + if (matchedEveryTerm) matches.push({ entry, score: total }); + } + // Stable-by-recency: the index is already recency-ordered, and Array#sort is + // stable in every engine we ship on, so equal scores keep that order. + matches.sort((left, right) => right.score - left.score); + return matches; +} + +/** Hook wrapper so the index is rebuilt only when the session/lane lists change. */ +export function useThreadIndex( + sessions: readonly TerminalSessionSummary[], + lanes: readonly LaneSummary[], + foreignMachines: Readonly>, +): ThreadIndexEntry[] { + return useMemo( + () => buildThreadIndex(sessions, lanes, foreignMachines), + [sessions, lanes, foreignMachines], + ); +} + +/** + * Resolve the row's status label, applying the snooze/woke visibility overlays + * the same way the sidebar does. Returns null for calm rows — those render no + * status at all, so a settled thread's slot stays free for its timestamp. + */ +export function threadStatusPresentation( + session: TerminalSessionSummary, + nowMs: number = Date.now(), +): SessionStatusPresentation | null { + const snoozed = isSessionSnoozed(session, nowMs); + const woke = !snoozed && sessionWokeMarker(session, nowMs) != null; + return sessionStatusDisplay(canonicalInputFromSummary(session), { + snoozed, + woke, + snoozeWakeLabel: snoozed ? snoozeWakeLabel(session.snoozedUntil, nowMs) : null, + }); +} + +function ThreadGlyph({ session }: { session: TerminalSessionSummary }) { + const className = "shrink-0 text-[var(--color-muted-fg)]"; + return isChatToolType(session.toolType) ? ( + + ) : ( + + ); +} + +export const ThreadResultRow = React.memo(function ThreadResultRow({ + entry, + query, + index, + isSelected, + isCurrent, + projectName, + onHover, + onActivate, +}: { + entry: ThreadIndexEntry; + query: string; + index: number; + isSelected: boolean; + /** The session currently open in the Work tab — annotated, never hidden. */ + isCurrent: boolean; + projectName: string | null; + onHover: (index: number) => void; + /** Takes the whole entry — foreign rows need the machine binding to route. */ + onActivate: (entry: ThreadIndexEntry) => void; +}) { + const { session } = entry; + const status = threadStatusPresentation(session); + const time = relativeTimeCompact( + session.lastActivityAt ?? session.settledAt ?? session.startedAt, + ); + // Foreign rows carry the same marker the sidebar puts on a foreign lane. Its + // amber tower is IDENTITY, not status — it lives in the context line and + // never in the status slot above, so it cannot be read as an attention call. + // `mode: "name"` because the palette has no lane header to disambiguate a + // bare glyph against. + const machineMarker: CrossMachineLaneMarker | null = + entry.machineId && entry.machineName + ? { + machineId: entry.machineId, + machineName: entry.machineName, + online: entry.machineOnline, + mode: "name", + title: entry.machineName, + sameBranchElsewhere: false, + } + : null; + // An unreachable machine's rows stay findable but recede, matching the + // sidebar's `dimmed` group treatment. Its status is the LAST THING REPORTED, + // not a live claim — a dropped machine's chat can still read "Working" hours + // after the machine went away, so the row must not look equally trustworthy. + const offline = machineMarker != null && !entry.machineOnline; + // Secondary line: where the thread lives. Project first (it is the coarser + // scope and the one the user names out loud), then branch, then the + // current-thread marker. + const contextParts: string[] = []; + if (projectName) contextParts.push(projectName); + else if (entry.laneName) contextParts.push(entry.laneName); + if (entry.branch) contextParts.push(`#${entry.branch}`); + else if (projectName && entry.laneName) contextParts.push(entry.laneName); + + return ( +
  • + +
  • + ); +}); + +/** + * The visible half of the render cap. Non-interactive and outside the flat + * keyboard index on purpose: it is a statement about the list, not a row you + * can act on, and making it focusable would put a dead stop in the middle of + * arrow-key navigation. + */ +export function ThreadOverflowNote({ + shown, + total, +}: { + shown: number; + total: number; +}) { + if (total <= shown) return null; + return ( +
  • + Showing {shown} of {total} threads — keep typing to narrow. +
  • + ); +} diff --git a/apps/desktop/src/renderer/components/attention/AttentionCenter.css b/apps/desktop/src/renderer/components/attention/AttentionCenter.css index cae103b6a..d4f82b0c2 100644 --- a/apps/desktop/src/renderer/components/attention/AttentionCenter.css +++ b/apps/desktop/src/renderer/components/attention/AttentionCenter.css @@ -48,7 +48,9 @@ literal, so it is safe to hold here. */ --tone-color: #a1a1aa; --tone-on: #0e1111; - --attn-warn: #fbbf24; + /* No --attn-warn here on purpose. A free-floating amber token is how amber + leaked onto four unrelated things; the only amber left in this file is + .attention-tone-amber, which a phase must earn by meaning "your move". */ --attn-danger: #f87171; --attn-ok: #34d399; --attn-idle: #71717a; @@ -70,7 +72,6 @@ --attn-sheen: rgba(255, 255, 255, 0.6); --attn-shadow-popover: 0 24px 60px -28px rgba(15, 23, 42, 0.28); --attn-shadow-menu: 0 18px 44px -22px rgba(15, 23, 42, 0.22); - --attn-warn: #b45309; --attn-danger: #b91c1c; --attn-ok: #047857; --attn-idle: #71717a; @@ -160,6 +161,10 @@ box-shadow: inset 0 1px 0 var(--attn-sheen), 0 8px 22px -14px var(--color-accent); } +/* The bell badge counts the whole inbox — needs-you, failures, review requests + and unseen outcomes together — so it is an aggregate, not a request. It used + to be amber, which is exactly how amber came to mean four things at once; + neutral-strong keeps it legible and leaves the meaning to the per-row tones. */ .attention-title-icon > span { position: absolute; top: -5px; @@ -170,10 +175,10 @@ align-items: center; justify-content: center; padding: 0 4px; - color: #1c1500; + color: var(--color-bg); border: 2px solid var(--color-bg); border-radius: 99px; - background: #fbbf24; + background: var(--color-fg); font-family: var(--font-mono); font-size: var(--attn-fs-2xs); font-weight: 750; @@ -475,12 +480,15 @@ white-space: nowrap; } +/* Jump-to-inbox affordance. Accent, not amber: the number behind it is the + whole inbox (failures and review requests included), and amber is reserved + for states where the user personally has to move. */ .attention-panel-heading button { flex: 0 0 auto; padding: 4px 7px; - color: var(--attn-warn); + color: var(--color-accent-bright, var(--color-accent)); border-radius: 6px; - background: color-mix(in srgb, var(--attn-warn) 12%, transparent); + background: color-mix(in srgb, var(--color-accent) 14%, transparent); font-size: var(--attn-fs-xs); font-weight: 600; white-space: nowrap; @@ -488,7 +496,7 @@ } .attention-panel-heading button:hover { - background: color-mix(in srgb, var(--attn-warn) 20%, transparent); + background: color-mix(in srgb, var(--color-accent) 22%, transparent); } .attention-roster-scroll { @@ -953,10 +961,14 @@ line-height: 1.45; } +/* "This machine is offline, you're reading last-known state" is true but not + actionable — the user cannot reconnect it from here. It was one of the five + things amber used to mean; it is neutral now, per the one-hue rule in + apps/desktop/src/shared/sessionStatusPresentation.ts. */ .attention-offline-banner { - color: var(--attn-warn); - border: 1px solid color-mix(in srgb, var(--attn-warn) 26%, transparent); - background: color-mix(in srgb, var(--attn-warn) 8%, transparent); + color: var(--color-muted-fg); + border: 1px solid color-mix(in srgb, var(--attn-idle) 30%, transparent); + background: color-mix(in srgb, var(--attn-idle) 10%, transparent); } .attention-ack-error { diff --git a/apps/desktop/src/renderer/components/attention/AttentionCenter.tsx b/apps/desktop/src/renderer/components/attention/AttentionCenter.tsx index 79f04d6a5..2a5f06212 100644 --- a/apps/desktop/src/renderer/components/attention/AttentionCenter.tsx +++ b/apps/desktop/src/renderer/components/attention/AttentionCenter.tsx @@ -1091,8 +1091,12 @@ export function AttentionCenter({ onAction, onOpenItem }: AttentionCenterProps = {visibleItems.length} item{visibleItems.length === 1 ? "" : "s"} {counts.inbox > 0 && view !== "inbox" ? ( + // "in inbox", not "needs you": this count is the whole inbox — + // failures, review requests and unseen outcomes as well as raised + // hands — and "needs you" is a claim only a needs_you row may + // make. The button jumps to the Inbox tab, which says the same. ) : null} diff --git a/apps/desktop/src/renderer/components/attention/HeaderAttentionControl.css b/apps/desktop/src/renderer/components/attention/HeaderAttentionControl.css index 1f3fd264c..019f9ab77 100644 --- a/apps/desktop/src/renderer/components/attention/HeaderAttentionControl.css +++ b/apps/desktop/src/renderer/components/attention/HeaderAttentionControl.css @@ -73,6 +73,14 @@ box-shadow: 0 0 0 1px color-mix(in srgb, var(--tone-color) 16%, transparent); } +/* The one amber in this file that is not a phase tone, and it earns it: a + degraded surface always carries an `availability.recovery` the user has to + perform (retry, sign in, update or restart the host). It is literally "your + move", which is the only meaning amber is allowed to carry — see the one-hue + rule in apps/desktop/src/shared/sessionStatusPresentation.ts. It cannot be + confused with a phase tone either: `data-state` is single-valued and + `degraded` outranks `waiting`, so the trigger paints this amber instead of — + never alongside — the leading bucket's colour. */ .attn-hdr-trigger[data-state="degraded"] { border-color: color-mix(in srgb, #f59e0b 42%, transparent); box-shadow: 0 0 0 1px color-mix(in srgb, #f59e0b 13%, transparent); diff --git a/apps/desktop/src/renderer/components/attention/HeaderAttentionControl.test.tsx b/apps/desktop/src/renderer/components/attention/HeaderAttentionControl.test.tsx index 931579765..ce44e6064 100644 --- a/apps/desktop/src/renderer/components/attention/HeaderAttentionControl.test.tsx +++ b/apps/desktop/src/renderer/components/attention/HeaderAttentionControl.test.tsx @@ -452,4 +452,67 @@ describe("header Attention summary", () => { .toBe("Attention · nothing waiting"); expect(summarizeAttentionForHeader({}, NOW).tone).toBe("neutral"); }); + + /** + * The header is the loudest surface ADE has, so amber there has to keep + * meaning exactly one thing. A run that merely finished is an outcome, not a + * request: it may collect in the badge, but it must not colour the bell. + */ + it("lights amber only for work that needs the user, and emerald for finished work", () => { + const doneOnly = summarizeAttentionForHeader( + byId([item("done", "completed"), item("merged", "merged", { kind: "pull_request" })]), + NOW, + ); + + expect(doneOnly.buckets.map((bucket) => bucket.id)).toEqual(["done"]); + expect(doneOnly.tone).toBe("emerald"); + expect(doneOnly.headline).toBe("2 done"); + + const raisedHand = summarizeAttentionForHeader( + byId([item("done", "completed"), item("asks", "needs_you")]), + NOW, + ); + + expect(raisedHand.tone).toBe("amber"); + expect(raisedHand.buckets.map((bucket) => bucket.id)).toEqual(["needs_you", "done"]); + expect(raisedHand.buckets.map((bucket) => bucket.tone)).toEqual(["amber", "emerald"]); + }); + + it("separates an outstanding PR review from work that is simply finished", () => { + const summary = summarizeAttentionForHeader( + byId([ + item("review", "review_requested", { kind: "pull_request" }), + item("done", "completed"), + ]), + NOW, + ); + + expect(summary.buckets.map((bucket) => bucket.id)).toEqual(["review", "done"]); + expect(summary.buckets.map((bucket) => bucket.tone)).toEqual(["violet", "emerald"]); + expect(attentionHeaderTriggerLabel(summary)).toBe("Attention · 1 to review · 1 done"); + }); + + /** + * Stale is a silence, not a signal. It used to share amber with a raised + * hand; it must now neither colour the header nor inflate the badge. + */ + it("keeps stale and already-open work out of every count", () => { + const summary = summarizeAttentionForHeader( + byId([ + item("quiet", "stale"), + item("open", "open", { kind: "pull_request" }), + item("closed", "closed", { kind: "pull_request" }), + ]), + NOW, + ); + + expect(summary.buckets).toEqual([]); + expect(summary.waitingCount).toBe(0); + expect(summary.liveCount).toBe(0); + expect(summary.tone).toBe("neutral"); + expect(summary.headline).toBe("All clear"); + // Still tracked — the full Attention center can show them; the header just + // stays quiet about them. + expect(summary.trackedCount).toBe(3); + }); }); diff --git a/apps/desktop/src/renderer/components/attention/attentionHeaderSummary.ts b/apps/desktop/src/renderer/components/attention/attentionHeaderSummary.ts index 8a50086f0..db6e2d606 100644 --- a/apps/desktop/src/renderer/components/attention/attentionHeaderSummary.ts +++ b/apps/desktop/src/renderer/components/attention/attentionHeaderSummary.ts @@ -11,7 +11,12 @@ import type { AttentionTone } from "./attentionPresentation"; * inflating the count. Everything the header claims is derived here, once, so * the trigger, its label, and the popover can never disagree. */ -export type AttentionHeaderBucketId = "needs_you" | "blocked" | "review" | "live"; +export type AttentionHeaderBucketId = + | "needs_you" + | "blocked" + | "review" + | "done" + | "live"; export type AttentionHeaderBucket = { id: AttentionHeaderBucketId; @@ -44,20 +49,31 @@ const BUCKET_ORDER: AttentionHeaderBucketId[] = [ "needs_you", "blocked", "review", + "done", "live", ]; const BUCKET_LABEL: Record = { needs_you: "Needs you", blocked: "Failing or blocked", - review: "Done, unreviewed", + review: "Waiting on review", + done: "Done, unreviewed", live: "Live now", }; +/** + * Amber appears exactly once in this table, on `needs_you`, and that is the + * whole point: the header is the loudest surface ADE has, so the hue that means + * "your move" must not be shared with anything else. `done` is emerald because + * a finished run you have not looked at yet is an outcome, not a request — + * previously it rode in the same violet bucket as an outstanding PR review, + * which made "go look" and "go review" one indistinguishable colour. + */ const BUCKET_TONE: Record = { needs_you: "amber", blocked: "red", review: "violet", + done: "emerald", live: "blue", }; @@ -65,6 +81,7 @@ function bucketSummary(id: AttentionHeaderBucketId, count: number): string { if (id === "needs_you") return `${count} need${count === 1 ? "s" : ""} you`; if (id === "blocked") return `${count} failing or blocked`; if (id === "review") return `${count} to review`; + if (id === "done") return `${count} done`; return `${count} live`; } @@ -78,6 +95,15 @@ function isExpired(item: AttentionItem, now: number): boolean { * Phases the header deliberately stays quiet about: `open`, `stale`, `closed`, * and outcomes the user already acknowledged. They are neither in motion nor * asking for anything, so they belong to the full Attention center. + * + * `stale` in particular is a silence, not a signal — it stays out of every + * bucket so it can never inflate the badge. The same holds for a session the + * user stopped: it never reaches the header at all, because a stopped run is an + * outcome the user chose. + * + * The loud tier is exactly `needs_you`. Nothing else may enter it — a resting + * or finished chat lands in `done`, never in the bucket that colours the header + * amber. */ export function attentionHeaderBucketFor( item: AttentionItem, @@ -96,7 +122,7 @@ export function attentionHeaderBucketFor( return "review"; case "completed": case "merged": - return item.seenAt ? null : "review"; + return item.seenAt ? null : "done"; case "starting": case "running": return "live"; diff --git a/apps/desktop/src/renderer/components/attention/attentionPresentation.test.ts b/apps/desktop/src/renderer/components/attention/attentionPresentation.test.ts new file mode 100644 index 000000000..a3af91fb3 --- /dev/null +++ b/apps/desktop/src/renderer/components/attention/attentionPresentation.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from "vitest"; + +import { attentionPhasePriority, type AttentionPhase } from "../../../shared/types"; +import type { CanonicalSessionPhase } from "../../../shared/sessionCanonicalState"; +import { sessionStatusPresentation } from "../../../shared/sessionStatusPresentation"; +import { + attentionPhaseIsSessionDerived, + attentionPhasePresentation, + attentionViewEmptyCopy, + SESSION_DERIVED_ATTENTION_PHASES, + type AttentionTone, +} from "./attentionPresentation"; + +/** + * Written as an exhaustive record rather than an array so a new `AttentionPhase` + * cannot slip past the amber guard below: adding one to the union without + * listing it here is a type error, not a silently unchecked phase. + */ +const ALL_ATTENTION_PHASES = Object.keys({ + starting: true, + running: true, + needs_you: true, + blocked: true, + failed: true, + completed: true, + stale: true, + checks_failing: true, + review_requested: true, + changes_requested: true, + merge_ready: true, + open: true, + merged: true, + closed: true, +} satisfies Record) as AttentionPhase[]; + +/** + * The only phase allowed to be amber, anywhere in Attention: a raised hand. + * `blocked` is deliberately absent — it means MERGE blocked (tier 2 in + * `attentionPhasePriority`, grouped under "Failing or blocked", never published + * for an agent session), which is not a claim on the reader. + */ +const AMBER_ALLOWLIST: AttentionPhase[] = ["needs_you"]; + +describe("attention phase presentation", () => { + it("speaks the sidebar's words for every session-derived phase", () => { + // Not a hand-copied table: each expectation is the phase's canonical + // counterpart, so a wording change in sessionStatusPresentation.ts either + // flows through both surfaces or fails here. + const bridge: Record = { + starting: "starting", + running: "running", + needs_you: "needs_you", + completed: "ready", + failed: "failed", + stale: "stale", + }; + + expect(SESSION_DERIVED_ATTENTION_PHASES.slice().sort()) + .toEqual(Object.keys(bridge).sort()); + + for (const [attentionPhase, canonicalPhase] of Object.entries(bridge)) { + const canonical = sessionStatusPresentation(canonicalPhase); + const attention = attentionPhasePresentation(attentionPhase as AttentionPhase); + expect(canonical).not.toBeNull(); + expect(attention.label).toBe(canonical?.label); + expect(attention.tone).toBe(canonical?.tone); + } + }); + + it("labels work in motion 'Working' and clean outcomes 'Done'", () => { + // The two renames the sidebar redesign turned on. Asserted literally as + // well as via the bridge above, because these exact words appear in + // notification copy and in the iOS mirror — a silent drift here desyncs + // three surfaces at once. + expect(attentionPhasePresentation("running")).toEqual({ + label: "Working", + tone: "blue", + active: true, + }); + expect(attentionPhasePresentation("completed")).toEqual({ + label: "Done", + tone: "emerald", + active: false, + }); + expect(attentionPhasePresentation("starting").label).toBe("Starting"); + expect(attentionPhasePresentation("needs_you").label).toBe("Needs you"); + expect(attentionPhasePresentation("failed").label).toBe("Failed"); + expect(attentionPhasePresentation("stale").label).toBe("Stale"); + }); + + /** + * The guard on the one-hue-one-meaning rule. Amber used to carry five + * unrelated meanings on a single row; the fix only holds if adding a sixth is + * loud. If this fails, do not extend the allowlist — find another hue, or + * decide the state genuinely is "your move" and say so in the test. + */ + it("spends amber only on states that need the user to act", () => { + const amberPhases = ALL_ATTENTION_PHASES.filter( + (phase) => attentionPhasePresentation(phase).tone === "amber", + ); + expect(amberPhases.sort()).toEqual(AMBER_ALLOWLIST.slice().sort()); + }); + + it("keeps every session-derived phase out of amber unless it is a raised hand", () => { + for (const phase of SESSION_DERIVED_ATTENTION_PHASES) { + if (phase === "needs_you") { + expect(attentionPhasePresentation(phase).tone).toBe("amber"); + continue; + } + expect(attentionPhasePresentation(phase).tone).not.toBe("amber"); + } + }); + + it("keeps the deliberate deviations from the old vocabulary", () => { + // `completed` is emerald, not amber: "finished, go look" must not wear the + // same colour as "blocked, go act". + expect(attentionPhasePresentation("completed").tone).toBe("emerald"); + // `stale` is neutral, not amber and not blue: a silent process is true but + // not actionable, and calling it live was the lie the old green dot told. + expect(attentionPhasePresentation("stale").tone).toBe("neutral"); + expect(attentionPhasePresentation("stale").active).toBe(false); + // `failed` keeps red, so red still means exactly one thing: it broke. + expect(attentionPhasePresentation("failed").tone).toBe("red"); + }); + + it("returns a presentation for every phase and pulses only live ones", () => { + const active = ALL_ATTENTION_PHASES.filter( + (phase) => attentionPhasePresentation(phase).active, + ); + expect(active.sort()).toEqual(["needs_you", "running", "starting"]); + for (const phase of ALL_ATTENTION_PHASES) { + expect(attentionPhasePresentation(phase).label.length).toBeGreaterThan(0); + } + }); + + it("knows which phases came from a session and which are pull-request only", () => { + expect(attentionPhaseIsSessionDerived("running")).toBe(true); + expect(attentionPhaseIsSessionDerived("completed")).toBe(true); + expect(attentionPhaseIsSessionDerived("merge_ready")).toBe(false); + expect(attentionPhaseIsSessionDerived("blocked")).toBe(false); + }); + + it("never colours a pull-request phase amber", () => { + const prTones: AttentionTone[] = ALL_ATTENTION_PHASES + .filter((phase) => !attentionPhaseIsSessionDerived(phase)) + .map((phase) => attentionPhasePresentation(phase).tone); + expect(prTones).not.toContain("amber"); + }); + + it("reads a merge-blocked pull request as neutral, not as a raised hand", () => { + // `blocked` is the phase most likely to be re-promoted to amber by someone + // reading the word alone. Its priority tier is the argument against that: + // it sits with review_requested and merge_ready, not with needs_you. + expect(attentionPhasePresentation("blocked")).toEqual({ + label: "Blocked", + tone: "neutral", + active: false, + }); + expect(attentionPhasePriority("blocked")).toBe(attentionPhasePriority("merge_ready")); + expect(attentionPhasePriority("blocked")).toBeGreaterThan( + attentionPhasePriority("needs_you"), + ); + }); + + it("uses the row's own word for finished work in empty-state copy", () => { + const recent = attentionViewEmptyCopy("recent"); + expect(recent.body).toContain("Done"); + expect(recent.body).not.toMatch(/completed/i); + }); +}); diff --git a/apps/desktop/src/renderer/components/attention/attentionPresentation.ts b/apps/desktop/src/renderer/components/attention/attentionPresentation.ts index 12e0f86a0..462503d51 100644 --- a/apps/desktop/src/renderer/components/attention/attentionPresentation.ts +++ b/apps/desktop/src/renderer/components/attention/attentionPresentation.ts @@ -1,5 +1,31 @@ import type { AttentionActionKind, AttentionPhase } from "../../../shared/types"; +import type { CanonicalSessionPhase } from "../../../shared/sessionCanonicalState"; +import { + sessionStatusPresentation, + type SessionStatusPresentation, +} from "../../../shared/sessionStatusPresentation"; +/** + * Attention's tone vocabulary is `sessionStatusPresentation`'s five hues plus + * two that only pull requests ever use. The session five keep their meanings + * exactly — see the one-hue-one-meaning rule in + * `apps/desktop/src/shared/sessionStatusPresentation.ts`: + * + * blue work is happening, nothing is asked of you + * amber YOUR MOVE — and nothing else, ever + * emerald finished cleanly, you have not looked yet + * red it broke + * neutral true, but not actionable + * + * Exactly one phase in this module is amber: `needs_you`. + * + * `violet` carries "a human review is outstanding" — neither "your move" (it is + * usually someone else's) nor an outcome, and without its own hue it would have + * to borrow amber, which is precisely the erosion the rule forbids. `cyan` is + * currently unused by any phase; it stays in the union and the stylesheets as + * the spare for the next PR-side distinction, and must never be handed to a + * session state — those five hues are settled. + */ export type AttentionTone = | "amber" | "red" @@ -9,17 +35,120 @@ export type AttentionTone = | "emerald" | "neutral"; -const PHASE_PRESENTATION: Record< - AttentionPhase, - { label: string; tone: AttentionTone; active: boolean } +export type AttentionPhasePresentation = { + label: string; + tone: AttentionTone; + /** + * Liveness, not prominence: drives the pulsing dot on a row whose machine is + * online. Deliberately NOT the same axis as `SessionStatusPresentation.prominent` + * (which asks "should this pull the eye"), because a finished run is prominent + * but must not pulse, and a starting run pulses but must not shout. + */ + active: boolean; +}; + +/** + * `AttentionPhase` is strictly broader than `CanonicalSessionPhase`: it also + * carries the pull-request lifecycle. The overlap — everything a running agent + * session can be — is bridged here rather than re-tabulated, so the attention + * center, the header rollup, and the Work sidebar cannot drift into three + * different words for the same state. `sessionStatusPresentation.ts` is the + * authority; this table only says which canonical phase each attention phase + * stands for. + * + * `completed` maps to `ready` because the push wire format collapses + * `ready`/`idle` into one terminal-but-clean phase, and both read "Done". + */ +const SESSION_PHASE_BY_ATTENTION_PHASE = { + starting: "starting", + running: "running", + needs_you: "needs_you", + completed: "ready", + failed: "failed", + stale: "stale", +} as const satisfies Partial>; + +export type SessionDerivedAttentionPhase = keyof typeof SESSION_PHASE_BY_ATTENTION_PHASE; + +/** + * Every session-derived attention phase, exported so the regression test that + * guards the one-hue rule enumerates the real list instead of a copy that can + * silently fall behind. + */ +export const SESSION_DERIVED_ATTENTION_PHASES = Object.keys( + SESSION_PHASE_BY_ATTENTION_PHASE, +) as SessionDerivedAttentionPhase[]; + +/** + * Only these pulse: a session that is in motion or is actively holding for the + * user. Everything else is a resting state, and a pulsing dot on a resting row + * is the progress-report version of the same lie amber used to tell. + */ +const ACTIVE_PHASES = new Set(["starting", "running", "needs_you"]); + +/** + * `sessionStatusPresentation` returns null only for `settled`, which has no + * attention-phase counterpart and therefore never appears in the bridge table + * above. The fallback exists so this module stays total rather than throwing + * during module init — and it is deliberately the quietest presentation there + * is, because a fallback that could manufacture amber would defeat the rule it + * is meant to protect. + */ +function requireSessionPresentation(phase: CanonicalSessionPhase): SessionStatusPresentation { + return ( + sessionStatusPresentation(phase) + ?? { label: "Stale", tone: "neutral", glyph: "stale", showsElapsed: true, prominent: false } + ); +} + +function sessionDerivedPresentation( + phase: SessionDerivedAttentionPhase, +): AttentionPhasePresentation { + const presentation = requireSessionPresentation(SESSION_PHASE_BY_ATTENTION_PHASE[phase]); + // This annotated assignment is what enforces `SessionStatusTone ⊆ AttentionTone` + // at compile time: add a hue to the session vocabulary that Attention has no + // colour for and this line stops compiling, rather than silently rendering an + // unstyled tone class. + const tone: AttentionTone = presentation.tone; + return { label: presentation.label, tone, active: ACTIVE_PHASES.has(phase) }; +} + +const SESSION_DERIVED_PRESENTATION = Object.fromEntries( + SESSION_DERIVED_ATTENTION_PHASES.map((phase) => [phase, sessionDerivedPresentation(phase)]), +) as Record; + +/** + * Phases with no session counterpart — the pull-request lifecycle. Amber does + * not appear here at all, which leaves `needs_you` as the only amber in the + * whole module. + * + * `blocked` is the one that looks like it should be amber and is not, and the + * sharpest fact about it is that NOTHING EMITS IT AT ALL. The sole publisher, + * `apps/ade-cli/src/services/push/pushPublisherService.ts`, maps phases through + * two closed, exhaustive functions — `agentAttentionPhase()` and + * `prAttentionState()` — and neither can produce `blocked` on either side. It + * has no `AttentionEventKind` either. It entered the union speculatively and its + * only occurrence in the repo is a test fixture; it is a deletion candidate, + * left in place only because removing it would change the push wire contract. + * + * So its tone is decided by what the word means everywhere else in the product: + * MERGE blocked, not "your move". `attentionPhasePriority` files it at tier 2 + * beside `review_requested` and `merge_ready` rather than with `needs_you` at + * tier 0, and both the header rollup and the `ade code` TUI group it under + * "Failing or blocked" — a different group from "Needs you". A PR blocked on + * branch policy, CI, or someone else's approval is frequently something the + * reader cannot clear at all, so it makes no claim on them. + * + * NOTE: `attentionHeaderSummary.ts` still files `blocked` into the red + * "Failing or blocked" bucket. That disagreement with the neutral tone here is + * known and deliberately left for now — reconciling a phase that has no + * producer would be two speculative changes instead of one documented one. + */ +const NON_SESSION_PRESENTATION: Record< + Exclude, + AttentionPhasePresentation > = { - starting: { label: "Starting", tone: "blue", active: true }, - running: { label: "Working", tone: "blue", active: true }, - needs_you: { label: "Needs you", tone: "amber", active: true }, - blocked: { label: "Blocked", tone: "amber", active: true }, - failed: { label: "Failed", tone: "red", active: false }, - completed: { label: "Completed", tone: "emerald", active: false }, - stale: { label: "Stale", tone: "neutral", active: false }, + blocked: { label: "Blocked", tone: "neutral", active: false }, checks_failing: { label: "Checks failing", tone: "red", active: false }, review_requested: { label: "Review requested", tone: "violet", active: false }, changes_requested: { label: "Changes requested", tone: "red", active: false }, @@ -29,10 +158,21 @@ const PHASE_PRESENTATION: Record< closed: { label: "Closed", tone: "neutral", active: false }, }; -export function attentionPhasePresentation(phase: AttentionPhase) { +const PHASE_PRESENTATION: Record = { + ...SESSION_DERIVED_PRESENTATION, + ...NON_SESSION_PRESENTATION, +}; + +export function attentionPhasePresentation(phase: AttentionPhase): AttentionPhasePresentation { return PHASE_PRESENTATION[phase]; } +export function attentionPhaseIsSessionDerived( + phase: AttentionPhase, +): phase is SessionDerivedAttentionPhase { + return phase in SESSION_PHASE_BY_ATTENTION_PHASE; +} + export function attentionActionTone( kind: AttentionActionKind, ): "primary" | "danger" | "secondary" | "ghost" { @@ -54,8 +194,11 @@ export function attentionViewEmptyCopy(view: "live" | "inbox" | "recent"): { } if (view === "recent") { return { + // "Done" rather than "Completed": the pill on these rows says Done, and + // prose that uses a different word for the same state is how a vocabulary + // starts to fray. title: "No recent outcomes", - body: "Completed and resolved work stays here for 24 hours after you review it.", + body: "Done and resolved work stays here for 24 hours after you review it.", }; } return { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 94299c81c..0ae039271 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -103,6 +103,7 @@ import { filterChatModelIdsForSession } from "../../../shared/chatModelSwitching import { CURSOR_AVAILABLE_MODE_IDS } from "../../../shared/cursorModes"; import { cn } from "../ui/cn"; import { AgentChatComposer, type ParallelComposerControlSlot } from "./AgentChatComposer"; +import { ChatLifecycleBanner } from "./ChatLifecycleBanner"; import { resolveModelDescriptorWithRuntimeCatalog, descriptorsFromAgentChatModelCatalog } from "../shared/ModelPicker/modelCatalog"; import { latestContextUsageInput, toUsageViewModel, type ContextUsageViewModel } from "./usage/contextUsageModel"; import { getSharedRuntimeCatalog } from "../shared/ModelPicker/runtimeCatalogCache"; @@ -11650,6 +11651,7 @@ export function AgentChatPane({ onToggleToolsPane={onToggleToolsPane} toolsPaneOpen={toolsPaneOpen} className="space-y-0 p-0" + testId="work-chat-session-header" /> {!lockSessionId && !hideSessionTabs ? ( @@ -11781,6 +11783,13 @@ export function AgentChatPane({ /> ) : null; + // Settled/snoozed notice pinned above the composer. The header chips say WHAT + // the state is; this says what sending will do about it, where the eye already + // is while typing. Renders null for a live chat, so nothing below it moves. + const lifecycleBanner = selectedSessionId ? ( + + ) : null; + const composerMachineBinding = activeComposerRuntimeBinding; const composerMachineId = selectedSessionId ? (composerMachineBinding?.kind === "remote" ? composerMachineBinding.targetId : "this-mac") @@ -12430,6 +12439,7 @@ export function AgentChatPane({ {authStickyBar} {awayDigestStrip} + {lifecycleBanner} {composerElement} ); @@ -12878,6 +12888,7 @@ export function AgentChatPane({ {authStickyBar} {awayDigestStrip} + {lifecycleBanner} {composerElement} ) : null} diff --git a/apps/desktop/src/renderer/components/chat/ChatLifecycleBanner.test.tsx b/apps/desktop/src/renderer/components/chat/ChatLifecycleBanner.test.tsx new file mode 100644 index 000000000..efd1310a9 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/ChatLifecycleBanner.test.tsx @@ -0,0 +1,188 @@ +/* @vitest-environment jsdom */ + +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 { ChatLifecycleBanner } from "./ChatLifecycleBanner"; + +vi.mock("../app/toast/toastStore", () => ({ + showToast: vi.fn(), +})); + +const PROJECT_ROOT = "/tmp/project"; + +function makeSession(overrides: Partial = {}): TerminalSessionSummary { + return { + id: "session-1", + laneId: "lane-1", + laneName: "Lane 1", + ptyId: null, + tracked: true, + pinned: false, + goal: null, + toolType: "codex-chat", + title: "Codex chat", + status: "running", + startedAt: "2026-07-09T10:00:00.000Z", + endedAt: null, + exitCode: null, + transcriptPath: "", + headShaStart: null, + headShaEnd: null, + lastOutputPreview: null, + summary: null, + runtimeState: "idle", + resumeCommand: null, + ...overrides, + }; +} + +/** A session with a declared settle at rest — the only way to reach `settled`. */ +function settledOverrides(): Partial { + return { + status: "completed", + runtimeState: "exited", + endedAt: "2026-07-09T11:00:00.000Z", + settledAt: "2026-07-09T11:01:00.000Z", + }; +} + +function snoozedOverrides(): Partial { + return { + snoozedAt: new Date(Date.now() - 60_000).toISOString(), + snoozedUntil: new Date(Date.now() + 3 * 3_600_000).toISOString(), + }; +} + +function seedSessions(sessions: TerminalSessionSummary[]): void { + useAppStore.setState({ + project: { rootPath: PROJECT_ROOT } as never, + projectBinding: null, + sessionsCacheByProject: { [PROJECT_ROOT]: sessions }, + }); +} + +describe("ChatLifecycleBanner", () => { + let sessionsApi: Record>; + + beforeEach(() => { + sessionsApi = { + wakeSession: vi.fn().mockResolvedValue(true), + unsettle: vi.fn().mockResolvedValue(undefined), + unsettleMany: vi.fn().mockResolvedValue(undefined), + setSettleOverride: vi.fn().mockResolvedValue(true), + }; + Object.defineProperty(window, "ade", { + configurable: true, + value: { sessions: sessionsApi }, + }); + }); + + afterEach(() => { + cleanup(); + useAppStore.setState({ sessionsCacheByProject: {} }); + Reflect.deleteProperty(window, "ade"); + vi.clearAllMocks(); + }); + + it("renders nothing for a live chat, so the composer never moves", () => { + seedSessions([makeSession()]); + const { container } = render(); + // Not an empty placeholder box: literally no node. + expect(container.firstChild).toBeNull(); + }); + + it("renders the settled variant with copy that matches ADE's settle semantics", () => { + 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"); + // Emerald means "finished cleanly"; amber is reserved for "your move". + expect(banner.className).toContain("emerald"); + expect(banner.className).not.toContain("amber"); + }); + + it("renders the snoozed variant naming when it comes back, in neutral chrome", () => { + seedSessions([makeSession(snoozedOverrides())]); + render(); + + 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"); + // Snooze is a visibility overlay, so it gets neither emerald nor amber. + expect(banner.className).not.toContain("emerald"); + expect(banner.className).not.toContain("amber"); + }); + + it("names the open-ended wake condition rather than a ~100-year date", () => { + seedSessions([makeSession({ + snoozedAt: new Date(Date.now() - 60_000).toISOString(), + snoozedUntil: new Date(Date.now() + 100 * 365 * 24 * 3_600_000).toISOString(), + })]); + render(); + + expect(screen.getByTestId("chat-lifecycle-banner").textContent).toContain( + "until when you're asked", + ); + }); + + it("gives Un-settle and Wake now the same fill on hover and on keyboard focus", () => { + // The one control that undoes the state the banner describes, so it has to + // read as pressable for pointer AND keyboard users — focus-visible mirrors + // hover rather than relying on a default outline. + 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]"); + + cleanup(); + seedSessions([makeSession(snoozedOverrides())]); + render(); + const snoozedButton = screen.getByTestId("chat-lifecycle-wake"); + expect(snoozedButton.className).toContain("hover:bg-white/[0.07]"); + expect(snoozedButton.className).toContain("focus-visible:bg-white/[0.07]"); + }); + + it("lets snooze win when a chat is both snoozed and settled", () => { + // Matches the overlay precedence in `sessionStatusPresentation`: the overlay + // is resolved above the phase. + seedSessions([makeSession({ ...settledOverrides(), ...snoozedOverrides() })]); + render(); + + expect(screen.getByTestId("chat-lifecycle-banner").getAttribute("data-lifecycle-variant")) + .toBe("snoozed"); + expect(screen.getByTestId("chat-lifecycle-wake")).toBeTruthy(); + expect(screen.queryByTestId("chat-lifecycle-unsettle")).toBeNull(); + }); + + it("clears the settle through the pin-aware single-session write", async () => { + seedSessions([makeSession(settledOverrides())]); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Un-settle" })); + + await waitFor(() => expect(sessionsApi.unsettle).toHaveBeenCalledWith("session-1")); + // `unsettleMany` exists but ignores the runtime pin and reports no failure; + // the shared lifecycle action is the one both the sidebar and the header + // chip use. + expect(sessionsApi.unsettleMany).not.toHaveBeenCalled(); + expect(sessionsApi.setSettleOverride).not.toHaveBeenCalled(); + }); + + it("wakes a snoozed chat with the manual reason", async () => { + seedSessions([makeSession(snoozedOverrides())]); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Wake now" })); + + await waitFor(() => expect(sessionsApi.wakeSession).toHaveBeenCalledWith("session-1", "manual")); + }); +}); diff --git a/apps/desktop/src/renderer/components/chat/ChatLifecycleBanner.tsx b/apps/desktop/src/renderer/components/chat/ChatLifecycleBanner.tsx new file mode 100644 index 000000000..bde3c499f --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/ChatLifecycleBanner.tsx @@ -0,0 +1,160 @@ +import { CheckCircle, Moon } from "@phosphor-icons/react"; + +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"; +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"; + +type LifecycleVariant = "settled" | "snoozed"; + +const VARIANT_CHROME: Record` does not match its + // ForwardRef/propTypes shape. + icon: typeof CheckCircle; +}> = { + // Emerald = "finished cleanly, you have not looked yet" — the same hue the + // sidebar spends on Done, so a settled chat reads as an outcome, not a warning. + settled: { + card: "border-emerald-400/16 bg-emerald-400/[0.05]", + iconTile: "border-emerald-400/18 bg-emerald-400/[0.08] text-emerald-200", + title: "text-emerald-50/90", + // focus-visible mirrors hover exactly: keyboard users never see hover, and + // this button is the only way out of the state the banner describes. + button: + "border-emerald-300/22 bg-emerald-400/[0.07] text-emerald-50/80 hover:border-emerald-200/38 hover:bg-emerald-400/[0.13] focus-visible:border-emerald-200/38 focus-visible:bg-emerald-400/[0.13]", + icon: CheckCircle, + }, + // Neutral on purpose: snooze hides a row, it does not change what the row IS. + // Giving it a hue would claim a lifecycle change that never happened. + snoozed: { + card: "border-white/[0.09] bg-white/[0.028]", + iconTile: "border-white/[0.10] bg-white/[0.05] text-muted-fg/80", + title: "text-fg/85", + button: + "border-white/[0.10] bg-white/[0.035] text-fg/70 hover:border-white/[0.2] hover:bg-white/[0.07] hover:text-fg/90 focus-visible:border-white/[0.2] focus-visible:bg-white/[0.07] focus-visible:text-fg/90", + icon: Moon, + }, +}; + +/** + * "Hidden until " line for a snoozed chat. The return ticket is the whole + * story of a snoozed row, so the deadline goes in the sentence rather than a + * bare "Snoozed". + * + * `snoozeWakeDescription` returns `"now"` for a lapsed deadline; that is + * unreachable here (an expired snooze is no longer `isSessionSnoozed`) but + * "until now" would be nonsense if a render ever straddled the deadline, so it + * falls back with the null case. + */ +function snoozeLine(snoozedUntil: string | null | undefined, nowMs?: number): string { + const when = snoozeWakeDescription(snoozedUntil, nowMs); + const until = !when || when === "now" ? "it wakes" : when; + return `Hidden from the sidebar until ${until}. Snooze only hides the row — this chat keeps running.`; +} + +export function ChatLifecycleBanner({ + sessionId, + className, +}: { + sessionId: string | null | undefined; + className?: string; +}) { + const session = useSessionLifecycleSnapshot(sessionId); + if (!session) return null; + + const snoozed = isSessionSnoozed(session); + const settled = sessionCanonicalUiState(canonicalInputFromSummary(session)).phase === "settled"; + if (!snoozed && !settled) return null; + + // Snooze outranks the phase, matching the overlay precedence in + // `sessionStatusPresentation`. (That module's `needs_you` carve-out is + // deliberately not mirrored: a raised hand already owns the composer itself + // via the pending-input card, so it cannot be buried by this banner.) + const variant: LifecycleVariant = snoozed ? "snoozed" : "settled"; + const chrome = VARIANT_CHROME[variant]; + const Icon = chrome.icon; + + const title = snoozed ? "This chat is snoozed" : "This chat is settled"; + const line = snoozed + ? snoozeLine(session.snoozedUntil) + // Truthful for ADE, not borrowed copy: `sessionCanonicalState` clears + // `settledAt` at the write site on real activity (user turn start / PTY + // output), so sending is literally what un-settles it. + : "Sending a message clears the settle and moves this chat back to Active in the sidebar."; + const actionLabel = snoozed ? "Wake now" : "Un-settle"; + + return ( +
    +
    + +
    +
    +
    + {title} +
    +
    + {line} +
    +
    + +
    + ); +} diff --git a/apps/desktop/src/renderer/components/chat/ChatSurfaceShell.tsx b/apps/desktop/src/renderer/components/chat/ChatSurfaceShell.tsx index b98a51d77..86f88fb1c 100644 --- a/apps/desktop/src/renderer/components/chat/ChatSurfaceShell.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatSurfaceShell.tsx @@ -7,8 +7,8 @@ import { chatSurfaceVars } from "./chatSurfaceTheme"; export type ChatSurfaceShellLayoutVariant = "standard" | "mobile"; -/** Shared padding for chat shell headers (title row, git toolbar, actions). */ -export const CHAT_SHELL_HEADER_CLASS = "space-y-1 px-2 py-1"; +/** Canonical 32px chat title rail, matched by CLI and the sessions toolbar. */ +export const CHAT_SHELL_HEADER_CLASS = "h-8 px-2"; export function ChatSurfaceShell({ mode, diff --git a/apps/desktop/src/renderer/components/lanes/LaneContextMenu.test.tsx b/apps/desktop/src/renderer/components/lanes/LaneContextMenu.test.tsx new file mode 100644 index 000000000..ba836dccd --- /dev/null +++ b/apps/desktop/src/renderer/components/lanes/LaneContextMenu.test.tsx @@ -0,0 +1,136 @@ +/* @vitest-environment jsdom */ + +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { LaneSummary } from "../../../shared/types"; +import { SUBMENU_OPEN_DELAY_MS } from "../ui/MenuSubmenu"; +import { LaneContextMenu } from "./LaneContextMenu"; + +let isRemoteProject = false; + +vi.mock("../../state/appStore", async () => { + const actual = await vi.importActual("../../state/appStore"); + return { + ...actual, + useAppStore: (selector: (state: Record) => unknown) => + selector({ projectBinding: { kind: isRemoteProject ? "remote" : "local" } }), + }; +}); + +const lane = { + id: "lane-1", + name: "Lane One", + laneType: "worktree", + baseRef: "main", + branchRef: "refs/heads/lane-one", + worktreePath: "/tmp/lane-one", + parentLaneId: null, + childCount: 0, + stackDepth: 0, + parentStatus: null, + isEditProtected: false, + status: { dirty: false, ahead: 0, behind: 0, remoteBehind: 0, rebaseInProgress: false }, + color: null, + icon: null, + tags: [], + createdAt: "2026-07-10T10:00:00.000Z", +} satisfies LaneSummary; + +const writeClipboardText = vi.fn().mockResolvedValue(undefined); +const revealPath = vi.fn().mockResolvedValue(undefined); + +function renderLaneMenu(overrides: Partial[0]> = {}) { + const props = { + laneContextMenu: { laneId: lane.id, x: 10, y: 10 }, + lanesById: new Map([[lane.id, lane as LaneSummary]]), + visibleLaneIds: [lane.id], + onClose: vi.fn(), + onAdoptAttached: vi.fn(), + onManage: vi.fn(), + selectLane: vi.fn(), + onRemoveFromSplit: vi.fn(), + onCloseOtherSplits: vi.fn(), + onSelectAll: vi.fn(), + onBatchManage: vi.fn(), + onStartChatInLane: vi.fn(), + onToggleWorkPin: vi.fn(), + workPinnedLaneIds: [] as string[], + ...overrides, + }; + render(); + return props; +} + +function openSubmenu(trigger: Element) { + fireEvent.pointerOver(trigger); + act(() => { vi.advanceTimersByTime(SUBMENU_OPEN_DELAY_MS + 10); }); +} + +beforeEach(() => { + vi.useFakeTimers(); + isRemoteProject = false; + (window as unknown as { ade: unknown }).ade = { + app: { writeClipboardText, revealPath }, + prs: { getForLane: vi.fn().mockResolvedValue(null) }, + github: { getRemoteStatus: vi.fn().mockResolvedValue({ repo: null }) }, + lanes: { updateAppearance: vi.fn().mockResolvedValue(undefined) }, + }; +}); + +afterEach(() => { + vi.useRealTimers(); + cleanup(); + vi.clearAllMocks(); + delete (window as unknown as { ade?: unknown }).ade; +}); + +describe("LaneContextMenu grouping", () => { + it("labels its sections and keeps the primary actions on the top level", () => { + renderLaneMenu(); + + for (const label of ["Go to", "Copy", "Tabs", "Color", "Manage"]) { + expect(screen.getByText(label)).toBeTruthy(); + } + // The two actions the Work sidebar's singleton row depends on are never + // buried in a submenu. + expect(screen.getByRole("menuitem", { name: "Start chat in lane" })).toBeTruthy(); + expect(screen.getByRole("menuitem", { name: "Manage Lane" })).toBeTruthy(); + }); + + it("keeps every clipboard action reachable behind the Copy submenu", () => { + const props = renderLaneMenu(); + + // Collapsed, not dropped: the four copy rows were the biggest block in the + // flat menu and the one nobody read. + expect(screen.queryByRole("menuitem", { name: "Copy Path" })).toBeNull(); + openSubmenu(screen.getByRole("menuitem", { name: "Copy" })); + + for (const name of ["Copy ADE Lane Link", "Copy Branch Link (Cross-Machine)", "Copy Path"]) { + expect(screen.getByRole("menuitem", { name })).toBeTruthy(); + } + fireEvent.click(screen.getByRole("menuitem", { name: "Copy Path" })); + expect(props.onClose).toHaveBeenCalledTimes(1); + expect(writeClipboardText).toHaveBeenCalledWith("/tmp/lane-one"); + }); + + it("files a remote project's path copy under Copy and drops the reveal row", () => { + // Nothing local to reveal on a remote project, so the same button that + // would reveal becomes the only way to get at the path. + isRemoteProject = true; + renderLaneMenu(); + + expect(screen.queryByRole("menuitem", { name: /^Reveal in/ })).toBeNull(); + openSubmenu(screen.getByRole("menuitem", { name: "Copy" })); + fireEvent.click(screen.getByRole("menuitem", { name: "Copy Remote Path" })); + expect(writeClipboardText).toHaveBeenCalledWith("/tmp/lane-one"); + expect(revealPath).not.toHaveBeenCalled(); + }); + + it("shows the split rows once the lane is one of several open tabs", () => { + renderLaneMenu({ visibleLaneIds: [lane.id, "lane-2"] }); + + expect(screen.getByText("2 tabs open")).toBeTruthy(); + expect(screen.getByRole("menuitem", { name: "Remove from Split" })).toBeTruthy(); + expect(screen.getByRole("menuitem", { name: "Close Other Tabs" })).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/renderer/components/lanes/LaneContextMenu.tsx b/apps/desktop/src/renderer/components/lanes/LaneContextMenu.tsx index b7c0acda6..a8589d4f6 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneContextMenu.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneContextMenu.tsx @@ -1,27 +1,15 @@ import React from "react"; import type { LaneSummary } from "../../../shared/types"; import { useClampedFixedPosition } from "../../hooks/useClampedFixedPosition"; -import { revealLabel } from "../../lib/platform"; import { useAppStore } from "../../state/appStore"; +import { MenuSubmenu } from "../ui/MenuSubmenu"; import { COLORS, MONO_FONT } from "./laneDesignTokens"; -import { LANE_CLASSIC_COLORS, LANE_RAINBOW_COLORS, colorsInUse, type LaneColor } from "./laneColorPalette"; -import { buildDeeplink } from "../../../shared/deeplinks"; -import { buildWebClientUrl } from "../../../shared/webClientUrl"; -import { openExternalUrl } from "../../lib/openExternal"; - -function branchNameFromRef(ref: string | null | undefined): string { - if (!ref) return ""; - return ref.replace(/^refs\/heads\//, ""); -} - -async function fetchRepoForCopy(): Promise<{ owner: string; name: string } | null> { - try { - const status = await window.ade.github.getRemoteStatus(); - return status.repo ?? null; - } catch { - return null; - } -} +import { + buildLaneMenuGroups, + laneMenuHeaderStyle, + type LaneMenuArgs, + type LaneMenuGroup, +} from "./laneContextMenuItems"; export const menuItemStyle: React.CSSProperties = { display: "block", @@ -37,14 +25,12 @@ export const menuItemStyle: React.CSSProperties = { transition: "background 100ms", }; -const menuHeaderStyle: React.CSSProperties = { - padding: "5px 14px 3px", - fontSize: 9, - fontFamily: MONO_FONT, - fontWeight: 700, - letterSpacing: "0.12em", - textTransform: "uppercase", - color: COLORS.textDim, +/** Same row, laid out for a trailing chevron instead of a plain label. */ +const submenuTriggerStyle: React.CSSProperties = { + ...menuItemStyle, + display: "flex", + alignItems: "center", + gap: 8, }; export function HoverButton({ @@ -78,6 +64,68 @@ export function HoverButton({ ); } +/** + * Renders one grouped block of the lane menu. Shared by the lane divider's menu + * and by the "Lane ▸" submenu on a singleton lane's session card, so both read + * the same groups out of `buildLaneMenuGroups`. + */ +export function LaneMenuGroups({ groups }: { groups: LaneMenuGroup[] }) { + return ( + <> + {groups.map((group, index) => ( + + {index > 0 ? ( +
    + ) : null} + {group.submenu ? ( + + {group.entries.map((entry) => ( + entry.kind === "custom" + ? {entry.node} + : ( + + {entry.label} + + ) + ))} + + ) : ( + <> + {group.label ?
    {group.label}
    : null} + {group.entries.map((entry) => ( + entry.kind === "custom" + ? {entry.node} + : ( + + {entry.label} + + ) + ))} + + )} + + ))} + + ); +} + export function LaneContextMenu({ laneContextMenu, lanesById, @@ -114,13 +162,6 @@ export function LaneContextMenu({ }) { const isRemoteProject = useAppStore((s) => s.projectBinding?.kind === "remote"); const ctxLane = lanesById.get(laneContextMenu.laneId) ?? null; - const isInSplit = visibleLaneIds.includes(laneContextMenu.laneId); - const splitCount = visibleLaneIds.length; - const isPrimary = ctxLane?.laneType === "primary"; - const deletableVisibleIds = visibleLaneIds.filter((id) => { - const lane = lanesById.get(id); - return lane && lane.laneType !== "primary"; - }); const { ref: menuRef, position: menuPosition } = useClampedFixedPosition( { x: laneContextMenu.x, y: laneContextMenu.y }, @@ -142,6 +183,26 @@ export function LaneContextMenu({ menuRef.current?.focus(); }, []); + const args: LaneMenuArgs = { + laneId: laneContextMenu.laneId, + lane: ctxLane, + lanesById, + visibleLaneIds, + isRemoteProject, + onClose, + onAdoptAttached, + onManage, + selectLane, + onRemoveFromSplit, + onCloseOtherSplits, + onSelectAll, + onBatchManage, + ...(onAppearanceChanged ? { onAppearanceChanged } : {}), + ...(onStartChatInLane ? { onStartChatInLane } : {}), + ...(onToggleWorkPin ? { onToggleWorkPin } : {}), + ...(workPinnedLaneIds ? { workPinnedLaneIds } : {}), + }; + return (
    e.stopPropagation()} > - {ctxLane?.worktreePath ? ( - <> - { - onClose(); - if (isRemoteProject) { - window.ade.app.writeClipboardText(ctxLane.worktreePath).catch(() => {}); - return; - } - window.ade.app.revealPath(ctxLane.worktreePath).catch(() => {}); - }} - > - {isRemoteProject ? "Copy Remote Path" : revealLabel} - - {!isRemoteProject ? ( - { - onClose(); - window.ade.app.writeClipboardText(ctxLane.worktreePath).catch(() => {}); - }} - > - Copy Path - - ) : null} - - ) : null} - - {ctxLane ? ( - <> -
    - { - onClose(); - void (async () => { - const repo = await fetchRepoForCopy(); - const pr = await window.ade.prs.getForLane(ctxLane.id).catch(() => null); - const branch = branchNameFromRef(ctxLane.branchRef); - const url = buildDeeplink( - { - kind: "lane", - laneId: laneContextMenu.laneId, - ...(repo - ? { - envelope: { - repoOwner: repo.owner, - repoName: repo.name, - ...(branch ? { branch } : {}), - ...(pr?.githubPrNumber ? { prNumber: pr.githubPrNumber } : {}), - ...(ctxLane.linearIssue?.identifier ? { linearIssue: ctxLane.linearIssue.identifier } : {}), - }, - } - : {}), - }, - { form: "ade" }, - ); - await window.ade.app.writeClipboardText(url).catch(() => {}); - })(); - }} - > - Copy ADE Lane Link - - { - onClose(); - openExternalUrl(buildWebClientUrl({ kind: "lane", laneId: laneContextMenu.laneId })); - }} - > - Open in web - - {branchNameFromRef(ctxLane.branchRef) ? ( - { - onClose(); - const branch = branchNameFromRef(ctxLane.branchRef); - void fetchRepoForCopy().then((repo) => { - if (!repo) { - // No GitHub origin — fall back to lane link as the best we can offer. - const fallback = buildDeeplink( - { kind: "lane", laneId: laneContextMenu.laneId }, - { form: "ade" }, - ); - return window.ade.app.writeClipboardText(fallback).catch(() => {}); - } - const url = buildDeeplink({ - kind: "branch", - repoOwner: repo.owner, - repoName: repo.name, - branch, - }); - return window.ade.app.writeClipboardText(url).catch(() => {}); - }); - }} - > - Copy Branch Link (Cross-Machine) - - ) : null} - {ctxLane.linearIssue?.url ? ( - { - const linearUrl = ctxLane.linearIssue?.url; - onClose(); - if (!linearUrl) return; - window.ade.app.writeClipboardText(linearUrl).catch(() => {}); - }} - > - Copy Linear Issue Link - - ) : null} - - ) : null} - - {ctxLane && onStartChatInLane ? ( - <> -
    - { - const ctxLaneId = laneContextMenu.laneId; - onClose(); - onStartChatInLane(ctxLaneId); - }} - > - Start chat in lane - - - ) : null} - - {ctxLane && onToggleWorkPin ? ( - { - const ctxLaneId = laneContextMenu.laneId; - onClose(); - onToggleWorkPin(ctxLaneId); - }} - > - {workPinnedLaneIds?.includes(laneContextMenu.laneId) - ? "Unpin from Work sidebar" - : "Pin to Work sidebar"} - - ) : null} - - {ctxLane && !isPrimary ? ( - <> -
    - {ctxLane.laneType === "attached" ? ( - { - const ctxLaneId = laneContextMenu.laneId; - onClose(); - selectLane(ctxLaneId); - onAdoptAttached(ctxLaneId); - }} - > - Move To .ade/worktrees - - ) : null} - { - const ctxLaneId = laneContextMenu.laneId; - onClose(); - selectLane(ctxLaneId); - onManage(ctxLaneId); - }} - > - Manage Lane - - - ) : null} - - {ctxLane ? ( - <> -
    -
    Color
    - - - ) : null} - - {/* ── Split / multi-tab actions ── */} - {splitCount > 1 || !isInSplit ? ( - <> -
    -
    - {splitCount > 1 ? `${splitCount} tabs open` : "Tabs"} -
    - - ) : null} - - {!isInSplit ? ( - { - onClose(); - selectLane(laneContextMenu.laneId); - }} - > - Open in Split - - ) : null} - - {isInSplit && splitCount > 1 && !isPrimary ? ( - { - onClose(); - onRemoveFromSplit(laneContextMenu.laneId); - }} - > - Remove from Split - - ) : null} - - {isInSplit && splitCount > 1 ? ( - { - onClose(); - onCloseOtherSplits(laneContextMenu.laneId); - }} - > - Close Other Tabs - - ) : null} - - { - onClose(); - onSelectAll(); - }} - > - Select All Lanes - - - {deletableVisibleIds.length > 1 ? ( - <> -
    - { - onClose(); - onBatchManage(deletableVisibleIds); - }} - > - Manage {deletableVisibleIds.length} Open Lanes... - - - ) : null} -
    - ); -} - -function ColorSwatchRow({ - ctxLane, - lanesById, - onChanged, -}: { - ctxLane: LaneSummary; - lanesById: Map; - onChanged?: () => void | Promise; -}) { - const [error, setError] = React.useState(null); - const [busy, setBusy] = React.useState(false); - const lanesArray = React.useMemo(() => Array.from(lanesById.values()), [lanesById]); - const used = React.useMemo(() => colorsInUse(lanesArray, ctxLane.id), [lanesArray, ctxLane.id]); - const owners = React.useMemo(() => { - const map = new Map(); - for (const lane of lanesArray) { - if (lane.archivedAt || lane.id === ctxLane.id || !lane.color) continue; - map.set(lane.color.toLowerCase(), lane.name); - } - return map; - }, [lanesArray, ctxLane.id]); - const currentLower = ctxLane.color?.toLowerCase() ?? null; - - const apply = async (next: string | null) => { - setError(null); - setBusy(true); - try { - await window.ade.lanes.updateAppearance({ laneId: ctxLane.id, color: next }); - await onChanged?.(); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to set color"); - } finally { - setBusy(false); - } - }; - - return ( -
    - - apply(null)} - style={{ - width: 18, - height: 18, - borderRadius: 9999, - backgroundColor: "transparent", - cursor: busy ? "not-allowed" : "pointer", - border: "1px dashed rgba(255,255,255,0.35)", - padding: 0, - color: "rgba(255,255,255,0.55)", - fontSize: 10, - lineHeight: "16px", - }} - > - ✕ - - ) : null} - /> - {error ? ( -
    {error}
    - ) : null} -
    - ); -} - -function SwatchGroup({ - label, - colors, - selectedLower, - used, - owners, - busy, - onPick, - trailing, -}: { - label: string; - colors: readonly LaneColor[]; - selectedLower: string | null; - used: Set; - owners: Map; - busy: boolean; - onPick: (hex: string) => void; - trailing?: React.ReactNode; -}) { - return ( -
    - - {label} - -
    - {colors.map((entry) => { - const lower = entry.hex.toLowerCase(); - const isSelected = selectedLower === lower; - const isTaken = !isSelected && used.has(lower); - const owner = isTaken ? owners.get(lower) ?? null : null; - const title = isTaken - ? owner - ? `${entry.name} — used by ${owner}` - : `${entry.name} — in use` - : entry.name; - return ( - - ); - })} - {trailing} -
    +
    ); } diff --git a/apps/desktop/src/renderer/components/lanes/laneColorPalette.ts b/apps/desktop/src/renderer/components/lanes/laneColorPalette.ts index 3d54722ca..45389e7c4 100644 --- a/apps/desktop/src/renderer/components/lanes/laneColorPalette.ts +++ b/apps/desktop/src/renderer/components/lanes/laneColorPalette.ts @@ -1,23 +1,32 @@ import type { LaneSummary } from "../../../shared/types"; import { + ALLOCATABLE_LANE_COLORS, LANE_CLASSIC_COLORS, LANE_CLASSIC_COUNT, LANE_COLOR_PALETTE, LANE_FALLBACK_COLORS, LANE_RAINBOW_COLORS, + PRIMARY_LANE_COLOR, allocateLaneColor, laneColorName, nextAvailableLaneColor, + resolveLaneAccentColor, type LaneColor, } from "../../../shared/laneColorPalette"; export { + // `LANE_COLOR_PALETTE` is everything the manual picker may offer; + // `ALLOCATABLE_LANE_COLORS` is what auto-assignment may draw from. They differ + // by exactly one entry: the Primary lane's reserved ADE purple. + ALLOCATABLE_LANE_COLORS, LANE_CLASSIC_COLORS, LANE_CLASSIC_COUNT, LANE_COLOR_PALETTE, LANE_FALLBACK_COLORS, LANE_RAINBOW_COLORS, + PRIMARY_LANE_COLOR, laneColorName, + resolveLaneAccentColor, type LaneColor, }; diff --git a/apps/desktop/src/renderer/components/lanes/laneContextMenuItems.tsx b/apps/desktop/src/renderer/components/lanes/laneContextMenuItems.tsx new file mode 100644 index 000000000..43add7e22 --- /dev/null +++ b/apps/desktop/src/renderer/components/lanes/laneContextMenuItems.tsx @@ -0,0 +1,537 @@ +import React from "react"; +import type { LaneSummary } from "../../../shared/types"; +import { buildDeeplink } from "../../../shared/deeplinks"; +import { buildWebClientUrl } from "../../../shared/webClientUrl"; +import { openExternalUrl } from "../../lib/openExternal"; +import { revealLabel } from "../../lib/platform"; +import { COLORS, MONO_FONT } from "./laneDesignTokens"; +import { LANE_CLASSIC_COLORS, LANE_RAINBOW_COLORS, colorsInUse, type LaneColor } from "./laneColorPalette"; + +/** + * The lane menu's items, as data. + * + * There are two surfaces that must offer the *same* lane actions: the lane + * divider's own right-click menu, and the "Lane ▸" submenu a singleton lane's + * session menu carries (that row is its lane's only visible surface). They used + * to stay in sync because the session menu simply re-opened the real lane menu + * portal; now that it renders the items inline instead, this module is what + * keeps the two from drifting — both render exactly these groups, so an action + * added here shows up on both without anyone remembering to. + */ + +export type LaneMenuAction = { + kind: "action"; + key: string; + label: string; + onSelect: () => void; + dataTour?: string; +}; + +/** A group entry that is not a row — today only the colour swatch grid. */ +export type LaneMenuCustom = { + kind: "custom"; + key: string; + node: React.ReactNode; +}; + +export type LaneMenuEntry = LaneMenuAction | LaneMenuCustom; + +export type LaneMenuGroup = { + key: string; + /** Omitted for the top group, which needs no heading to be findable. */ + label?: string; + entries: LaneMenuEntry[]; + /** Rendered as a hover submenu rather than inline rows. */ + submenu?: boolean; +}; + +export type LaneMenuArgs = { + laneId: string; + lane: LaneSummary | null; + lanesById: Map; + visibleLaneIds: string[]; + isRemoteProject: boolean; + onClose: () => void; + onAdoptAttached: (laneId: string) => void; + onManage: (laneId: string) => void; + selectLane: (id: string) => void; + onRemoveFromSplit: (laneId: string) => void; + onCloseOtherSplits: (keepLaneId: string) => void; + onSelectAll: () => void; + onBatchManage: (laneIds: string[]) => void; + onAppearanceChanged?: () => void | Promise; + onStartChatInLane?: (laneId: string) => void; + onToggleWorkPin?: (laneId: string) => void; + workPinnedLaneIds?: string[]; +}; + +/** + * Deliberately narrower than `prs/shared/laneBranchTargets`: the deep links + * below only ever see a local `refs/heads/` ref, and stripping remote prefixes + * too would silently turn `origin/x` into a branch link this lane does not own. + */ +function branchNameFromRef(ref: string | null | undefined): string { + if (!ref) return ""; + return ref.replace(/^refs\/heads\//, ""); +} + +async function fetchRepoForCopy(): Promise<{ owner: string; name: string } | null> { + try { + const status = await window.ade.github.getRemoteStatus(); + return status.repo ?? null; + } catch { + return null; + } +} + +/** + * Groups, in the order a first-time reader looks for them: what you do with the + * lane, where you go, what you copy, how it is laid out, what it looks like, + * and — last, because they open destructive surfaces — the manage entries. + */ +export function buildLaneMenuGroups(args: LaneMenuArgs): LaneMenuGroup[] { + const { + laneId, + lane, + lanesById, + visibleLaneIds, + isRemoteProject, + onClose, + onAdoptAttached, + onManage, + selectLane, + onRemoveFromSplit, + onCloseOtherSplits, + onSelectAll, + onBatchManage, + onAppearanceChanged, + onStartChatInLane, + onToggleWorkPin, + workPinnedLaneIds, + } = args; + + const isInSplit = visibleLaneIds.includes(laneId); + const splitCount = visibleLaneIds.length; + const isPrimary = lane?.laneType === "primary"; + const deletableVisibleIds = visibleLaneIds.filter((id) => { + const candidate = lanesById.get(id); + return candidate && candidate.laneType !== "primary"; + }); + const branch = branchNameFromRef(lane?.branchRef); + + const groups: LaneMenuGroup[] = []; + + // ── Top group: the two things you do TO the lane, unlabelled because they + // are the first rows under the cursor and need no signpost. + const top: LaneMenuEntry[] = []; + if (lane && onStartChatInLane) { + top.push({ + kind: "action", + key: "start-chat", + label: "Start chat in lane", + dataTour: "lanes.startChatInLane", + onSelect: () => { onClose(); onStartChatInLane(laneId); }, + }); + } + if (lane && onToggleWorkPin) { + top.push({ + kind: "action", + key: "work-pin", + label: workPinnedLaneIds?.includes(laneId) + ? "Unpin from Work sidebar" + : "Pin to Work sidebar", + onSelect: () => { onClose(); onToggleWorkPin(laneId); }, + }); + } + if (top.length) groups.push({ key: "lane", entries: top }); + + // ── Go to: surfaces outside this menu that show the same lane. + const goTo: LaneMenuEntry[] = []; + if (lane?.worktreePath && !isRemoteProject) { + goTo.push({ + kind: "action", + key: "reveal", + label: revealLabel, + dataTour: "lanes.manageLane", + onSelect: () => { + onClose(); + window.ade.app.revealPath(lane.worktreePath!).catch(() => {}); + }, + }); + } + if (lane) { + goTo.push({ + kind: "action", + key: "open-web", + label: "Open in web", + onSelect: () => { + onClose(); + openExternalUrl(buildWebClientUrl({ kind: "lane", laneId })); + }, + }); + } + if (goTo.length) groups.push({ key: "go-to", label: "Go to", entries: goTo }); + + // ── Copy: four near-identical "put a string on the clipboard" rows. They are + // the single biggest block in the flat menu and the one nobody reads, so + // they collapse behind one row. + const copy: LaneMenuEntry[] = []; + if (lane) { + copy.push({ + kind: "action", + key: "copy-lane-link", + label: "Copy ADE Lane Link", + onSelect: () => { + onClose(); + void (async () => { + const repo = await fetchRepoForCopy(); + const pr = await window.ade.prs.getForLane(lane.id).catch(() => null); + const url = buildDeeplink( + { + kind: "lane", + laneId, + ...(repo + ? { + envelope: { + repoOwner: repo.owner, + repoName: repo.name, + ...(branch ? { branch } : {}), + ...(pr?.githubPrNumber ? { prNumber: pr.githubPrNumber } : {}), + ...(lane.linearIssue?.identifier + ? { linearIssue: lane.linearIssue.identifier } + : {}), + }, + } + : {}), + }, + { form: "ade" }, + ); + await window.ade.app.writeClipboardText(url).catch(() => {}); + })(); + }, + }); + if (branch) { + copy.push({ + kind: "action", + key: "copy-branch-link", + label: "Copy Branch Link (Cross-Machine)", + onSelect: () => { + onClose(); + void fetchRepoForCopy().then((repo) => { + if (!repo) { + // No GitHub origin — fall back to lane link as the best we can offer. + const fallback = buildDeeplink({ kind: "lane", laneId }, { form: "ade" }); + return window.ade.app.writeClipboardText(fallback).catch(() => {}); + } + const url = buildDeeplink({ + kind: "branch", + repoOwner: repo.owner, + repoName: repo.name, + branch, + }); + return window.ade.app.writeClipboardText(url).catch(() => {}); + }); + }, + }); + } + if (lane.linearIssue?.url) { + const linearUrl = lane.linearIssue.url; + copy.push({ + kind: "action", + key: "copy-linear-link", + label: "Copy Linear Issue Link", + onSelect: () => { + onClose(); + window.ade.app.writeClipboardText(linearUrl).catch(() => {}); + }, + }); + } + } + if (lane?.worktreePath) { + // A remote project has no local path to reveal, so the path is only ever + // copyable there — same action, filed where copies live. + copy.push({ + kind: "action", + key: "copy-path", + label: isRemoteProject ? "Copy Remote Path" : "Copy Path", + ...(isRemoteProject ? { dataTour: "lanes.manageLane" } : {}), + onSelect: () => { + onClose(); + window.ade.app.writeClipboardText(lane.worktreePath!).catch(() => {}); + }, + }); + } + if (copy.length) groups.push({ key: "copy", label: "Copy", entries: copy, submenu: true }); + + // ── Tabs: everything about which lanes are on screen. + const tabs: LaneMenuEntry[] = []; + if (!isInSplit) { + tabs.push({ + kind: "action", + key: "open-split", + label: "Open in Split", + onSelect: () => { onClose(); selectLane(laneId); }, + }); + } + if (isInSplit && splitCount > 1 && !isPrimary) { + tabs.push({ + kind: "action", + key: "remove-split", + label: "Remove from Split", + onSelect: () => { onClose(); onRemoveFromSplit(laneId); }, + }); + } + if (isInSplit && splitCount > 1) { + tabs.push({ + kind: "action", + key: "close-other-tabs", + label: "Close Other Tabs", + onSelect: () => { onClose(); onCloseOtherSplits(laneId); }, + }); + } + tabs.push({ + kind: "action", + key: "select-all", + label: "Select All Lanes", + onSelect: () => { onClose(); onSelectAll(); }, + }); + groups.push({ + key: "tabs", + label: splitCount > 1 ? `${splitCount} tabs open` : "Tabs", + entries: tabs, + }); + + if (lane) { + groups.push({ + key: "color", + label: "Color", + entries: [{ + kind: "custom", + key: "swatches", + node: ( + + ), + }], + }); + } + + // ── Manage: last, because both entries open a surface that can delete. + const manage: LaneMenuEntry[] = []; + if (lane && !isPrimary) { + if (lane.laneType === "attached") { + manage.push({ + kind: "action", + key: "adopt", + label: "Move To .ade/worktrees", + onSelect: () => { onClose(); selectLane(laneId); onAdoptAttached(laneId); }, + }); + } + manage.push({ + kind: "action", + key: "manage", + label: "Manage Lane", + onSelect: () => { onClose(); selectLane(laneId); onManage(laneId); }, + }); + } + if (deletableVisibleIds.length > 1) { + manage.push({ + kind: "action", + key: "batch-manage", + label: `Manage ${deletableVisibleIds.length} Open Lanes...`, + onSelect: () => { onClose(); onBatchManage(deletableVisibleIds); }, + }); + } + if (manage.length) groups.push({ key: "manage", label: "Manage", entries: manage }); + + return groups; +} + +export const laneMenuHeaderStyle: React.CSSProperties = { + padding: "5px 14px 3px", + fontSize: 9, + fontFamily: MONO_FONT, + fontWeight: 700, + letterSpacing: "0.12em", + textTransform: "uppercase", + color: COLORS.textDim, +}; + +export function ColorSwatchRow({ + ctxLane, + lanesById, + onChanged, +}: { + ctxLane: LaneSummary; + lanesById: Map; + onChanged?: () => void | Promise; +}) { + const [error, setError] = React.useState(null); + const [busy, setBusy] = React.useState(false); + const lanesArray = React.useMemo(() => Array.from(lanesById.values()), [lanesById]); + const used = React.useMemo(() => colorsInUse(lanesArray, ctxLane.id), [lanesArray, ctxLane.id]); + const owners = React.useMemo(() => { + const map = new Map(); + for (const lane of lanesArray) { + if (lane.archivedAt || lane.id === ctxLane.id || !lane.color) continue; + map.set(lane.color.toLowerCase(), lane.name); + } + return map; + }, [lanesArray, ctxLane.id]); + const currentLower = ctxLane.color?.toLowerCase() ?? null; + + const apply = async (next: string | null) => { + setError(null); + setBusy(true); + try { + await window.ade.lanes.updateAppearance({ laneId: ctxLane.id, color: next }); + await onChanged?.(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to set color"); + } finally { + setBusy(false); + } + }; + + return ( +
    + + apply(null)} + style={{ + width: 18, + height: 18, + borderRadius: 9999, + backgroundColor: "transparent", + cursor: busy ? "not-allowed" : "pointer", + border: "1px dashed rgba(255,255,255,0.35)", + padding: 0, + color: "rgba(255,255,255,0.55)", + fontSize: 10, + lineHeight: "16px", + }} + > + ✕ + + ) : null} + /> + {error ? ( +
    {error}
    + ) : null} +
    + ); +} + +function SwatchGroup({ + label, + colors, + selectedLower, + used, + owners, + busy, + onPick, + trailing, +}: { + label: string; + colors: readonly LaneColor[]; + selectedLower: string | null; + used: Set; + owners: Map; + busy: boolean; + onPick: (hex: string) => void; + trailing?: React.ReactNode; +}) { + return ( +
    + + {label} + +
    + {colors.map((entry) => { + const lower = entry.hex.toLowerCase(); + const isSelected = selectedLower === lower; + const isTaken = !isSelected && used.has(lower); + const owner = isTaken ? owners.get(lower) ?? null : null; + const title = isTaken + ? owner + ? `${entry.name} — used by ${owner}` + : `${entry.name} — in use` + : entry.name; + return ( + + ); + })} + {trailing} +
    +
    + ); +} diff --git a/apps/desktop/src/renderer/components/lanes/laneDesignTokens.ts b/apps/desktop/src/renderer/components/lanes/laneDesignTokens.ts index bac816840..98d32810f 100644 --- a/apps/desktop/src/renderer/components/lanes/laneDesignTokens.ts +++ b/apps/desktop/src/renderer/components/lanes/laneDesignTokens.ts @@ -124,6 +124,40 @@ export function laneSurfaceTint( }; } +/** + * The vertical rail drawn beside an expanded lane group's indented rows. + * + * It is deliberately a TINT, not a stripe: at ~25% the lane accent is enough to + * say which group the rows belong to while staying quieter than the lane name it + * hangs under, so a column of expanded groups doesn't read as a set of coloured + * bars competing with the cards. A lane with no accent falls back to the same + * neutral hairline every other divider in the sidebar uses. + */ +export function laneRailTint(color: string | null | undefined, percent = 25): string { + const c = typeof color === "string" ? color.trim() : ""; + if (!c) return "rgba(255,255,255,0.07)"; + // Every lane colour ADE assigns is a hex literal, and resolving it to `rgba` + // here rather than leaning on `color-mix` keeps the value a real colour any + // engine can parse — jsdom included, so the rail is assertable in tests. + const rgb = hexToRgb(c); + if (rgb) return `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${percent / 100})`; + return `color-mix(in srgb, ${c} ${percent}%, transparent)`; +} + +function hexToRgb(value: string): [number, number, number] | null { + const match = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(value); + if (!match) return null; + const digits = match[1]!; + const full = digits.length === 3 + ? digits.split("").map((d) => `${d}${d}`).join("") + : digits; + return [ + Number.parseInt(full.slice(0, 2), 16), + Number.parseInt(full.slice(2, 4), 16), + Number.parseInt(full.slice(4, 6), 16), + ]; +} + export function outlineButton(overrides?: CSSProperties): CSSProperties { return { display: "inline-flex", diff --git a/apps/desktop/src/renderer/components/lanes/laneUtils.test.ts b/apps/desktop/src/renderer/components/lanes/laneUtils.test.ts index 71b47b3db..f89a6e659 100644 --- a/apps/desktop/src/renderer/components/lanes/laneUtils.test.ts +++ b/apps/desktop/src/renderer/components/lanes/laneUtils.test.ts @@ -10,8 +10,10 @@ import { validateBranchName, } from "./laneUtils"; import { + ALLOCATABLE_LANE_COLORS, LANE_COLOR_PALETTE, LANE_FALLBACK_COLORS, + PRIMARY_LANE_COLOR, colorsInUse, getLaneAccent, laneColorName, @@ -195,15 +197,25 @@ describe("laneColorPalette", () => { }); describe("nextAvailableColor", () => { - it("returns the first palette entry when nothing is in use", () => { - expect(nextAvailableColor([])).toBe(LANE_COLOR_PALETTE[0].hex); + it("returns the first ALLOCATABLE entry when nothing is in use", () => { + // Not `LANE_COLOR_PALETTE[0]`: that is the reserved Primary purple, which + // no auto-assigned lane may claim. + expect(nextAvailableColor([])).toBe(ALLOCATABLE_LANE_COLORS[0]!.hex); + }); + + it("never allocates the reserved Primary purple", () => { + const taken = ALLOCATABLE_LANE_COLORS.map((c) => makeLane({ id: c.hex, color: c.hex })); + // Purple is the one palette entry left unused, and it is still not offered. + expect(nextAvailableColor(taken)).toBeNull(); + expect(ALLOCATABLE_LANE_COLORS.some((c) => c.hex === PRIMARY_LANE_COLOR)).toBe(false); + expect(LANE_FALLBACK_COLORS).not.toContain(PRIMARY_LANE_COLOR); }); it("skips colors already taken by an active lane", () => { - const taken = LANE_COLOR_PALETTE.slice(0, 3).map((c) => + const taken = ALLOCATABLE_LANE_COLORS.slice(0, 3).map((c) => makeLane({ id: c.hex, color: c.hex }), ); - expect(nextAvailableColor(taken)).toBe(LANE_COLOR_PALETTE[3].hex); + expect(nextAvailableColor(taken)).toBe(ALLOCATABLE_LANE_COLORS[3]!.hex); }); it("returns null when every palette color is already in use", () => { diff --git a/apps/desktop/src/renderer/components/terminals/LaneActionsSubmenu.tsx b/apps/desktop/src/renderer/components/terminals/LaneActionsSubmenu.tsx new file mode 100644 index 000000000..20511f450 --- /dev/null +++ b/apps/desktop/src/renderer/components/terminals/LaneActionsSubmenu.tsx @@ -0,0 +1,92 @@ +import { useMemo } from "react"; +import { useAppStore } from "../../state/appStore"; +import { LaneMenuGroups, menuItemStyle } from "../lanes/LaneContextMenu"; +import { buildLaneMenuGroups } from "../lanes/laneContextMenuItems"; +import { COLORS } from "../lanes/laneDesignTokens"; +import { MenuSubmenu } from "../ui/MenuSubmenu"; +import { useLaneMenuActions } from "./useWorkLaneContextMenu"; + +/** + * The lane menu, hosted as a submenu of a singleton lane's session menu. + * + * It renders `buildLaneMenuGroups` — the same definitions the lane divider's + * own menu renders — rather than a transcription of them, and drives them with + * `useLaneMenuActions`, the same wiring. Both halves are shared on purpose: the + * previous design delegated to the real lane menu portal precisely so the two + * could not disagree, and turning the row into a hover submenu must not buy + * discoverability at the price of a second copy that silently falls behind. + */ +export function LaneActionsSubmenu({ + laneId, + laneName, + onClose, + onManageLane, + onFallbackOpen, + triggerClassName, + anchor, +}: { + laneId: string; + laneName: string; + /** Closes the parent session menu. */ + onClose: () => void; + /** Hands the lane off to the session menu's manage-dialog host. */ + onManageLane: (laneId: string) => void; + /** + * Escape hatch for a lane this renderer cannot resolve (a cross-machine row, + * say): reopens the real lane menu at the session menu's anchor, exactly as + * the pre-submenu row did. + */ + onFallbackOpen: (position: { x: number; y: number }) => void; + triggerClassName: string; + anchor: { x: number; y: number }; +}) { + const lanes = useAppStore((s) => s.lanes); + const isRemoteProject = useAppStore((s) => s.projectBinding?.kind === "remote"); + const lanesById = useMemo(() => { + const map = new Map(); + for (const lane of lanes) map.set(lane.id, lane); + return map; + }, [lanes]); + const lane = lanesById.get(laneId) ?? null; + const actions = useLaneMenuActions({ close: onClose, onManageLane }); + + const groups = useMemo( + () => buildLaneMenuGroups({ + laneId, + lane, + lanesById, + // The card's lane is the only one this menu can speak for, matching what + // the Work sidebar passes its own lane menu. + visibleLaneIds: [laneId], + isRemoteProject, + onClose, + ...actions, + }), + [actions, isRemoteProject, lane, laneId, lanesById, onClose], + ); + + return ( + + {lane ? ( + + ) : ( + + )} + + ); +} diff --git a/apps/desktop/src/renderer/components/terminals/LanePrBadge.tsx b/apps/desktop/src/renderer/components/terminals/LanePrBadge.tsx new file mode 100644 index 000000000..adc7921e6 --- /dev/null +++ b/apps/desktop/src/renderer/components/terminals/LanePrBadge.tsx @@ -0,0 +1,53 @@ +import React from "react"; +import type { PrSummary } from "../../../shared/types"; +import { lanePrStateColor, lanePrStateLabel } from "../../lib/lanePrBadge"; + +/** + * Compact PR status chip for a lane in the Work sidebar: state-coloured dot + + * `#` + one-word state. Clicking deep-links into the PRs tab. + * + * Shared deliberately, not copied. "Does this lane have a PR, and where is it + * at" has to survive every place the lane header is minimized or absent — the + * full divider, a collapsed quiet divider, and (with no divider at all) the + * singleton lane's lone card. Three copies of this markup would drift into + * three different PR badges; one component cannot. + * + * `span role="button"`, never a native ` + ) : null; + + const titleNode = ( + + {primaryText} + + ); + + /* Recede rule (inbox-zero): rows the presentation marks non-prominent — + Working, Starting, Stale, Stopped, Ended, Snoozed — and settled rows fade + back so the handful that actually want a human stand out. The status label + keeps its hue either way, so a working row is still findable. Selection + always wins: whatever you are looking at renders at full strength. */ + const shouldRecede = !isHighlighted && !presentation?.prominent; + + const row = ( +
    cannot legally host the status slot's snooze/settle + buttons, and the swap only works with those controls in flow. t3code's + row makes the same trade. */ + className={cn( + // Both properties in ONE transition declaration: a second + // `transition-opacity` on the recede branch would override + // `transition-colors` outright and kill the hover fade. + // Full-bleed row, not a floating card: this element is already the full + // width of the widened wrapper (see SESSION_ROW_BLEED_CLASS), so the + // hover/selected fill runs edge to edge and reads as "you are on this + // ROW" rather than "you are hovering a card". A full-width band wants a + // hairline corner, not a card's. + "group/v2-row relative w-full select-none overflow-hidden rounded-md text-left outline-none transition-[background-color,opacity] duration-100", + disabledReason ? "cursor-default" : "cursor-pointer", + isHighlighted ? "bg-white/[0.06]" : "bg-transparent hover:bg-white/[0.035]", + isMultiSelected && "ring-1 ring-accent/35", + shouldRecede && "opacity-70 hover:opacity-100", + )} + data-session-recede={shouldRecede ? "true" : undefined} + {...(disabledReason + ? { "aria-label": `${primaryText}: ${disabledReason}` } + : orchestrationLabel + ? { "aria-label": `${orchestrationLabel}: ${primaryText}` } + : { "aria-label": primaryText })} + onClick={(event) => { + // Selecting loads this session into the very area the card floats over, + // so the card gets out of the way the moment the row is acted on. + hoverCard.close(); + if (disabledReason) return; + onSelect(session.id, event); + }} + onKeyDown={(event) => { + // Only the row itself — a key pressed inside a nested control (settle, + // snooze, a preview link) belongs to that control. + if (event.target !== event.currentTarget) return; + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + event.currentTarget.click(); + }} + > + {compact ? ( +
    + {titleNode} + {compactLineageGlyph} + + {statusSlot} +
    + ) : ( +
    + {/* Line 1 — where this work lives, then the status slot. */} +
    + {/* Never empty — the model floor guarantees at least one part, and + that part is `flex-1`, which is what keeps the status slot + right-anchored on an otherwise bare row. */} + {whereParts.map((part, index) => ( + + {index > 0 ? : null} + {part} + + ))} + {statusSlot} +
    + + {/* Line 2 — the title. The one prominent element in the row. */} +
    {titleNode}
    + + {/* Line 3 — what it is doing, then the quiet meta. */} +
    + {isAutoNaming ? ( + + Auto-naming lane underway… + + ) : previewLine ? ( + + {previewLine.linkify ? ( + navigate(`/prs?pr=${prNumber}`)} + /> + ) : previewLine.text} + + ) : ( + + )} + + {showClaudeCacheTimer ? ( + + ) : null} + {/* The diff chips used to sit here. They moved to line 1's floor: + on this line they were competing with the preview text for the + same pixels, and the preview was the one that lost — real rows + truncated mid-token. The preview keeps `min-w-0 flex-1` above, + so the freed width goes to it and not to the trailing cluster. */} + {/* A non-zero exit that is NOT a stop signal: the status slot + already says "Failed", this carries the code it failed with. + The old "STOPPED" chip is gone — the slot says that word now. */} + {session.exitCode != null + && session.exitCode !== 0 + && session.exitCode !== 130 + && session.exitCode !== 143 ? ( + + EXIT {session.exitCode} + + ) : null} + {/* The provider mark is the least informative thing in the row — + most rows share a provider — so it sits in the least prominent + slot rather than leading the card. */} + +
    +
    + )} + + {disabledReason ? ( + + {disabledBusy ? ( + + ) : ( + + )} + {disabledReason} + + ) : null} +
    + ); return (
    { + // The context menu is the other thing that opens next to this row; + // two floating panels over the same spot is one too many. + hoverCard.close(); + onContextMenu(event); + } + } draggable={!disabledReason} onDragStart={(event) => { // Source for the Cursor-style work grid: drop onto a session / the work @@ -552,289 +1042,15 @@ export const SessionCard = React.memo(function SessionCard({ event.dataTransfer.effectAllowed = "copyMove"; }} > -
    -
    - - {/* Row 2: auto-naming status, else summary/preview line (conditional) */} - {!compact && isAutoNaming ? ( -
    - - Auto-naming lane underway… - -
    - ) : previewLine && !compact ? ( -
    - - {previewLine.linkify ? ( - navigate(`/prs?pr=${prNumber}`)} - /> - ) : previewLine.text} - -
    - ) : null} - - {/* Row 3: Cache badge + Delta chips + Exit code */} - {hasFooterMeta ? ( -
    - {showClaudeCacheTimer ? ( - - ) : null} - - {hasDeltaChips && delta ? ( - <> - {delta.insertions > 0 ? ( - - +{delta.insertions} - - ) : null} - {delta.deletions > 0 ? ( - - -{delta.deletions} - - ) : null} - - ) : null} - - {session.exitCode != null && session.exitCode !== 0 ? ( - - {stoppedBySignal ? "STOPPED" : `EXIT ${session.exitCode}`} - - ) : null} -
    - ) : null} -
    -
    - {disabledReason ? ( - - {disabledBusy ? ( - - ) : ( - - )} - {disabledReason} - - ) : null} - - {/* Row hover actions live OUTSIDE the card button: nesting an interactive - control inside a native
    ); }); diff --git a/apps/desktop/src/renderer/components/terminals/SessionContextMenu.test.tsx b/apps/desktop/src/renderer/components/terminals/SessionContextMenu.test.tsx index be61c5f7d..2d538ea63 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionContextMenu.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionContextMenu.test.tsx @@ -1,12 +1,68 @@ /* @vitest-environment jsdom */ import React from "react"; -import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenProjectBinding, TerminalSessionSummary } from "../../../shared/types"; -import { SessionContextMenu } from "./SessionContextMenu"; +import { MemoryRouter } from "react-router-dom"; +import type { LaneSummary, OpenProjectBinding, TerminalSessionSummary } from "../../../shared/types"; +import { SUBMENU_CLOSE_DELAY_MS, SUBMENU_OPEN_DELAY_MS } from "../ui/MenuSubmenu"; +import { + SessionContextMenu, + type SessionContextMenuLaneActions, +} from "./SessionContextMenu"; + +/** + * The lane submenu reads the lane out of the store rather than being handed a + * copy, so the store is the fixture for anything that renders it. + */ +const soloLane = { + id: "lane-solo", + name: "Solo lane", + laneType: "worktree", + baseRef: "main", + branchRef: "refs/heads/solo-lane", + worktreePath: "/tmp/solo-lane", + parentLaneId: null, + childCount: 0, + stackDepth: 0, + parentStatus: null, + isEditProtected: false, + status: { dirty: false, ahead: 0, behind: 0, remoteBehind: 0, rebaseInProgress: false }, + color: null, + icon: null, + tags: [], + createdAt: "2026-07-10T10:00:00.000Z", +} satisfies LaneSummary; + +let storeLanes: LaneSummary[] = [soloLane]; + +vi.mock("../../state/appStore", async () => { + const actual = await vi.importActual("../../state/appStore"); + return { + ...actual, + useAppStore: (selector: (state: Record) => unknown) => + selector({ + lanes: storeLanes, + project: { rootPath: "/project" }, + projectBinding: { kind: "local", key: "/project", rootPath: "/project" }, + selectLane: vi.fn(), + setWorkViewState: vi.fn(), + }), + }; +}); -afterEach(cleanup); +/** React routes pointer enter/leave through pointerover/pointerout. */ +function hover(element: Element) { + fireEvent.pointerOver(element); +} +function unhover(element: Element, to: Element | null = null) { + fireEvent.pointerOut(element, { relatedTarget: to }); +} + +afterEach(() => { + cleanup(); + storeLanes = [soloLane]; +}); function makeSession(overrides: Partial = {}): TerminalSessionSummary { return { @@ -39,25 +95,38 @@ function renderMenu( onSetChatTag = vi.fn(), onSettle = vi.fn(), binding?: OpenProjectBinding | null, + laneActions?: SessionContextMenuLaneActions | null, ) { const onClose = vi.fn(); + const onCopySessionId = vi.fn(); + const onCopySessionDeepLink = vi.fn(); + const onGoToLane = vi.fn(); render( - , + + + , ); - return { onClose, onSetChatTag, onSettle }; + return { onClose, onSetChatTag, onSettle, onCopySessionId, onCopySessionDeepLink, onGoToLane }; +} + +/** Opens a submenu the way a pointer user would: hover, then wait out the intent delay. */ +function openSubmenuByHover(trigger: Element) { + hover(trigger); + act(() => { vi.advanceTimersByTime(SUBMENU_OPEN_DELAY_MS + 10); }); } describe("SessionContextMenu Claude tags", () => { @@ -100,6 +169,210 @@ describe("SessionContextMenu Claude tags", () => { }); }); +describe("SessionContextMenu lane section", () => { + const laneActions: SessionContextMenuLaneActions = { + laneId: "lane-solo", + laneName: "Solo lane", + open: vi.fn(), + }; + + beforeEach(() => { vi.useFakeTimers(); }); + afterEach(() => { vi.useRealTimers(); }); + + it("expands the real lane menu items in a submenu rather than a second menu", () => { + renderMenu(makeSession(), vi.fn(), vi.fn(), null, laneActions); + + const entry = screen.getByTestId("session-menu-lane-actions"); + // Names the lane, so a menu opened from a card still says which lane it acts on. + expect(entry.textContent).toContain("Solo lane"); + + openSubmenuByHover(entry); + + // These come from `buildLaneMenuGroups`, the same source the lane divider's + // own menu renders — the assertion is that the submenu is that menu, not a + // transcription of it. + expect(screen.getByRole("menuitem", { name: "Start chat in lane" })).toBeTruthy(); + expect(screen.getByRole("menuitem", { name: "Manage Lane" })).toBeTruthy(); + expect(screen.getByRole("menuitem", { name: "Open in web" })).toBeTruthy(); + expect(screen.getByRole("menuitem", { name: /Select All Lanes/ })).toBeTruthy(); + // Colour palette survived the move into the submenu. + expect(screen.getAllByRole("button", { name: /Rainbow|Sky|Amber|Rose/ }).length).toBeGreaterThan(0); + }); + + it("keeps the deep lane copy links reachable through a nested submenu", () => { + renderMenu(makeSession(), vi.fn(), vi.fn(), null, laneActions); + + openSubmenuByHover(screen.getByTestId("session-menu-lane-actions")); + openSubmenuByHover(screen.getByRole("menuitem", { name: "Copy" })); + + expect(screen.getByRole("menuitem", { name: "Copy ADE Lane Link" })).toBeTruthy(); + expect(screen.getByRole("menuitem", { name: "Copy Branch Link (Cross-Machine)" })).toBeTruthy(); + expect(screen.getByRole("menuitem", { name: "Copy Path" })).toBeTruthy(); + }); + + it("falls back to the real lane menu portal for a lane it cannot resolve", () => { + // A cross-machine row's lane is not in this renderer's store, so the only + // honest answer is still to hand off to the portal that owns it. + storeLanes = []; + const open = vi.fn(); + const { onClose } = renderMenu( + makeSession(), + vi.fn(), + vi.fn(), + null, + { laneId: "lane-solo", laneName: "Solo lane", open }, + ); + + openSubmenuByHover(screen.getByTestId("session-menu-lane-actions")); + fireEvent.click(screen.getByRole("menuitem", { name: "Open lane menu…" })); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(open).toHaveBeenCalledWith({ x: 20, y: 20 }); + }); + + it("omits the lane section for a row whose lane still has a divider", () => { + // Not a singleton: the lane header is on screen and owns lane management, + // so duplicating it into every row's session menu would be pure noise. + renderMenu(makeSession()); + expect(screen.queryByTestId("session-menu-lane-actions")).toBeNull(); + }); +}); + +describe("SessionContextMenu submenu interaction", () => { + beforeEach(() => { vi.useFakeTimers(); }); + afterEach(() => { vi.useRealTimers(); }); + + it("waits out the intent delay instead of firing on a pass-over", () => { + renderMenu(makeSession()); + const trigger = screen.getByRole("button", { name: "Snooze…" }); + + hover(trigger); + act(() => { vi.advanceTimersByTime(SUBMENU_OPEN_DELAY_MS - 40); }); + // Still nothing: the cursor merely crossed the row on its way somewhere else. + expect(screen.queryByRole("button", { name: /^In 1 hour/ })).toBeNull(); + + act(() => { vi.advanceTimersByTime(50); }); + expect(screen.getByRole("button", { name: /^In 1 hour/ })).toBeTruthy(); + }); + + it("cancels the pending open when the pointer leaves before the delay", () => { + renderMenu(makeSession()); + const trigger = screen.getByRole("button", { name: "Snooze…" }); + + hover(trigger); + act(() => { vi.advanceTimersByTime(SUBMENU_OPEN_DELAY_MS - 40); }); + unhover(trigger); + act(() => { vi.advanceTimersByTime(500); }); + + expect(screen.queryByRole("button", { name: /^In 1 hour/ })).toBeNull(); + }); + + it("stays open while the pointer travels diagonally from the row to the panel", () => { + renderMenu(makeSession()); + const trigger = screen.getByRole("button", { name: "Snooze…" }); + openSubmenuByHover(trigger); + const panel = screen.getByRole("button", { name: /^In 1 hour/ }).closest("[role=menu]")!; + + // The diagonal crosses rows that are neither the trigger nor the panel, so + // the panel has to survive a gap with the pointer over nothing. + unhover(trigger); + act(() => { vi.advanceTimersByTime(SUBMENU_CLOSE_DELAY_MS - 60); }); + hover(panel); + act(() => { vi.advanceTimersByTime(1_000); }); + + expect(screen.getByRole("button", { name: /^In 1 hour/ })).toBeTruthy(); + }); + + it("closes once the pointer has left both the row and the panel", () => { + renderMenu(makeSession()); + const trigger = screen.getByRole("button", { name: "Snooze…" }); + openSubmenuByHover(trigger); + const panel = screen.getByRole("button", { name: /^In 1 hour/ }).closest("[role=menu]")!; + + unhover(panel); + act(() => { vi.advanceTimersByTime(SUBMENU_CLOSE_DELAY_MS + 20); }); + + expect(screen.queryByRole("button", { name: /^In 1 hour/ })).toBeNull(); + }); + + it("opens on ArrowRight and closes on Escape without dismissing the parent menu", () => { + const { onClose } = renderMenu(makeSession()); + const trigger = screen.getByRole("button", { name: "Snooze…" }); + + fireEvent.keyDown(trigger, { key: "ArrowRight" }); + const first = screen.getByRole("button", { name: /^In 1 hour/ }); + expect(first).toBeTruthy(); + // Keyboard opens move focus into the panel; pointer opens deliberately do not. + expect(document.activeElement).toBe(first); + + fireEvent.keyDown(first, { key: "Escape" }); + expect(screen.queryByRole("button", { name: /^In 1 hour/ })).toBeNull(); + expect(document.activeElement).toBe(trigger); + // Escape belongs to the submenu it closed, not to the menu behind it. + expect(onClose).not.toHaveBeenCalled(); + }); + + it("moves focus between panel rows with the arrow keys", () => { + renderMenu(makeSession()); + fireEvent.keyDown(screen.getByRole("button", { name: "Snooze…" }), { key: "ArrowRight" }); + const rows = screen.getAllByRole("button").filter((button) => + button.closest("[role=menu]") !== null); + + fireEvent.keyDown(rows[0]!, { key: "ArrowDown" }); + expect(document.activeElement).toBe(rows[1]); + fireEvent.keyDown(rows[1]!, { key: "ArrowUp" }); + expect(document.activeElement).toBe(rows[0]); + }); +}); + +describe("SessionContextMenu grouped actions", () => { + beforeEach(() => { vi.useFakeTimers(); }); + afterEach(() => { vi.useRealTimers(); }); + + it("keeps every copy action invocable behind the Copy submenu", () => { + const session = makeSession(); + const { onCopySessionId, onCopySessionDeepLink } = renderMenu(session); + + openSubmenuByHover(screen.getByRole("button", { name: "Copy" })); + fireEvent.click(screen.getByRole("button", { name: "Session ID" })); + expect(onCopySessionId).toHaveBeenCalledWith("chat-1"); + + openSubmenuByHover(screen.getByRole("button", { name: "Copy" })); + fireEvent.click(screen.getByRole("button", { name: "Session deep link" })); + expect(onCopySessionDeepLink).toHaveBeenCalledWith(session); + }); + + it("keeps the flat menu's top-level actions on the top level", () => { + const session = makeSession(); + const { onGoToLane } = renderMenu(session); + + for (const name of ["Rename", "Go to lane", "Snooze…", "Delete chat"]) { + expect(screen.getByRole("button", { name })).toBeTruthy(); + } + fireEvent.click(screen.getByRole("button", { name: "Go to lane" })); + expect(onGoToLane).toHaveBeenCalledWith(session, null); + }); + + it("re-resolves the snooze presets every time the submenu opens", () => { + // 09:00 offers "This evening"; 21:00 must not, because it would silently + // mean tomorrow. The presets are a snapshot of the clock at open time, so + // re-opening after the boundary has to produce the later list. + vi.setSystemTime(new Date("2026-07-30T09:00:00")); + renderMenu(makeSession()); + const trigger = screen.getByRole("button", { name: "Snooze…" }); + + openSubmenuByHover(trigger); + expect(screen.queryByRole("button", { name: /^This evening/ })).toBeTruthy(); + + unhover(trigger); + act(() => { vi.advanceTimersByTime(SUBMENU_CLOSE_DELAY_MS + 20); }); + vi.setSystemTime(new Date("2026-07-30T21:00:00")); + openSubmenuByHover(trigger); + + expect(screen.queryByRole("button", { name: /^This evening/ })).toBeNull(); + }); +}); + describe("SessionContextMenu settle safety", () => { it("offers Dismiss & settle when a chat is waiting on input", () => { const session = makeSession({ @@ -212,7 +485,9 @@ describe("SessionContextMenu snooze and explicit-settle lifecycle", () => { renderMenu(makeSession()); fireEvent.click(screen.getByRole("button", { name: "Snooze…" })); - fireEvent.click(screen.getByRole("button", { name: "1 hour" })); + // Prefix match on the label: presets may carry a locale-formatted time + // alongside it, which an exact accessible name would make brittle. + fireEvent.click(screen.getByRole("button", { name: /^In 1 hour/ })); await waitFor(() => expect(sessionsApi.snoozeSession).toHaveBeenCalledTimes(1)); const [sessionId, untilIso] = sessionsApi.snoozeSession.mock.calls[0]!; diff --git a/apps/desktop/src/renderer/components/terminals/SessionContextMenu.tsx b/apps/desktop/src/renderer/components/terminals/SessionContextMenu.tsx index 79eff1d0c..3e490a4ae 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionContextMenu.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionContextMenu.tsx @@ -5,10 +5,13 @@ import { isChatToolType } from "../../lib/sessions"; import { sessionCanonicalUiState } from "../../lib/terminalAttention"; import { isSessionSnoozed, + resolveSnoozePresets, snoozeWakeLabel, - SNOOZE_DURATION_OPTIONS, type SnoozeDurationKey, } from "../../lib/sessionSnooze"; +import { MenuSectionLabel, MenuSeparator, MenuSubmenu } from "../ui/MenuSubmenu"; +import { LaneActionsSubmenu } from "./LaneActionsSubmenu"; +import { WorkManageLaneDialogHost } from "./WorkManageLaneDialogHost"; import { setSessionSettleOverride, snoozeSessionForDuration, @@ -16,13 +19,43 @@ import { wakeSessionNow, } from "./sessionLifecycleActions"; +/* `hover:bg-muted/40` used to be the hover here and read as nothing at all: + `--color-muted` is #1E1B28, a near-black purple, so 40% of it over an already + dark menu is imperceptible. Menu rows now use the same white-alpha fill every + other hoverable surface in the sidebar uses, so "this row is under my cursor" + is actually visible. */ const MENU_ITEM_CLASS = - "flex w-full items-center gap-2 rounded px-3 py-1.5 text-left text-xs transition-colors hover:bg-muted/40"; + "flex w-full items-center gap-2 rounded px-3 py-1.5 text-left text-xs transition-colors hover:bg-white/[0.07] focus-visible:bg-white/[0.07] outline-none"; +const DESTRUCTIVE_ITEM_CLASS = + "flex w-full items-center gap-2 rounded px-3 py-1.5 text-left text-xs text-red-300 transition-colors hover:bg-red-500/10"; + +/** + * Carried only by a row whose lane renders WITHOUT a divider (the singleton + * form). That row is the lane's only visible surface, so right-clicking it is + * the only gesture left that could reach lane management — without this the + * whole lane menu (rename, pin, colour, manage, delete) is unreachable for a + * one-chat lane. + * + * The lane's actions render as a hover submenu built from the SAME item + * definitions and the SAME action wiring the lane divider's own menu uses + * (`buildLaneMenuGroups` + `useLaneMenuActions`), so the two surfaces cannot + * drift. `open` survives as the escape hatch for a lane this renderer cannot + * resolve from the store, where re-opening the real portal is still the only + * correct answer. + */ +export type SessionContextMenuLaneActions = { + laneId: string; + laneName: string; + /** Opens the real lane context menu, anchored where the session menu opened. */ + open: (position: { x: number; y: number }) => void; +}; export type SessionContextMenuState = { session: TerminalSessionSummary; binding?: OpenProjectBinding | null; machineName?: string | null; + /** Present only for a singleton lane's card (see the type above). */ + laneActions?: SessionContextMenuLaneActions | null; x: number; y: number; } | null; @@ -75,7 +108,34 @@ type SessionContextMenuProps = { onRemoveFromGrid?: (session: TerminalSessionSummary) => void; }; -export function SessionContextMenu({ +/** + * Shell. Owns nothing but the lane manage dialog, which has to outlive the menu + * that opened it — "Manage Lane" closes the menu, so a dialog hosted inside the + * panel would unmount in the same tick it was asked for. + */ +export function SessionContextMenu(props: SessionContextMenuProps) { + const [managedLaneId, setManagedLaneId] = useState(null); + + return ( + <> + {props.menu ? ( + + ) : null} + {managedLaneId ? ( + setManagedLaneId(null)} + /> + ) : null} + + ); +} + +function SessionContextMenuPanel({ menu, onClose, onStopRuntime, @@ -94,15 +154,22 @@ export function SessionContextMenu({ pinnedSessionIds, gridSessionIds, onRemoveFromGrid, -}: SessionContextMenuProps) { + onManageLane, +}: Omit & { + menu: NonNullable; + onManageLane: (laneId: string) => void; +}) { const [renaming, setRenaming] = useState(false); const [tagging, setTagging] = useState(false); - const [snoozing, setSnoozing] = useState(false); + // Captured when the submenu opens rather than on every render: a list that + // re-resolved mid-render could drop the row the pointer is already over if + // the 17:00 boundary happened to pass while the menu was open. + const [snoozePresets, setSnoozePresets] = useState(() => resolveSnoozePresets()); const [draft, setDraft] = useState(""); const inputRef = useRef(null); const finalizedRef = useRef(false); const { ref: menuRef, position: clampedPosition } = useClampedFixedPosition( - menu ? { x: menu.x, y: menu.y } : null, + { x: menu.x, y: menu.y }, renaming || tagging, ); @@ -110,7 +177,6 @@ export function SessionContextMenu({ useEffect(() => { setRenaming(false); setTagging(false); - setSnoozing(false); setDraft(""); finalizedRef.current = false; }, [menu]); @@ -123,9 +189,7 @@ export function SessionContextMenu({ } }, [renaming, tagging]); - if (!menu) return null; - - const { session, binding = null, x, y } = menu; + const { session, binding = null, laneActions = null, x, y } = menu; const menuPosition = clampedPosition ?? { left: x, top: y }; const isRunning = session.status === "running"; const isChat = isChatToolType(session.toolType); @@ -143,6 +207,7 @@ export function SessionContextMenu({ const snoozeWake = isSnoozed ? snoozeWakeLabel(session.snoozedUntil) : null; const isSettled = canonicalPhase === "settled"; const isDeclaredSettled = Boolean(session.settledAt); + const canStopRuntime = isRunning && Boolean(session.ptyId) && !isChat; const chooseSnooze = (key: SnoozeDurationKey) => { void snoozeSessionForDuration(session, key, Date.now(), binding); onClose(); @@ -165,6 +230,55 @@ export function SessionContextMenu({ onClose(); }; + // Hoisted out of the JSX only because the settle branch is a four-way chain; + // inline it would bury the grouping it sits inside. + const settleRow = isSettled ? ( + <> + + {isDeclaredSettled ? ( + + ) : null} + + ) : !isActivelyRunning && onSettle && canDismissNeedsYou ? ( + + ) : canonicalPhase === "needs_you" && !canDismissNeedsYou ? ( + + ) : null; + + const deletingLabel = deletingSessionId === session.id ? "Deleting…" : null; + return ( <> {/* Backdrop */} @@ -180,6 +294,8 @@ export function SessionContextMenu({ }} onPointerDown={(e) => e.stopPropagation()} > + {/* ── Identity: what this row is called and where it sits. Unlabelled; + it is the first block under the cursor and needs no signpost. ── */} {renaming && (
    { setDraft(session.title); setRenaming(true); }} > Rename @@ -230,7 +347,8 @@ export function SessionContextMenu({ ended sessions), so only offer the item while the session runs. */} {!renaming && !tagging && session.toolType === "claude-chat" && isRunning && onSetChatTag ? ( ) : null} + {onTogglePinned ? ( + + ) : null} + {onRemoveFromGrid && (gridSessionIds ?? []).includes(session.id) ? ( + + ) : null} - {isRunning && session.ptyId && !isChat ? ( + {/* ── Lifecycle — every action that changes where the sidebar files this + row: stop (runtime), snooze/wake (visibility) and settle/keep-active + (state). Keep it exhaustive: a row that reaches the end of this block + with nothing rendered is a row the user cannot un-hide. ── */} + + Lifecycle + {canStopRuntime ? ( ) : null} - {isRunning && session.ptyId && !isChat ? ( - - ) : null} - - {/* Lifecycle block — every action that changes where the sidebar files - this row lives here: snooze/wake (visibility) and settle/keep-active - (state). Keep it exhaustive: a row that reaches the end of this block - with nothing rendered is a row the user cannot un-hide. */} {isSnoozed ? ( - ) : snoozing ? ( - SNOOZE_DURATION_OPTIONS.map((option) => ( - - )) ) : ( - - )} - - {isSettled ? ( - <> - - {isDeclaredSettled ? ( + {snoozePresets.map((preset) => ( - ) : null} - - ) : !isActivelyRunning && onSettle && canDismissNeedsYou ? ( + ))} + + )} + + {settleRow} + + {/* ── Go to: the surfaces outside this menu that show the same session. ── */} + + Go to + + {onOpenSessionInWeb ? ( - ) : canonicalPhase === "needs_you" && !canDismissNeedsYou ? ( - ) : null} - {isChat ? ( + {/* ── Copy: near-identical clipboard rows, collapsed behind one. ── */} + - ) : null} + {onCopySessionDeepLink ? ( + + ) : null} + - {!isRunning && !isChat ? ( - + {/* Lane section — only on a singleton row, which has no lane divider to + right-click. Appended to THIS menu rather than bound to a second + gesture on some sub-region of the card: a hidden gesture nobody can + discover is the same as no gesture at all. */} + {laneActions ? ( + ) : null} -
    - - - - - - {onCopySessionDeepLink ? ( + {/* ── Destructive, last and fenced off. ── */} + {canStopRuntime || isChat || (!isRunning && !isChat) ? : null} + {canStopRuntime ? ( ) : null} - - {onOpenSessionInWeb ? ( + {isChat ? ( ) : null} - - {onTogglePinned ? ( + {!isRunning && !isChat ? ( ) : null} - - {onRemoveFromGrid && (gridSessionIds ?? []).includes(session.id) ? ( - <> -
    - - - ) : null}
    ); diff --git a/apps/desktop/src/renderer/components/terminals/SessionHoverCard.tsx b/apps/desktop/src/renderer/components/terminals/SessionHoverCard.tsx new file mode 100644 index 000000000..be095a179 --- /dev/null +++ b/apps/desktop/src/renderer/components/terminals/SessionHoverCard.tsx @@ -0,0 +1,345 @@ +import React from "react"; +import { createPortal } from "react-dom"; +import { motion, useReducedMotion } from "motion/react"; +import { useClampedFixedPosition, type FixedAnchor } from "../../hooks/useClampedFixedPosition"; +import { cn } from "../ui/cn"; +import { MONO_FONT } from "../lanes/laneDesignTokens"; + +/* ────────────────────────────────────────────────────────────────────────── + The session row's DETAIL CARD. + + Not a tooltip, and deliberately not `SmartTooltip`. Three things the shared + tooltip cannot do, none of which can be added to it without changing every + tooltip in the app: + + • it opens on a 320ms tooltip delay — far too eager for a panel this + heavy, which should only appear when someone has genuinely stopped on a + row; + • it positions itself above/below its own trigger, and this card has to + land over the content area to the RIGHT of the sidebar, never on top of + the list it describes; + • its body is a single pre-line string, so it can render `Label: value` + lines and nothing else — no icons, no per-row colour, no clickable row. + + The content model here is icon-led: there are no `Label:` prefixes because + the ICON is the label. Each row's icon carries its own colour, and colour is + load-bearing — see `sessionStatusPresentation.ts` for the one-hue-one-meaning + rule. Amber means "your move" and nothing else; the only amber in this file + is the machine tower, an identity mark, exactly as on the row itself. + ────────────────────────────────────────────────────────────────────────── */ + +/** How long a pointer must rest on a row before the card appears. */ +export const SESSION_HOVER_CARD_DELAY_MS = 1000; +/** Grace for crossing the gap from the row into the card. */ +const SESSION_HOVER_CARD_CLOSE_DELAY_MS = 140; +/** Gap between the sidebar's right edge and the card. */ +const SESSION_HOVER_CARD_GAP = 10; +/** The card hangs a touch above the row it describes, so the row stays visible. */ +const SESSION_HOVER_CARD_LIFT = 6; + +/** + * Module scope on purpose: direct row-to-row handoff crosses hook instances. + * It is deliberately NOT a timed "warm window": only a pointer entering from + * the exact row whose card is open skips the cold delay. Coming from blank + * space, the detail pane, or a row whose card never opened always starts fresh. + */ +let activeHoverCard: { + rowId: string | null; + trigger: HTMLElement; + dismiss: () => void; +} | null = null; + +export type SessionHoverCardRow = { + /** React key + a stable hook for tests (`data-session-hover-row`). */ + id: string; + /** Already coloured by the caller — the icon IS the label. */ + icon: React.ReactNode; + value: React.ReactNode; + /** Branch names, tags: things that have to be copied character-exact. */ + mono?: boolean; + /** + * `fact` (default) is one scannable line; `advice` wraps and recedes — it is + * a suggestion, not an attribute of the session. + */ + variant?: "fact" | "advice"; + /** Turns the row into a clickable target (e.g. "open the parent thread"). */ + onActivate?: () => void; + /** Native title + accessible name for an activatable row. */ + activateLabel?: string; + testId?: string; +}; + +/** + * Hover-intent state machine for one row. Owns the open delay, the close grace + * and the cancel-on-scroll rule; the card itself is pure presentation. + */ +export function useSessionHoverCard(options?: { disabled?: boolean; rowId?: string }): { + /** Non-null while the card should be mounted. */ + anchor: FixedAnchor | null; + triggerProps: Pick, "onMouseEnter" | "onMouseLeave">; + cardProps: Pick, "onMouseEnter" | "onMouseLeave">; + close: () => void; +} { + const disabled = Boolean(options?.disabled); + const rowId = options?.rowId ?? null; + const [anchor, setAnchor] = React.useState(null); + const openTimerRef = React.useRef(null); + const closeTimerRef = React.useRef(null); + // The element measured when the timer FIRES, not when the pointer arrived: + // one second is long enough for the list to have moved underneath. + const triggerElementRef = React.useRef(null); + + /** + * Re-resolve the row by id when the captured node has gone stale. + * + * One second is enough time in this sidebar for the status slot to re-render + * every second for the elapsed ticker, rows re-sort on activity, and lane + * groups run motion layout animations. Any of those can make React swap the + * wrapper node, and the element captured on mouse-enter is then + * `isConnected === false`. Bailing on that made the card silently never + * appear in the running app while every static test still passed — the whole + * failure mode only exists once the list is actually alive. + */ + const resolveTriggerElement = React.useCallback((): HTMLElement | null => { + const captured = triggerElementRef.current; + if (captured?.isConnected) return captured; + if (!rowId) return null; + const selector = `[data-session-row][data-session-id="${CSS.escape(rowId)}"]`; + const replacement = document.querySelector(selector); + // Only adopt a replacement the pointer is genuinely still resting on — + // otherwise a row that scrolled away would pop a card for a cursor that + // has long since moved on. + if (replacement && replacement.matches(":hover")) { + triggerElementRef.current = replacement; + return replacement; + } + return null; + }, [rowId]); + + const clearTimers = React.useCallback(() => { + if (openTimerRef.current != null) { + window.clearTimeout(openTimerRef.current); + openTimerRef.current = null; + } + if (closeTimerRef.current != null) { + window.clearTimeout(closeTimerRef.current); + closeTimerRef.current = null; + } + }, []); + + const close = React.useCallback(() => { + clearTimers(); + setAnchor(null); + if (activeHoverCard?.rowId === rowId) activeHoverCard = null; + }, [clearTimers, rowId]); + + const onMouseEnter = React.useCallback( + (event: React.MouseEvent) => { + if (disabled) return; + const element = event.currentTarget; + const related = event.relatedTarget; + const sourceRow = related instanceof Element + ? related.closest("[data-session-row]") + : null; + const directHandoff = Boolean( + activeHoverCard + && sourceRow + && sourceRow !== element + && activeHoverCard.trigger === sourceRow, + ); + + // One hover pane at a time. A direct sibling handoff opens below without + // delay; every other arrival dismisses stale UI and earns a fresh second. + activeHoverCard?.dismiss(); + triggerElementRef.current = element; + clearTimers(); + + const open = () => { + const target = resolveTriggerElement(); + if (!target) return; + const rect = target.getBoundingClientRect(); + /* The row is full-bleed (see `SESSION_ROW_BLEED_CLASS`), so its right + edge IS the sidebar pane's right edge — no pane ref, no data + attribute on a file another owner maintains. The card therefore + lands over the content area, never over the list it describes. */ + setAnchor({ x: rect.right + SESSION_HOVER_CARD_GAP, y: rect.top - SESSION_HOVER_CARD_LIFT }); + activeHoverCard = { rowId, trigger: target, dismiss: close }; + }; + + if (directHandoff) { + open(); + return; + } + openTimerRef.current = window.setTimeout(() => { + openTimerRef.current = null; + open(); + }, SESSION_HOVER_CARD_DELAY_MS); + }, + [clearTimers, close, disabled, resolveTriggerElement, rowId], + ); + + const onMouseLeave = React.useCallback(() => { + if (openTimerRef.current != null) { + // Still waiting: a leave cancels outright, with no close grace. + window.clearTimeout(openTimerRef.current); + openTimerRef.current = null; + } + if (closeTimerRef.current != null) window.clearTimeout(closeTimerRef.current); + closeTimerRef.current = window.setTimeout(() => { + closeTimerRef.current = null; + close(); + }, SESSION_HOVER_CARD_CLOSE_DELAY_MS); + }, [close]); + + const onCardEnter = React.useCallback(() => { + if (closeTimerRef.current != null) { + window.clearTimeout(closeTimerRef.current); + closeTimerRef.current = null; + } + }, []); + + // A scrolled list moves the anchor out from under the card, and re-measuring + // mid-scroll would make it chase the row. Cancel instead — the pointer has + // not "rested" on anything while the list is moving. + React.useEffect(() => { + if (!anchor && openTimerRef.current == null) return undefined; + const cancel = () => close(); + window.addEventListener("scroll", cancel, true); + window.addEventListener("resize", cancel); + return () => { + window.removeEventListener("scroll", cancel, true); + window.removeEventListener("resize", cancel); + }; + }, [anchor, close]); + + React.useEffect(() => clearTimers, [clearTimers]); + + return { + anchor, + triggerProps: { onMouseEnter, onMouseLeave }, + cardProps: { onMouseEnter: onCardEnter, onMouseLeave }, + close, + }; +} + +/** Test seam: the active cross-row handoff is module state. */ +export function resetSessionHoverCardGroupForTests(): void { + activeHoverCard = null; +} + +export function SessionHoverCard({ + anchor, + title, + rows, + cardProps, +}: { + anchor: FixedAnchor; + title: string; + rows: SessionHoverCardRow[]; + cardProps?: Pick, "onMouseEnter" | "onMouseLeave">; +}) { + // `rows.length` as the remeasure key: the card's height changes with its + // content, and a stale height would clamp against the wrong box. + const { ref, position } = useClampedFixedPosition(anchor, rows.length); + const reduceMotion = useReducedMotion(); + + const card = ( + . + Measured in the browser: inline `top: 76px` resolved to a real + `rect.top` of 938 in an 862px viewport, i.e. entirely below the fold. + The card was mounting and painting correctly the whole time; nobody + could see it. Inline beats both rules and pins the intent here. */ + position: "fixed", + left: position?.left ?? anchor.x, + top: position?.top ?? anchor.y, + }} + {...cardProps} + > +
    + {title} +
    +
    + {rows.map((row) => ( + + ))} +
    +
    + ); + + return typeof document !== "undefined" && document.body + ? createPortal(card, document.body) + : card; +} + +function SessionHoverCardFact({ row }: { row: SessionHoverCardRow }) { + const advice = row.variant === "advice"; + const body = ( + <> + + {row.icon} + + + {row.value} + + + ); + + const className = cn( + "flex w-full gap-2 rounded-md px-1 py-0.5 text-left text-[12px]", + // Advice wraps to several lines, so its icon aligns to the first line. + advice ? "items-start" : "items-center", + ); + + if (!row.onActivate) { + return ( +
    + {body} +
    + ); + } + return ( + + ); +} diff --git a/apps/desktop/src/renderer/components/terminals/SessionInfoPopover.tsx b/apps/desktop/src/renderer/components/terminals/SessionInfoPopover.tsx index 9cdadf16a..e0d1b674c 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionInfoPopover.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionInfoPopover.tsx @@ -8,14 +8,27 @@ import { Trash, X, } from "@phosphor-icons/react"; -import type { TerminalSessionStatus, TerminalSessionSummary } from "../../../shared/types"; -import { sanitizeTerminalInlineText } from "../../lib/terminalAttention"; +import type { TerminalSessionSummary } from "../../../shared/types"; +import { + canonicalInputFromSummary, + sanitizeTerminalInlineText, + sessionCanonicalUiState, + sessionStatusDisplay, +} from "../../lib/terminalAttention"; +import { SESSION_TONE_TEXT_CLASS } from "../../../shared/sessionStatusPresentation"; import { formatToolTypeLabel, isChatToolType } from "../../lib/sessions"; import { getTerminalRuntimeSnapshot } from "./TerminalView"; import { SessionDeltaCard } from "./SessionDeltaCard"; import { Button } from "../ui/Button"; import { SmartTooltip } from "../ui/SmartTooltip"; +/** + * The raw PROCESS column, not a session status. This is a different axis from + * the row's state — "the pty is idle" is a fact about the process, not a claim + * about whether the session wants you — so it carries no tone and never + * competes with the canonical label above it. Keeping it is not a second status + * vocabulary; it is the only place the runtime column is spelled out. + */ function runtimeStateLabel(state: TerminalSessionSummary["runtimeState"]): string { if (state === "waiting-input") return "Waiting for input"; if (state === "exited") return "Exited"; @@ -25,16 +38,9 @@ function runtimeStateLabel(state: TerminalSessionSummary["runtimeState"]): strin return state; } -function formatSessionStatus(s: TerminalSessionStatus): string { - const map: Record = { - running: "Running", - completed: "Completed", - failed: "Failed", - disposed: "Disposed", - detached: "Ended", - }; - return map[s] ?? s; -} +/** `[label, value]`, plus an optional class for the value — only the canonical + * status row uses it, and only with a class from the shared tone table. */ +type InfoRow = readonly [label: string, value: string, valueClassName?: string]; function formatWhen(iso: string): string { try { @@ -153,6 +159,23 @@ export function SessionInfoPopover({ const { session, anchor } = popover; const isChat = isChatToolType(session.toolType); + + /* The "State" row reads through the ONE presentation vocabulary, same as the + sidebar row, the attention center and iOS: `running` → "Working", + `ready`/`idle` → "Done", amber only for "your move", `stopped` neutral + rather than red. This popover used to keep its own table and was the last + surface in the app still saying "Completed". + + `settled` is the single phase the shared module deliberately returns null + for: on a row the settled *section* is the status, so the slot shows a + timestamp instead. A key/value sheet has no section to lean on, so it names + the phase. That is the documented gap, not a second table. */ + const canonicalInput = canonicalInputFromSummary(session); + const canonicalPhase = sessionCanonicalUiState(canonicalInput).phase; + const statusPresentation = sessionStatusDisplay(canonicalInput); + const statusLabel = statusPresentation?.label + ?? (canonicalPhase === "settled" ? "Settled" : canonicalPhase); + const statusToneClass = SESSION_TONE_TEXT_CLASS[statusPresentation?.tone ?? "neutral"]; const runtime = getTerminalRuntimeSnapshot(session.id); const health = !isChat && runtime ? runtime.health ?? null : null; @@ -189,7 +212,12 @@ export function SessionInfoPopover({
    @@ -215,7 +243,9 @@ export function SessionInfoPopover({ [ ["Title", (session.goal ?? session.title).trim() || "—"], ["Lane", session.laneName || "—"], - ["State", formatSessionStatus(session.status)], + // The one row that carries a hue, and it comes from the + // shared tone table — never from a colour picked here. + ["State", statusLabel, statusToneClass], ["Process", runtimeStateLabel(session.runtimeState)], session.toolType ? ["Tool", formatToolTypeLabel(session.toolType)] : null, session.exitCode != null ? ["Exit code", String(session.exitCode)] : null, @@ -231,11 +261,11 @@ export function SessionInfoPopover({ isChat && session.archivedAt ? ["Archived", formatWhen(session.archivedAt)] : null, ["Started", formatWhen(session.startedAt)], session.endedAt ? ["Ended", formatWhen(session.endedAt)] : null, - ["Session id", session.id] as [string, string], - ] as const + ["Session id", session.id], + ] as ReadonlyArray ) - .filter((row): row is [string, string] => row != null) - .map(([label, value]) => ( + .filter((row): row is InfoRow => row != null) + .map(([label, value, valueClassName]) => (
    ) : ( - {value} + + {value} + )}
    ))} diff --git a/apps/desktop/src/renderer/components/terminals/SessionListPane.test.tsx b/apps/desktop/src/renderer/components/terminals/SessionListPane.test.tsx index 6cb1590fc..781d43bb4 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionListPane.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionListPane.test.tsx @@ -1,11 +1,13 @@ /* @vitest-environment jsdom */ -import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import type { ComponentProps } from "react"; import { MemoryRouter } from "react-router-dom"; import { afterEach, describe, expect, it, vi } from "vitest"; -import type { LaneSummary, TerminalSessionSummary } from "../../../shared/types"; +import type { LaneSummary, PrSummary, TerminalSessionSummary } from "../../../shared/types"; import { createPendingLaneDeleteProgress } from "../../lib/laneDeleteProgress"; +import { eventMatchesBinding } from "../../lib/keybindings"; +import { THIS_MACHINE_NAME } from "../../../shared/machineIdentity"; import { useAppStore, type CrossMachineMachineLanes } from "../../state/appStore"; import { resetCrossMachineLaneSyncForTest } from "../../state/crossMachineLanes"; import { SessionListPane } from "./SessionListPane"; @@ -20,6 +22,64 @@ vi.mock("./ToolLogos", () => ({ ToolLogo: () => , })); +// A pass-through recorder, not a stub: every existing test still renders the +// real card (and queries its real text), while the prop-contract tests below can +// read what this pane actually handed each row. +const { sessionCardPropsForTest } = vi.hoisted(() => ({ + sessionCardPropsForTest: [] as Record[], +})); +vi.mock("./SessionCard", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + SessionCard: (props: ComponentProps) => { + sessionCardPropsForTest.push(props as unknown as Record); + return ; + }, + }; +}); + +/** Props the pane passed for one session id in the most recent render pass. */ +function cardPropsFor(sessionId: string): Record | undefined { + return sessionCardPropsForTest.find( + (props) => (props.session as TerminalSessionSummary | undefined)?.id === sessionId, + ); +} + +// The lane-PR hook does its own coalesced IPC read; the header badge only cares +// about the map it returns, so tests seed that map directly. +const { lanePrsByLaneIdForTest } = vi.hoisted(() => ({ + lanePrsByLaneIdForTest: new Map(), +})); +vi.mock("./useLanePrs", () => ({ + useLanePrsByLaneId: () => lanePrsByLaneIdForTest, +})); + +function makePr(overrides: Partial = {}): PrSummary { + return { + id: "pr-1", + laneId: "lane-known", + projectId: "project-a", + repoOwner: "acme", + repoName: "ade", + githubPrNumber: 959, + githubUrl: "https://github.com/acme/ade/pull/959", + githubNodeId: null, + title: "Refine the Work sidebar", + state: "open", + baseBranch: "main", + headBranch: "known-lane", + checksStatus: "pending", + reviewStatus: "none", + additions: 0, + deletions: 0, + lastSyncedAt: null, + createdAt: "2026-07-28T10:00:00.000Z", + updatedAt: "2026-07-28T10:00:00.000Z", + ...overrides, + }; +} + function makeLane(overrides: Partial = {}): LaneSummary { return { id: "lane-known", @@ -105,9 +165,34 @@ function renderPane(props: Partial> = {}) ); } +/** The one shared header shape: sticky wrapper + the hairline row inside it. */ +function headerRow(sectionId: string): HTMLElement | null { + return document.querySelector( + `[data-section-id="${sectionId}"] > div`, + ); +} + +/** + * The quiet shelves default to CLOSED, and record only the explicit expand, so + * a test that wants to look inside one has to open it the way a user does — + * `shelf-open:`, not the absence of a collapse entry. + */ +const OPEN_QUIET_SHELVES = [ + "shelf-open:status:snoozed", + "shelf-open:status:settled", + "shelf-open:lane-shelf:snoozed", + "shelf-open:lane-shelf:settled", +]; + +/** The chevron at the right end of a group header — same toggle as its label. */ +function headerChevron(sectionId: string): HTMLElement { + return screen.getByTestId(`section-chevron-${sectionId}`); +} + describe("SessionListPane", () => { afterEach(() => { cleanup(); + lanePrsByLaneIdForTest.clear(); useAppStore.setState({ laneDeleteProgressByLaneId: {} }); Reflect.deleteProperty(window, "ade"); }); @@ -164,6 +249,14 @@ describe("SessionListPane", () => { laneName: "Known Lane", title: "Deleting lane chat", }); + // Two rows, so the lane keeps a header to disable: a singleton lane renders + // its group without one. + const sibling = makeSession({ + id: "session-sibling", + laneId: "lane-known", + laneName: "Known Lane", + title: "Sibling chat", + }); useAppStore.setState({ laneDeleteProgressByLaneId: { "lane-known": createPendingLaneDeleteProgress("lane-known"), @@ -171,15 +264,17 @@ describe("SessionListPane", () => { }); renderPane({ - runningFiltered: [session], - allSessionsUnfiltered: [session], - sessionsGroupedByLane: new Map([[session.laneId, [session]]]), + runningFiltered: [session, sibling], + allSessionsUnfiltered: [session, sibling], + sessionsGroupedByLane: new Map([[session.laneId, [session, sibling]]]), onSelectSession, }); const chatButton = screen.getByRole("button", { name: "Deleting lane chat: Deleting lane" }); const laneHeaderButton = screen.getByText("Known Lane").closest("button"); - expect((chatButton as HTMLButtonElement).disabled).toBe(true); + // The card is a `div role="button"`, so it reports inertness with + // `aria-disabled`; the header's collapse toggle is a real button. + expect(chatButton.getAttribute("aria-disabled")).toBe("true"); expect((laneHeaderButton as HTMLButtonElement | null)?.disabled).toBe(true); fireEvent.click(chatButton); expect(onSelectSession).not.toHaveBeenCalled(); @@ -212,12 +307,14 @@ describe("SessionListPane", () => { snoozedFiltered: [snoozed], allSessionsUnfiltered: [snoozed, settled], sessionsGroupedByLane: new Map(), + workCollapsedSectionIds: OPEN_QUIET_SHELVES, }); const snoozedHeading = screen.getByRole("heading", { name: "Snoozed (1)" }); expect(snoozedHeading).toBeTruthy(); expect(screen.getByText("Snoozed chat")).toBeTruthy(); - expect(screen.getByLabelText("Snoozed, wakes in 3h")).toBeTruthy(); + // The return ticket IS the status now — no separate snoozed chip. + expect(document.querySelector('[data-session-status="wakes in 3h"]')).toBeTruthy(); // Snoozed must sit ABOVE Settled in DOM order. const settledHeader = screen.getByText("Settled").closest("[data-section-id]"); @@ -235,7 +332,9 @@ describe("SessionListPane", () => { renderPane(); fireEvent.click(screen.getAllByRole("button", { name: "Snooze session" })[0]!); - fireEvent.click(screen.getByRole("menuitem", { name: "Until tomorrow 9am" })); + // Matched on the label alone: the row's trailing time column is formatted + // via `toLocaleTimeString`, so an exact accessible name would be brittle. + fireEvent.click(screen.getByRole("menuitem", { name: /^Tomorrow/ })); await waitFor(() => expect(snoozeSession).toHaveBeenCalledTimes(1)); const [sessionId, untilIso] = snoozeSession.mock.calls[0]!; @@ -378,7 +477,7 @@ describe("SessionListPane", () => { expect(screen.getByTestId("work-lane-filter-active-indicator")).toBeTruthy(); }); - it("bolds only the session name in sidebar cards", () => { + it("weights only the session name in sidebar cards", () => { const session = makeSession({ id: "session-style", laneId: "lane-known", @@ -394,34 +493,22 @@ describe("SessionListPane", () => { }); const title = screen.getByText("Style target session"); - const row = title.closest("button"); + // The row is a `div role="button"`: it hosts the in-flow snooze/settle + // buttons, which cannot legally nest inside a native -
    - {machineMarker} - {prBadge} - {quietCounts.snoozed > 0 ? ( - - - {quietCounts.snoozed} - - ) : null} - {quietCounts.settled > 0 ? ( - - - {quietCounts.settled} - - ) : null} -
    -
    - ); +/** + * Non-lane shelf tones, following t3's Sidebar V2 convention: Snoozed is blue + * (hidden for now, still yours), Settled is muted (done, out of the way). + * Everything else is the neutral hairline. + */ +type GroupShelfTone = "default" | "snoozed" | "settled"; + +const SHELF_TONE_LABEL_CLASS: Record = { + default: "text-fg/75", + snoozed: "text-blue-400", + settled: "text-muted-fg/50", +}; + +/** + * The rule that replaces the old bordered pill. It carries the shelf's colour + * at a fraction of the label's strength, so the hue is legible without the row + * ever painting a surface. + */ +const SHELF_TONE_RULE_CLASS: Record = { + default: "bg-white/[0.06]", + snoozed: "bg-blue-400/15", + settled: "bg-white/[0.06]", +}; + +/** + * The quiet shelves — Snoozed and Settled, as `status:*` in the by-status and + * by-time columns and as `lane-shelf:*` in the by-lane one. They are the only + * sections in this pane whose DEFAULT is collapsed: the whole point of snoozing + * or settling a row is to stop looking at it, so a freshly opened sidebar that + * greets you with the rows you told it to hide has undone the gesture that put + * them there. Every other section keeps the plain default (expanded). + * + * `workCollapsedSectionIds` is a list of sections that ARE collapsed, so absence + * means expanded — a shape that cannot express "closed until you say + * otherwise". The quiet shelves therefore record the OPPOSITE fact: an explicit + * expand writes `shelf-open:`, exactly like the per-lane `lane-open:` + * and `snoozed-open:` markers further down. That keeps three states apart where + * the plain list only had two — never touched (no marker, closed), explicitly + * opened (marker present, and it survives a reload), explicitly closed again + * (marker removed). A legacy `status:settled` entry left over from when the + * shelf defaulted to open is inert now: it said "collapsed", which is what the + * shelf does anyway. + */ +function quietShelfOpenMarker(sectionId: string): string { + return `shelf-open:${sectionId}`; +} + +function isQuietShelfCollapsed(collapsedSectionIds: string[], sectionId: string): boolean { + return !collapsedSectionIds.includes(quietShelfOpenMarker(sectionId)); } function StickyGroupHeader({ @@ -317,11 +406,13 @@ function StickyGroupHeader({ machineMarker = null, headerAction = null, variant = "default", + tone = "default", busyLabel = null, heading = false, dimmed = false, quietCounts = null, pinned = false, + headerless = false, dragProps = null, dropIndicatorEdge = null, layoutDependency, @@ -354,8 +445,15 @@ function StickyGroupHeader({ dimmed?: boolean; /** Compact action shown next to the count for non-lane headers. */ headerAction?: React.ReactNode; - /** `lane` uses a larger header and pads the nested session list. */ - variant?: "default" | "lane"; + /** + * `lane` carries the lane accent on its name and indents its nested session + * list behind a lane-tinted rail. `quiet-shelf` is the Snoozed/Settled zone at + * the foot of the column: a grey uppercase label with no rule at all, so it + * cannot be mistaken for a lane divider. + */ + variant?: "default" | "lane" | "quiet-shelf"; + /** Shelf colour for non-lane headers (Snoozed blue, Settled muted). */ + tone?: GroupShelfTone; /** Disables the lane group and overlays lifecycle progress. */ busyLabel?: string | null; /** Quiet-lane mode plus inline `☾n ○n` counts replacing the total pill. */ @@ -366,6 +464,13 @@ function StickyGroupHeader({ * pin keeps the full-height header and adds a glyph instead. */ pinned?: boolean; + /** + * Render the group WITHOUT its divider — the singleton form. The body still + * renders (and the group still files into whatever shelf holds it); only the + * header is gone, because a divider describing a group of one is pure chrome + * and the lone card carries the lane identity instead. + */ + headerless?: boolean; /** Native drag wiring for manual reorder; null disables dragging this row. */ dragProps?: React.HTMLAttributes | null; /** Which side of this lane a pending drop would land on. */ @@ -383,6 +488,7 @@ function StickyGroupHeader({ const [sliding, setSliding] = useState(false); if (count === 0) return null; const isLane = variant === "lane"; + const isQuietShelf = variant === "quiet-shelf"; const isQuietLane = isLane && quietCounts != null && !pinned; const stickyClass = sliding ? "relative" : "sticky top-0"; const branchText = subLabel?.trim() ?? ""; @@ -393,8 +499,50 @@ function StickyGroupHeader({ // PR badge off the edge. Non-lane group headers keep their sub-label. const showBranchCluster = !isLane && branchText.length > 0; const laneHeaderTitle = branchText ? `${label} · ${branchText}` : label; - const laneTint = laneSurfaceTint(accentColor, isLane ? "pastel" : "soft"); - const laneLabelColor = isLane && laneTint.text ? laneTint.text : accentColor ?? undefined; + // `laneSurfaceTint` is now consulted for its TEXT channel only. The background, + // border, and left-accent it also returns are deliberately unused here: surface + // is reserved for interaction, so the lane accent moved onto the lane NAME. + // Lane = accent colour + LaneIcon, branch = muted + BranchIcon, never crossed. + const laneTint = laneSurfaceTint(accentColor, "pastel"); + const laneLabelColor = isLane ? laneTint.text ?? accentColor ?? undefined : undefined; + // Count placement. Every disclosure follows the same rule: collapsed folds + // the hidden item count into the label (`Settled (12)`, `Lane name (3)`), + // expanded shows the bare label because the rows themselves are visible. + const showInlineCount = collapsed; + const labelText = showInlineCount ? `${label} (${count})` : label; + // The chevron is a second hit target for the SAME toggle, never decoration — + // it looks like the control, so it has to behave like it. It cannot live + // inside the label button: it sits at the far end of the trailing cluster, + // right of the PR badge, and the row deliberately is not one big + ); return ( // One rhythm: the gap between groups belongs to the list container, not to // per-variant margins here. Mixing `space-y-1` with `mb-1.5` / `mb-px` gave @@ -404,12 +552,17 @@ function StickyGroupHeader({ // A lane that goes quiet sinks immediately; `layout="position"` makes it // slide there. NOT full `layout` — size correction fights the collapse // body's height animation below and squashes the row. + // + // The same transition carries the singleton threshold: a lane gaining its + // second session gains a header, and every group below it slides down on + // this 0.24s ease instead of snapping (the caller folds headerlessness + // into `layoutDependency` so the measurement actually happens). layout={layoutDependency ? "position" : false} layoutDependency={layoutDependency} transition={{ duration: 0.24, ease: [0.22, 1, 0.36, 1] }} onLayoutAnimationStart={() => setSliding(true)} onLayoutAnimationComplete={() => setSliding(false)} - className={cn("relative", !isLane && "mt-0.5 first:mt-0", dimmed && "opacity-55")} + className={cn("relative", dimmed && "opacity-55")} data-dimmed={dimmed ? "true" : undefined} > {dropIndicatorEdge ? ( @@ -425,158 +578,159 @@ function StickyGroupHeader({ }} /> ) : null} - {isQuietLane ? ( - - ) : isLane ? ( - // The lane header is a flex row, NOT one big + {/* Trailing cluster. The machine marker and PR badge survive EVERY + form of this row — quiet, collapsed, shelf — because "there is an + open PR on this" is not redundant with the lane name. Counts live + only in collapsed labels, never as a separate trailing element. */} +
    + {pinned ? ( + + ) : null} + {machineMarker} + {prBadge} + {headerAction} + {quietCounts && isQuietLane && quietCounts.snoozed > 0 ? ( + + + {quietCounts.snoozed} + + ) : null} + {quietCounts && isQuietLane && quietCounts.settled > 0 ? ( + + + {quietCounts.settled} + ) : null} + {chevron} +
    + {busyLabel ? ( +
    + + {busyLabel}
    ) : null} - -
    - {pinned ? ( - - ) : null} - {machineMarker} - {prBadge} - - {count} - -
    - {busyLabel ? ( -
    - - {busyLabel} -
    - ) : null} -
    - ) : ( -
    - -
    - {headerAction} - - {count} -
    )} - {/* Children slide out/retract smoothly; the header stays put (no reflow jump). */} + {/* Children slide out/retract smoothly; the header stays put (no reflow jump). + A headerless group has no toggle to collapse it with, so it is always open. */} - {!collapsed && count > 0 ? ( + {(!collapsed || headerless) && count > 0 ? ( -
    + {/* The hierarchy cue. A grouped child is INDENTED and sits beside a + rail running the full height of the group; a singleton's card + stays flush-left with no rail at all. Without this the two were + identical — same indent, same size — and the only thing telling + them apart was a coloured dot in a header floating above. + Indentation is the clearest cue available and the rail binds the + rows into one object; a headerless group gets neither, because + with no divider above it the lone card IS the group and inset + rows under nothing would read as orphaned. */} +
    + {isLane && !headerless ? ( + + ) : null} {children}
    @@ -595,45 +785,16 @@ function StickyGroupHeader({ ); } -/** - * Compact PR status badge on a lane divider: state-colored dot + `#` + - * one-word state. Clicking deep-links into the PRs tab. Rendered inside the - * header button, so it stops propagation to avoid also toggling the section. +/* + * The lane divider's PR badge is `LanePrBadge` (./LanePrBadge) — shared with + * the singleton lane's card, which has no divider to hang it on. * * (`useLanePrsByLaneId` moved to ./useLanePrs so the Work session hook can - * answer the "Has PR" chip filter from the same PR data this badge renders.) + * answer the "Has PR" chip filter from the same PR data that badge renders.) */ -function LanePrHeaderBadge({ pr, onOpen }: { pr: PrSummary; onOpen: () => void }) { - const color = lanePrStateColor(pr.state); - const label = lanePrStateLabel(pr.state); - const open = (event: React.SyntheticEvent) => { - event.stopPropagation(); - onOpen(); - }; - return ( - { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - open(event); - } - }} - className="inline-flex shrink-0 items-center gap-1 rounded-full border border-white/10 bg-white/[0.04] px-1.5 py-px text-[10px] font-medium leading-none text-muted-fg/70 transition-colors hover:bg-white/[0.09]" - title={`Pull request #${pr.githubPrNumber} · ${label}`} - aria-label={`Pull request #${pr.githubPrNumber}, ${label}`} - > - - #{pr.githubPrNumber} - {label} - - ); -} export const SessionListPane = React.memo(function SessionListPane({ - lanes, + lanes: lanesProp, runningFiltered, awaitingInputFiltered, endedFiltered, @@ -644,7 +805,10 @@ export const SessionListPane = React.memo(function SessionListPane({ filterLaneId, setFilterLaneId, q, - setQ, + // The inline input is gone (search now opens the command palette), but the + // query itself still drives every filter in this file and the setter stays in + // the contract so the parent can keep restoring/clearing it. + setQ: _setQ, selectedSessionId, selectedSessionIds, draftKind: _draftKind, @@ -725,6 +889,12 @@ export const SessionListPane = React.memo(function SessionListPane({ e: React.MouseEvent, binding?: OpenProjectBinding | null, machineName?: string | null, + /** + * Set only for a singleton lane's card. That row has no lane divider above + * it, so its session menu is the only surface left that can reach lane + * management — the menu appends a lane section when this is present. + */ + laneActions?: SessionContextMenuLaneActions | null, ) => void; sessionListOrganization: WorkSessionListOrganization; setSessionListOrganization: (v: WorkSessionListOrganization) => void; @@ -757,8 +927,57 @@ export const SessionListPane = React.memo(function SessionListPane({ crossMachineSyncActive?: boolean; }) { const navigate = useNavigate(); + /** + * Primary's accent is locked to ADE purple here, once, rather than at each of + * the half-dozen places a lane colour is read: the header, the singleton + * card's lane identity, the lane combobox and the shelves all take it from + * this list, and a primary lane created before the colour was reserved still + * stores null. + */ + const lanes = useMemo( + () => (lanesProp.some((lane) => lane.laneType === "primary") + ? lanesProp.map((lane) => ( + lane.laneType === "primary" && lane.color !== resolveLaneAccentColor(lane) + ? { ...lane, color: resolveLaneAccentColor(lane) } + : lane + )) + : lanesProp), + [lanesProp], + ); const prsByLaneId = useLanePrsByLaneId(); const deleteProgressByLaneId = useAppStore((state) => state.laneDeleteProgressByLaneId); + // Names the machine this tab's own lanes live on — this Mac, unless the tab is + // bound to a remote runtime. Only read for the Primary machine badge. + const projectBinding = useAppStore((state) => state.projectBinding); + const keybindings = useAppStore((state) => state.keybindings); + const commandPaletteBinding = useMemo( + () => getEffectiveBinding(keybindings, "commandPalette.open", "Mod+K"), + [keybindings], + ); + const commandPaletteShortcut = useMemo( + () => shortcutChipLabel(commandPaletteBinding), + [commandPaletteBinding], + ); + /** + * The palette's `open` state lives in `AppShell`'s local state and has no + * programmatic entry point — the only way in is the global `keydown` listener + * AppShell installs for `commandPalette.open`. So the search button re-plays + * exactly that key rather than reaching across the tree, and it plays the + * user's OWN binding, so a rebound palette still opens from here. + */ + const openCommandPalette = useCallback(() => { + const combo = parsePrimaryCombo(commandPaletteBinding); + if (!combo) return; + window.dispatchEvent(new KeyboardEvent("keydown", { + key: combo.key, + ctrlKey: combo.ctrl, + metaKey: combo.meta, + altKey: combo.alt, + shiftKey: combo.shift, + bubbles: true, + cancelable: true, + })); + }, [commandPaletteBinding]); // The Work sidebar is a union across every connected machine, always — // independent of which machine this tab is bound to. That is the whole point: // you see work in flight anywhere without switching tabs. @@ -865,19 +1084,32 @@ export const SessionListPane = React.memo(function SessionListPane({ // Quietness is a property of the full lane roster, not the search/filter // subset. Otherwise a filtered-out needs-you row can disappear from this // check and let the lane fold into a thin quiet header. - const unfilteredQuietIdSet = useMemo(() => { + // + // 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 + // row has raised its hand is not snoozed here either, for free. + const unfilteredQuietBuckets = useMemo(() => { const nowMs = Date.now(); - const ids = new Set(); + const snoozed = new Set(); + const settled = new Set(); + const all = new Set(); for (const session of allSessionsUnfiltered) { const bucket = sessionFilingBucket(session, nowMs); - if (bucket === "snoozed" || bucket === "settled") { - ids.add(session.id); + if (bucket === "snoozed") { + snoozed.add(session.id); + all.add(session.id); + } else if (bucket === "settled") { + settled.add(session.id); + all.add(session.id); } } - return ids; + 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]); + const unfilteredQuietIdSet = unfilteredQuietBuckets.all; const unfilteredSessionsByLane = useMemo(() => { const map = new Map(); for (const session of allSessionsUnfiltered) { @@ -1047,6 +1279,96 @@ export const SessionListPane = React.memo(function SessionListPane({ [workPinnedLaneIds], ); + // Foreign lanes worth a row: ones with chats, after the same search, lane, and + // chip filters the local list applies. Lanes elsewhere with nothing running + // stay out of the sidebar — the union is about work in flight, not an inventory. + // + // Resolved before the lane ordering below because the Primary machine badge + // counts VISIBLE primaries, foreign ones included, and a lane whose every chat + // has been filtered out renders nothing and so must not count. + const visibleForeignRows = useMemo(() => { + if (foreignRows.length === 0) return EMPTY_FOREIGN_ROWS; + const query = q.trim().toLowerCase(); + const nowMs = Date.now(); + const rows: CrossMachineLaneRow[] = []; + for (const row of foreignRows) { + if (laneFilterActive && row.lane.id !== normalizedFilterLaneId) continue; + const sessions = query || chipFiltersActive + ? row.sessions.filter((session) => { + if (query && !`${primarySessionLabel(session)} ${row.lane.name}`.toLowerCase().includes(query)) { + return false; + } + return !chipFiltersActive || matchesWorkSessionFilters(session, workSessionFilters, { + nowMs, + // PR summaries are local to the active runtime. Fail closed for a + // remote lane instead of incorrectly treating an unknown PR as open. + laneHasPr: () => false, + laneIsDirty: (laneId) => laneId === row.lane.id && row.lane.status.dirty, + }); + }) + : row.sessions; + if (sessions.length === 0) continue; + // Another machine's Primary is still a Primary: it takes the same reserved + // purple as this machine's, which is what makes "purple = Primary" hold + // across the whole union. + const lane = row.lane.laneType === "primary" && row.lane.color !== resolveLaneAccentColor(row.lane) + ? { ...row.lane, color: resolveLaneAccentColor(row.lane) } + : row.lane; + 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]); + + /** + * Which shelf a CROSS-MACHINE lane files into, or null to stay in the inbox. + * + * The shelving rule has to be one rule — "a lane whose sessions are all quiet + * 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. + * + * 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 + * went away — so reachability is orthogonal to whether the work is done. + * Both halves of that: + * - offline must not BLOCK a demotion: if every row is quiet the lane files + * 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 + * `foreignRowHasLiveWork`, which folds `online` in on purpose — but only + * for the COLLAPSE default, where "unreachable" really does mean "not + * live right now". + * + * Recomputed per render rather than memoized, like the partition it replaced: + * quietness is a function of `Date.now()` (snooze windows lapse), and a memo + * keyed on the row array would keep serving a verdict taken before expiry. + */ + const foreignLaneShelving: ForeignLaneEntry[] = visibleForeignRows.map((row) => { + const compositeLaneId = `${row.machineId}:${row.lane.id}`; + const quiet = partitionQuietSessions(row.sessions); + 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 + // and must not migrate to the bottom on a quiet afternoon. Both pin keys + // are checked because a foreign lane is addressed by its composite id + // 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 (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 { row, compositeLaneId, quiet, shelf }; + }); + /** * Render order for the by-lane list: primary first, then pinned → active → * quiet, with the chosen sort mode applied inside each tier. @@ -1082,9 +1404,145 @@ export const SessionListPane = React.memo(function SessionListPane({ }, [isLaneQuiet, lanes, unfilteredSessionsByLane, workLaneOrder, workLaneSortMode, workPinnedLaneIdSet]); const renderedLaneIds = useMemo(() => orderedLanes.map((lane) => lane.id), [orderedLanes]); + + /** + * The Primary machine badge, or null. + * + * Every ADE machine has a Primary, so with two machines connected the sidebar + * shows two lanes called "Primary" in the same purple and neither says which + * machine it is. Foreign ones already answer that through `LaneMachineMarker`; + * the LOCAL one has no marker at all, because "not here" is the only thing the + * marker normally means. + * + * So this follows `LaneMachineMarker`'s own adaptive rule rather than inventing + * a parallel mechanism: promote the machine name only when the row would + * otherwise be ambiguous. One Primary on screen — the overwhelmingly common + * case — is unambiguous and pays nothing. + * + * "This machine" is the machine the tab is BOUND to, which is not necessarily + * this Mac: a remote-bound tab's local list is that runtime's lanes. + */ + const primaryLaneMachineMarker = useMemo((): CrossMachineLaneMarker | null => { + const localPrimaries = orderedLanes.filter((lane) => lane.laneType === "primary").length; + if (localPrimaries === 0) return null; + const foreignPrimaries = visibleForeignRows + .filter((row) => row.lane.laneType === "primary").length; + if (localPrimaries + foreignPrimaries < 2) return null; + const remote = projectBinding?.kind === "remote" ? projectBinding : null; + return { + machineId: remote?.targetId ?? THIS_MACHINE_ID, + machineName: remote?.runtimeName ?? THIS_MACHINE_NAME, + online: true, + mode: "name", + title: remote?.runtimeName ?? THIS_MACHINE_NAME, + sameBranchElsewhere: false, + }; + }, [orderedLanes, projectBinding, visibleForeignRows]); + + /** + * Which bottom shelf a fully-quiet lane files into, or null to stay in place. + * + * A lane whose rows are ALL settled belongs under Settled; one whose rows are + * ALL snoozed belongs under Snoozed, which sits above it. A lane mixing the + * two quiet kinds files by the DOMINANT kind rather than staying upstairs: + * it is fully quiet either way, so leaving it in the inbox spends a prime row + * on work nobody is waiting for, and the shelf is only a filing decision — + * its rows render flat (see `renderLaneSessionLists`) and each one still + * states its own status, so the minority kind is never mislabelled. A tie + * goes to Snoozed, the more visible of the two shelves. + * + * A lane that is quiet only in part is untouched here: `isLaneQuiet` gates + * every demotion, so anything holding live or asking work keeps its place and + * its in-lane tails. + * + * Read from the unfiltered roster for the same reason `isLaneQuiet` is: the + * shelf a lane lives on must not change as the user types in search. + */ + // Memoized rather than recomputed per call: this list re-renders on every + // session tick, and the shelf split is read once per lane per section. + const laneShelfByLaneId = useMemo(() => { + const map = new Map(); + for (const [laneId, roster] of unfilteredSessionsByLane) { + // A pin is an explicit "keep this where I can see it", which outranks + // every automatic demotion — the same exemption the quiet header grants. + if (workPinnedLaneIdSet.has(laneId)) continue; + // Primary is pinned to the very top of the column, above every tier. A + // shelf demotion would move it to the bottom on a quiet afternoon, which + // is exactly the fixed landmark this lane is supposed to be. + if (laneById.get(laneId)?.laneType === "primary") continue; + if (roster.length === 0 || !isLaneQuiet(laneId)) continue; + let snoozedRows = 0; + let settledRows = 0; + for (const session of roster) { + if (unfilteredQuietBuckets.settled.has(session.id)) settledRows += 1; + else if (unfilteredQuietBuckets.snoozed.has(session.id)) snoozedRows += 1; + } + if (snoozedRows + settledRows === 0) continue; + map.set(laneId, settledRows > snoozedRows ? "settled" : "snoozed"); + } + return map; + }, [isLaneQuiet, laneById, unfilteredQuietBuckets, unfilteredSessionsByLane, workPinnedLaneIdSet]); + const laneShelfFor = useCallback( + (laneId: string): "snoozed" | "settled" | null => laneShelfByLaneId.get(laneId) ?? null, + [laneShelfByLaneId], + ); + + /** + * Lanes that render their group WITHOUT a divider — the singleton form. + * + * One chat per lane is the common workflow, and it used to produce + * divider/card/divider/card with the lane name usually duplicating the chat + * title. The lone card carries the lane identity instead (`showLaneIdentity`). + * + * Four rules keep the threshold from jittering: + * 1. It reads the UNFILTERED roster, so the column does not reshape while + * the user types in search — same precedent as `isLaneQuiet`. + * 2. It counts TOP-LEVEL rows only. A chat with terminal-drawer children is + * one unit and must not summon a divider. + * 3. Manual sort opts out entirely: a singleton has no header to grab, and + * the card's drag gesture is already claimed by the work-grid DnD. + * 4. A pending handoff placeholder counts as a second row, so a lane does + * not lose its header for the second it takes the real session to land. + * A pinned lane also keeps its header — the pin glyph lives there. So does a + * Primary that is currently showing a machine badge: the badge hangs off the + * header, and dropping the header would drop the one thing distinguishing two + * identically-named, identically-coloured Primaries. + */ + const headerlessLaneIds = useMemo(() => { + const ids = new Set(); + if (workLaneSortMode === "manual") return ids; + for (const lane of orderedLanes) { + if (workPinnedLaneIdSet.has(lane.id)) continue; + if (lane.laneType === "primary" && primaryLaneMachineMarker) continue; + if ((unfilteredHandoffCountByLaneId.get(lane.id) ?? 0) > 0) continue; + const roster = unfilteredSessionsByLane.get(lane.id) ?? []; + const rosterIds = new Set(roster.map((session) => session.id)); + const topLevel = roster.filter((session) => { + const parentId = session.chatSessionId; + return !(parentId && parentId !== session.id && rosterIds.has(parentId)); + }); + if (topLevel.length === 1) ids.add(lane.id); + } + return ids; + }, [ + orderedLanes, + primaryLaneMachineMarker, + unfilteredHandoffCountByLaneId, + unfilteredSessionsByLane, + workLaneSortMode, + workPinnedLaneIdSet, + ]); + // Cheap order signature for the sink animation: without it `layout` would // re-measure every lane on every session tick, and this list ticks constantly. - const laneOrderSignature = useMemo(() => renderedLaneIds.join(","), [renderedLaneIds]); + // Headerlessness is part of the signature because gaining a header is exactly + // the moment the rows below must slide rather than snap. + const laneOrderSignature = useMemo( + () => orderedLanes + .map((lane) => `${lane.id}${headerlessLaneIds.has(lane.id) ? "~" : ""}`) + .join(","), + [headerlessLaneIds, orderedLanes], + ); const canStartLaneDrag = useCallback( (laneId: string) => laneById.get(laneId)?.laneType !== "primary", [laneById], @@ -1176,8 +1634,25 @@ export const SessionListPane = React.memo(function SessionListPane({ return ids; }; for (const lane of orderedLanes) { + const shelf = laneShelfFor(lane.id); + if (shelf && isQuietShelfCollapsed(workCollapsedSectionIds, `lane-shelf:${shelf}`)) continue; + const list = sessionsGroupedByLane?.get(lane.id) ?? []; + if (headerlessLaneIds.has(lane.id)) { + // No header means no collapse toggle and no quiet tail: the one row is + // always on screen, so it is always in range-selection order. + ids.push(...collectVisibleIds(list)); + continue; + } + if (shelf) { + // A shelf lane renders flat, so there is no per-tail marker to consult: + // once its own quiet header is expanded, every row in it is on screen. + // (Quiet lanes record only the explicit expand, hence `lane-open:`.) + if (!workCollapsedSectionIds.includes(`lane-open:${lane.id}`)) continue; + ids.push(...collectVisibleIds(list)); + continue; + } if (workCollapsedLaneIds.includes(lane.id)) continue; - ids.push(...laneVisibleIds(lane.id, sessionsGroupedByLane?.get(lane.id) ?? [])); + ids.push(...laneVisibleIds(lane.id, list)); } for (const [laneId, list] of missingLaneSessionGroups) { if (workCollapsedLaneIds.includes(laneId)) continue; @@ -1190,23 +1665,25 @@ export const SessionListPane = React.memo(function SessionListPane({ if (!workCollapsedSectionIds.includes("time:today")) ids.push(...collectVisibleIds(timeBuckets.today)); if (!workCollapsedSectionIds.includes("time:yesterday")) ids.push(...collectVisibleIds(timeBuckets.yesterday)); if (!workCollapsedSectionIds.includes("time:older")) ids.push(...collectVisibleIds(timeBuckets.older)); - if (!workCollapsedSectionIds.includes("status:snoozed")) ids.push(...collectVisibleIds(visibleSnoozed)); - if (!workCollapsedSectionIds.includes("status:settled")) ids.push(...collectVisibleIds(visibleSettled)); + if (!isQuietShelfCollapsed(workCollapsedSectionIds, "status:snoozed")) ids.push(...collectVisibleIds(visibleSnoozed)); + if (!isQuietShelfCollapsed(workCollapsedSectionIds, "status:settled")) ids.push(...collectVisibleIds(visibleSettled)); return ids; } const ids: string[] = []; if (!workCollapsedSectionIds.includes("status:running")) ids.push(...collectVisibleIds(runningFiltered)); if (!workCollapsedSectionIds.includes("status:awaiting")) ids.push(...collectVisibleIds(awaitingInputFiltered)); if (!workCollapsedSectionIds.includes("status:ended")) ids.push(...collectVisibleIds(endedFiltered)); - if (!workCollapsedSectionIds.includes("status:snoozed")) ids.push(...collectVisibleIds(visibleSnoozed)); - if (!workCollapsedSectionIds.includes("status:settled")) ids.push(...collectVisibleIds(visibleSettled)); + if (!isQuietShelfCollapsed(workCollapsedSectionIds, "status:snoozed")) ids.push(...collectVisibleIds(visibleSnoozed)); + if (!isQuietShelfCollapsed(workCollapsedSectionIds, "status:settled")) ids.push(...collectVisibleIds(visibleSettled)); return ids; }, [ awaitingInputFiltered, collectVisibleIds, endedFiltered, + headerlessLaneIds, isByLane, isByTime, + laneShelfFor, missingLaneSessionGroups, orderedLanes, quietIdSet, @@ -1239,6 +1716,27 @@ export const SessionListPane = React.memo(function SessionListPane({ compact?: boolean; foreignRow?: CrossMachineLaneRow; visibleSessionIds?: string[]; + /** + * Set only where the lane's divider was omitted (the singleton form). The + * card then leads its "where" slot with the lane accent + name, so the lane + * identity the header used to carry is not simply lost. + */ + showLaneIdentity?: boolean; + /** + * The other two things the omitted divider used to carry: its PR badge and + * its context menu. Both ride along with `showLaneIdentity` so a singleton + * lane loses no capability, only chrome. + */ + lanePr?: PrSummary | null; + laneActions?: SessionContextMenuLaneActions | null; + /** + * Set for cards rendered UNDER a lane header that already shows a machine + * marker. Every row in that group is on that machine by construction, so the + * row's own chip is the header's label repeated once per card. Never set for + * the singleton/headerless form: with no header above it, the chip is the + * only place the machine is named at all. + */ + suppressMachineChip?: boolean; }; const renderCardCore = (session: TerminalSessionSummary, options?: RenderCardOptions) => { const isFirst = !sessionItemAnchorEmitted; @@ -1284,11 +1782,20 @@ export const SessionListPane = React.memo(function SessionListPane({ }} onContextMenu={(e) => { e.preventDefault(); - onContextMenu(session, e, foreignRow?.binding, foreignRow?.machineName); + onContextMenu( + session, + e, + foreignRow?.binding, + foreignRow?.machineName, + options?.laneActions, + ); }} compact={options?.compact} + showLaneIdentity={options?.showLaneIdentity} + lanePr={options?.lanePr} gridBadge={foreignRow ? null : gridBadgeFor(session.id)} runtimePin={foreignRow?.binding} + suppressMachineChip={options?.suppressMachineChip} deltaEnabled={!foreignRow} disabledReason={disabledReason} disabledBusy={!foreignRow} @@ -1306,11 +1813,20 @@ export const SessionListPane = React.memo(function SessionListPane({ if (children.length === 0) return null; const collapsed = isChildSectionCollapsed(parentId); return ( -
    + // `data-indented` for the card's bleed rule, same as a lane group body: + // these rows hang off their own rail, so a left bleed would cross it. +
    {!collapsed ? ( -
    +
    {children.map((child) => (
    {renderCardCore(child, { compact: true })}
    ))} @@ -1390,11 +1906,17 @@ export const SessionListPane = React.memo(function SessionListPane({ // Presence of the open marker means the user explicitly expanded it. const collapsed = !workCollapsedSectionIds.includes(openMarker); return ( -
    + // The in-lane tail is the quiet shelf's idiom one step down: same uppercase + // grey label with no rule, smaller, so "quiet" reads the same everywhere + // while staying subordinate inside its group. +
    - {!collapsed ?
    {renderCards(list, options)}
    : null} + {!collapsed ?
    {renderCards(list, options)}
    : null}
    ); }; @@ -1416,22 +1938,45 @@ export const SessionListPane = React.memo(function SessionListPane({ * Lane folder body: active rows first, then quiet collapsible snoozed and * settled tails so hidden work stays openable in-stream without occupying the * folder's prime rows. + * + * `flat` drops the tails entirely — see below; it is set only for lanes that + * already live inside a quiet shelf. */ const renderLaneSessionLists = ( laneKey: string, list: TerminalSessionSummary[], - options?: { quiet?: boolean }, + options?: { quiet?: boolean; flat?: boolean; cardOptions?: RenderCardOptions }, ) => { - const active = list.filter((session) => !quietIdSet.has(session.id)); - const snoozed = list.filter((session) => snoozedIdSet.has(session.id)); - const settled = list.filter((session) => settledIdSet.has(session.id)); + const cardOptions = options?.cardOptions; // An expanded quiet lane holds nothing but quiet rows, so render them // compact — the full card's preview line and meta row are all about work in // flight, which by definition there isn't any of here. - const tailOptions = options?.quiet ? { compact: true } : undefined; + if (options?.flat) { + // Inside the Snoozed/Settled shelf, and therefore flat. The shelf header + // already asserts the state of everything beneath it, so re-grouping the + // same rows under a per-lane SETTLED/SNOOZED label repeats the one fact + // the user already has — and a disclosure level that reveals a LABEL + // rather than content is a level that should not exist. It cost three + // expands (shelf → lane → subsection) to reach a row whose state was + // stated at the first one. Expanding the lane now shows its sessions. + // + // A lane that mixes both quiet kinds renders flat here too: it was filed + // by its dominant kind (`laneShelfByLaneId`) and each card still carries + // its own status, so the minority rows are labelled, not mislabelled. + return <>{renderCards(list, { compact: true, ...cardOptions })}; + } + const active = list.filter((session) => !quietIdSet.has(session.id)); + const snoozed = list.filter((session) => snoozedIdSet.has(session.id)); + const settled = list.filter((session) => settledIdSet.has(session.id)); + // A lane that stayed upstairs is genuinely mixed — quiet rows AND live ones + // — which is exactly where the subsection carries information, so its tails + // are untouched. + const tailOptions = options?.quiet || cardOptions + ? { ...(options?.quiet ? { compact: true } : {}), ...cardOptions } + : undefined; return ( <> - {renderCards(active)} + {renderCards(active, cardOptions)} {renderLaneQuietTail(`snoozed-open:${laneKey}`, snoozedSectionIcon, "snoozed", snoozed, tailOptions)} {renderLaneQuietTail(`settled-open:${laneKey}`, settledSectionIcon, "settled", settled, tailOptions)} @@ -1471,16 +2016,36 @@ export const SessionListPane = React.memo(function SessionListPane({ workCollapsedSectionIds, ]); + /** + * The quiet region at the foot of the column. + * + * ONE heavier rule fences the whole zone off, and the shelves inside it carry + * none of their own. That is the separation that was missing: a lane divider + * and a Snoozed/Settled row used to be the same shape — hairline, chevron, + * coloured label — in a list that has both, so neither could be read at a + * glance. Now the rule says "below here is quiet" once, and everything inside + * is grey uppercase with no rule at all. + */ + const renderQuietZone = (present: boolean, children: React.ReactNode) => (present ? ( +
    +
    + {children} +
    + ) : null); + // Snoozed sits directly ABOVE Settled: hidden-for-now ranks above done. const snoozedStatusSection = visibleSnoozed.length > 0 ? ( toggleWorkSectionCollapsed("status:snoozed")} + // Collapsed unless explicitly opened — see `quietShelfOpenMarker`. + collapsed={isQuietShelfCollapsed(workCollapsedSectionIds, "status:snoozed")} + onToggleCollapsed={() => toggleWorkSectionCollapsed(quietShelfOpenMarker("status:snoozed"))} > {renderCards(visibleSnoozed)} @@ -1491,16 +2056,21 @@ export const SessionListPane = React.memo(function SessionListPane({ sectionId="status:settled" icon={settledSectionIcon} label="Settled" + variant="quiet-shelf" + tone="settled" count={visibleSettled.length} - collapsed={workCollapsedSectionIds.includes("status:settled")} - onToggleCollapsed={() => toggleWorkSectionCollapsed("status:settled")} + collapsed={isQuietShelfCollapsed(workCollapsedSectionIds, "status:settled")} + onToggleCollapsed={() => toggleWorkSectionCollapsed(quietShelfOpenMarker("status:settled"))} > {renderCards(visibleSettled)} ) : null; const groupedByStatusList = ( -
    + // Single rhythm, container-owned (see StickyGroupHeader): with the borders + // gone, whitespace IS the grouping, so the gap between groups is the large + // one and active and quiet groups get the SAME air. +
    } @@ -1556,118 +2126,304 @@ export const SessionListPane = React.memo(function SessionListPane({ > {renderCards(endedFiltered)} - {snoozedStatusSection} - {settledStatusSection} + {renderQuietZone(visibleSnoozed.length + visibleSettled.length > 0, ( + <> + {snoozedStatusSection} + {settledStatusSection} + + ))}
    ); - // Foreign lanes worth a row: ones with chats, after the same search, lane, and - // chip filters the local list applies. Lanes elsewhere with nothing running - // stay out of the sidebar — the union is about work in flight, not an inventory. - const visibleForeignRows = useMemo(() => { - if (foreignRows.length === 0) return EMPTY_FOREIGN_ROWS; - const query = q.trim().toLowerCase(); - const nowMs = Date.now(); - const rows: CrossMachineLaneRow[] = []; - for (const row of foreignRows) { - if (laneFilterActive && row.lane.id !== normalizedFilterLaneId) continue; - const sessions = query || chipFiltersActive - ? row.sessions.filter((session) => { - if (query && !`${primarySessionLabel(session)} ${row.lane.name}`.toLowerCase().includes(query)) { - return false; - } - return !chipFiltersActive || matchesWorkSessionFilters(session, workSessionFilters, { - nowMs, - // PR summaries are local to the active runtime. Fail closed for a - // remote lane instead of incorrectly treating an unknown PR as open. - laneHasPr: () => false, - laneIsDirty: (laneId) => laneId === row.lane.id && row.lane.status.dirty, - }); - }) - : row.sessions; - if (sessions.length === 0) continue; - rows.push(sessions === row.sessions ? row : { ...row, sessions }); - } - return rows.length > 0 ? rows : EMPTY_FOREIGN_ROWS; - }, [chipFiltersActive, foreignRows, laneFilterActive, normalizedFilterLaneId, q, workSessionFilters]); - // "No sessions" must not claim an empty machine when another machine is busy. const hasForeignSessions = visibleForeignRows.length > 0; - const byLaneList = ( -
    - {orderedLanes.map((lane) => { - const list = sessionsGroupedByLane?.get(lane.id) ?? []; - const laneHandoffJobs = handoffJobsByLaneId.get(lane.id) ?? []; - const lanePinned = workPinnedLaneIdSet.has(lane.id); - // A pinned lane keeps the full-height header even when everything in it - // has settled; only its position is exempt from the sink. - const laneQuiet = isLaneQuiet(lane.id) && !lanePinned; - // A quiet lane starts collapsed, and — like the quiet tails — records - // only the *explicit expand*, so it re-quiets on its own once the user - // moves on instead of leaving a stale "expanded" entry behind forever. - const laneOpenMarker = `lane-open:${lane.id}`; - const collapsed = laneQuiet - ? !workCollapsedSectionIds.includes(laneOpenMarker) - : workCollapsedLaneIds.includes(lane.id); - const total = list.length + laneHandoffJobs.length; - const laneAccent = lane.color ?? null; - const laneHeaderTint = laneSurfaceTint(laneAccent, "pastel"); - const laneIcon = ( - - {lane.icon ? iconGlyph(lane.icon) : } + const renderLaneGroup = (lane: LaneSummary) => { + const list = sessionsGroupedByLane?.get(lane.id) ?? []; + const laneHandoffJobs = handoffJobsByLaneId.get(lane.id) ?? []; + const lanePinned = workPinnedLaneIdSet.has(lane.id); + // A pinned lane keeps the full header even when everything in it has + // settled; only its position is exempt from the sink. + const laneQuiet = isLaneQuiet(lane.id) && !lanePinned; + const deleteProgress = deleteProgressByLaneId[lane.id] ?? null; + // A lane being torn down keeps its divider: the busy overlay lives on the + // header, and "Deleting lane" is the one thing worth reading at that moment. + const headerless = headerlessLaneIds.has(lane.id) && !deleteProgress; + // A quiet lane starts collapsed, and — like the quiet tails — records + // only the *explicit expand*, so it re-quiets on its own once the user + // moves on instead of leaving a stale "expanded" entry behind forever. + const laneOpenMarker = `lane-open:${lane.id}`; + const collapsed = laneQuiet + ? !workCollapsedSectionIds.includes(laneOpenMarker) + : workCollapsedLaneIds.includes(lane.id); + const total = list.length + laneHandoffJobs.length; + const laneAccent = lane.color ?? null; + const laneHeaderTint = laneSurfaceTint(laneAccent, "pastel"); + const laneIcon = ( + + {lane.icon ? iconGlyph(lane.icon) : } + + ); + const primaryPr = selectPrimaryLanePr(lane, prsByLaneId.get(lane.id) ?? []); + const prBadge = primaryPr ? ( + navigate(lanePrDeepLinkPath(primaryPr))} /> + ) : null; + // Never populated for a lane on this machine — the marker exists only to + // say "this work isn't here". The one exception is a Primary competing with + // another machine's Primary, where naming THIS machine is the only thing + // that disambiguates the pair (see `primaryLaneMachineMarker`). + const resolvedMachineMarker = markersByLaneId.get(lane.id) + ?? (lane.laneType === "primary" ? primaryLaneMachineMarker : null); + const machineMarker = resolvedMachineMarker + ? { + ...resolvedMachineMarker, + mode: lane.laneType === "primary" ? "name" as const : "glyph" as const, + } + : null; + // Both marker sources count: the cross-machine marker and the local-Primary + // badge say the same thing to the reader, so either one makes the rows' + // own machine chips a repetition. A headerless lane keeps its chip — there + // is no header above it doing the naming. + const suppressMachineChip = Boolean(machineMarker) && !headerless; + // A lane already filed into a quiet shelf renders its rows flat: the shelf + // states the tier once, for everything under it. + const inQuietShelf = laneShelfFor(lane.id) !== null; + return ( + : null} + busyLabel={deleteProgress ? getLaneDeleteStatusLabel(deleteProgress) : null} + pinned={lanePinned} + dragProps={laneDragProps(lane.id)} + dropIndicatorEdge={laneDrop?.laneId === lane.id ? laneDrop.edge : null} + layoutDependency={laneOrderSignature} + quietCounts={laneQuiet && collapsed + ? { + snoozed: list.filter((session) => snoozedIdSet.has(session.id)).length, + settled: list.filter((session) => settledIdSet.has(session.id)).length, + } + : null} + onToggleCollapsed={() => { + if (deleteProgress) return; + if (laneQuiet) toggleWorkSectionCollapsed(laneOpenMarker); + else toggleWorkLaneCollapsed(lane.id); + }} + onContextMenu={deleteProgress ? undefined : (e) => triggerLaneContextMenu(lane.id, e)} + > + {renderHandoffCards(laneHandoffJobs)} + {headerless + // A singleton skips the snoozed/settled tails entirely: wrapping one + // row in a "1 settled" disclosure under no header is more chrome than + // the row it hides, and the shelf this group files into already says + // which tier it is in. + // + // The lone card inherits everything the divider would have carried: + // the lane identity, the PR badge, and — via the session context menu + // — the lane menu, which would otherwise have no right-click target. + ? renderCards(list, { + showLaneIdentity: true, + lanePr: primaryPr, + laneActions: { + laneId: lane.id, + laneName: lane.name, + // Anchored at the session menu's own position, so the lane menu + // opens where the user right-clicked rather than jumping. + open: ({ x, y }) => triggerLaneContextMenu(lane.id, { + preventDefault: () => {}, + clientX: x, + clientY: y, + }), + }, + }) + : renderLaneSessionLists(lane.id, list, { + quiet: laneQuiet, + flat: inQuietShelf, + cardOptions: suppressMachineChip ? { suppressMachineChip: true } : undefined, + })} + + ); + }; + + /** + * The cross-machine counterpart of `renderLaneGroup`. One function, called + * from the main list AND from both shelves, so a foreign lane is filed by the + * same rule and rendered in the same shape wherever it ends up — the two + * render paths used to be separate, which is why a foreign lane could never + * shelve at all. + */ + const renderForeignLaneGroup = (entry: ForeignLaneEntry) => { + const { row, compositeLaneId, quiet, shelf } = entry; + const marker = markersByLaneId.get(compositeLaneId) ?? null; + // Primary is the only lane whose machine NAME is promoted permanently. + // Every machine owns a Primary, so an icon alone leaves otherwise identical + // headers ambiguous. Other lane headers keep the adaptive glyph/name marker + // chosen by the cross-machine union. + const headerMarker: CrossMachineLaneMarker | null = row.lane.laneType === "primary" + ? { + machineId: row.machineId, + machineName: row.machineName, + online: row.online, + mode: "name", + title: row.machineName, + sameBranchElsewhere: marker?.sameBranchElsewhere ?? false, + } + : marker + ? { ...marker, mode: "glyph" } + : null; + // An offline machine's group folds shut like a quiet one: retained and + // 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 laneOpenMarker = `lane-open:${compositeLaneId}`; + const collapsed = laneQuiet + ? !workCollapsedSectionIds.includes(laneOpenMarker) + : workCollapsedLaneIds.includes(compositeLaneId); + // A shelved lane shows nothing but its header, so the header has to keep + // carrying everything that identifies the lane — the machine marker below, + // and the PR badge here. PR records are local to this runtime, so a lane + // that exists only elsewhere simply has none; when the local runtime does + // know the lane's PR, it is the same lane and the same badge. + const primaryPr = selectPrimaryLanePr(row.lane, prsByLaneId.get(row.lane.id) ?? []); + // A foreign group always has a header, so its sessions never repeat machine + // identity. Primary spells the name out on the rail; other lanes retain the + // compact header marker when the cross-machine resolver says one is useful. + const cardOptions: RenderCardOptions = { foreignRow: row, suppressMachineChip: true }; + return ( + + - ); - const primaryPr = selectPrimaryLanePr(lane, prsByLaneId.get(lane.id) ?? []); - const prBadge = primaryPr ? ( - navigate(`/prs?tab=normal&prId=${encodeURIComponent(primaryPr.id)}`)} - /> - ) : null; - const deleteProgress = deleteProgressByLaneId[lane.id] ?? null; - // Never populated for a lane on this machine — the marker exists only to - // say "this work isn't here". - const machineMarker = markersByLaneId.get(lane.id) ?? null; - return ( - : null} - busyLabel={deleteProgress ? getLaneDeleteStatusLabel(deleteProgress) : null} - pinned={lanePinned} - dragProps={laneDragProps(lane.id)} - dropIndicatorEdge={laneDrop?.laneId === lane.id ? laneDrop.edge : null} - layoutDependency={laneOrderSignature} - quietCounts={laneQuiet && collapsed - ? { - snoozed: list.filter((session) => snoozedIdSet.has(session.id)).length, - settled: list.filter((session) => settledIdSet.has(session.id)).length, - } - : null} - onToggleCollapsed={() => { - if (deleteProgress) return; - if (laneQuiet) toggleWorkSectionCollapsed(laneOpenMarker); - else toggleWorkLaneCollapsed(lane.id); - }} - onContextMenu={deleteProgress ? undefined : (e) => triggerLaneContextMenu(lane.id, e)} - > - {renderHandoffCards(laneHandoffJobs)} - {renderLaneSessionLists(lane.id, list, { quiet: laneQuiet })} - - ); - })} + } + label={row.lane.name} + subLabel={branchNameFromRef(row.lane.branchRef)} + variant="lane" + count={row.sessions.length} + collapsed={collapsed} + accentColor={row.lane.color ?? null} + prBadge={primaryPr ? ( + navigate(lanePrDeepLinkPath(primaryPr))} /> + ) : null} + machineMarker={headerMarker ? : null} + quietCounts={laneQuiet && collapsed + ? { + snoozed: quiet.snoozed.length, + settled: quiet.settled.length, + } + : null} + dimmed={!row.online} + onToggleCollapsed={() => { + if (laneQuiet) toggleWorkSectionCollapsed(laneOpenMarker); + else toggleWorkLaneCollapsed(compositeLaneId); + }} + onContextMenu={row.binding + ? (event) => triggerForeignLaneContextMenu( + row.lane, + row.binding!, + row.machineName, + row.machineId, + event, + ) + : undefined} + > + {shelf + // Inside a shelf, and therefore flat — the same rule as + // `renderLaneSessionLists`'s `flat` branch: the shelf header already + // states the tier for everything under it, so a further per-lane + // SNOOZED/SETTLED disclosure would reveal a label rather than content. + ? renderCards(row.sessions, { compact: true, ...cardOptions }) + : ( + <> + {renderCards(quiet.active, cardOptions)} + {renderLaneQuietTail( + `snoozed-open:${compositeLaneId}`, + snoozedSectionIcon, + "snoozed", + quiet.snoozed, + { compact: laneQuiet, ...cardOptions }, + )} + {renderLaneQuietTail( + `settled-open:${compositeLaneId}`, + settledSectionIcon, + "settled", + quiet.settled, + { compact: laneQuiet, ...cardOptions }, + )} + + )} + + ); + }; + + /** + * Fully-quiet lanes leave the inbox for a shelf at the bottom, in t3's order: + * inbox → Snoozed → Settled. A lane is demoted only when EVERY row agrees on + * one tier (`laneShelfFor`); anything mixed keeps its place upstairs. + * + * Cross-machine lanes split the same three ways (`foreignLaneShelving`) and + * join the same three destinations: the shelf is about how quiet the work is, + * never about which machine it happens to be sitting on. + */ + const snoozedShelfLanes = orderedLanes.filter((lane) => laneShelfFor(lane.id) === "snoozed"); + const settledShelfLanes = orderedLanes.filter((lane) => laneShelfFor(lane.id) === "settled"); + const mainLanes = orderedLanes.filter((lane) => laneShelfFor(lane.id) === null); + const mainForeignRows = foreignLaneShelving.filter((entry) => entry.shelf === null); + const snoozedShelfForeignRows = foreignLaneShelving.filter((entry) => entry.shelf === "snoozed"); + const settledShelfForeignRows = foreignLaneShelving.filter((entry) => entry.shelf === "settled"); + // A lane filtered down to nothing renders nothing, so it must not inflate the + // shelf's count either. Foreign rows need no such guard: `visibleForeignRows` + // has already dropped any row the filters emptied out. + const shelfLaneCount = (laneList: LaneSummary[]) => laneList.filter( + (lane) => (sessionsGroupedByLane?.get(lane.id)?.length ?? 0) + + (handoffJobsByLaneId.get(lane.id)?.length ?? 0) > 0, + ).length; + const snoozedShelfCount = shelfLaneCount(snoozedShelfLanes) + snoozedShelfForeignRows.length; + const settledShelfCount = shelfLaneCount(settledShelfLanes) + settledShelfForeignRows.length; + + /** + * Is this shelved row actually showing cards? A shelved lane is quiet by + * construction, so its collapse state is the explicit-expand marker (the same + * rule `renderLaneGroup`/`renderForeignLaneGroup` apply). A headerless + * singleton is "open", but it is one flat card with no header above it — a + * row, not a group — so it keeps the row rhythm. + */ + const isShelfRowExpanded = (rowId: string, headerless = false) => ( + !headerless && workCollapsedSectionIds.includes(`lane-open:${rowId}`) + ); + /** Wraps a shelf child so the gap around it can depend on its own state. */ + const shelfRow = (rowId: string, expanded: boolean, node: React.ReactNode) => ( +
    + {node} +
    + ); + + const byLaneList = ( + // `pb-2` matches the status/time stacks: the quiet zone closes this column, + // so any extra bottom padding reads as dead space under the last shelf row + // rather than as the deliberate breathing room above the footer rule. +
    + {mainLanes.map(renderLaneGroup)} {missingLaneSessionGroups.map(([laneId, list]) => { const laneHandoffJobs = handoffJobsByLaneId.get(laneId) ?? []; const collapsed = workCollapsedLaneIds.includes(laneId); @@ -1737,77 +2493,78 @@ export const SessionListPane = React.memo(function SessionListPane({ ); })} - {visibleForeignRows.map((row) => { - const compositeLaneId = `${row.machineId}:${row.lane.id}`; - const marker = markersByLaneId.get(compositeLaneId) ?? null; - const quiet = partitionQuietSessions(row.sessions); - // An offline machine's group folds shut like a quiet one: retained and - // inspectable, not presented as live work. - const laneQuiet = !foreignRowHasLiveWork(row, quiet.active); - const laneOpenMarker = `lane-open:${compositeLaneId}`; - const collapsed = laneQuiet - ? !workCollapsedSectionIds.includes(laneOpenMarker) - : workCollapsedLaneIds.includes(compositeLaneId); - return ( - - - - } - label={row.lane.name} - subLabel={branchNameFromRef(row.lane.branchRef)} - variant="lane" - count={row.sessions.length} - collapsed={collapsed} - accentColor={row.lane.color ?? null} - machineMarker={marker ? : null} - quietCounts={laneQuiet && collapsed - ? { - snoozed: quiet.snoozed.length, - settled: quiet.settled.length, - } - : null} - dimmed={!row.online} - onToggleCollapsed={() => { - if (laneQuiet) toggleWorkSectionCollapsed(laneOpenMarker); - else toggleWorkLaneCollapsed(compositeLaneId); - }} - onContextMenu={row.binding - ? (event) => triggerForeignLaneContextMenu( - row.lane, - row.binding!, - row.machineName, - row.machineId, - event, - ) - : undefined} - > - {renderCards(quiet.active, { foreignRow: row })} - {renderLaneQuietTail( - `snoozed-open:${compositeLaneId}`, - snoozedSectionIcon, - "snoozed", - quiet.snoozed, - { compact: laneQuiet, foreignRow: row }, - )} - {renderLaneQuietTail( - `settled-open:${compositeLaneId}`, - settledSectionIcon, - "settled", - quiet.settled, - { compact: laneQuiet, foreignRow: row }, - )} - - ); - })} + {mainForeignRows.map(renderForeignLaneGroup)} + {/* The two shelves close the column, hidden-for-now above done, inside the + quiet zone's single heavier rule. A demoted lane keeps its group — a + quiet header, or none at all for a singleton — so it is filed, not + flattened; only its BODY goes flat, because the shelf above it has + already said what tier every row in it is in. */} + {renderQuietZone( + snoozedShelfCount + settledShelfCount > 0, + ( + <> + toggleWorkSectionCollapsed(quietShelfOpenMarker("lane-shelf:snoozed"))} + > + {/* Row rhythm, not group rhythm — see `SHELF_BODY_STACK_CLASS`; + a collapsed shelved lane is a one-line row and only an + expanded one earns the group gap. Local lanes first, then + cross-machine ones — the shelf is one list, but "here" still + reads before "elsewhere". */} +
    + {snoozedShelfLanes.map((lane) => shelfRow( + lane.id, + isShelfRowExpanded(lane.id, headerlessLaneIds.has(lane.id)), + renderLaneGroup(lane), + ))} + {snoozedShelfForeignRows.map((entry) => shelfRow( + entry.compositeLaneId, + isShelfRowExpanded(entry.compositeLaneId), + renderForeignLaneGroup(entry), + ))} +
    +
    + toggleWorkSectionCollapsed(quietShelfOpenMarker("lane-shelf:settled"))} + > +
    + {settledShelfLanes.map((lane) => shelfRow( + lane.id, + isShelfRowExpanded(lane.id, headerlessLaneIds.has(lane.id)), + renderLaneGroup(lane), + ))} + {settledShelfForeignRows.map((entry) => shelfRow( + entry.compositeLaneId, + isShelfRowExpanded(entry.compositeLaneId), + renderForeignLaneGroup(entry), + ))} +
    +
    + + ), + )}
    ); const byTimeList = ( -
    +
    - {snoozedStatusSection} - {settledStatusSection} + {renderQuietZone(visibleSnoozed.length + visibleSettled.length > 0, ( + <> + {snoozedStatusSection} + {settledStatusSection} + + ))}
    ); return (
    {/* Compact toolbar */} -
    -
    -
    - - setQ(e.target.value)} - /> -
    +
    + {/* Three borderless controls and no rule beneath them. The strip used to + spend four boundaries — input, funnel, outlined button, divider — + before the first chat, which left hover nothing to say. */} +
    + + + - +
    {/* Expandable filter panel */} {filterOpen ? ( -
    +
    Group
    @@ -2058,15 +2831,18 @@ export const SessionListPane = React.memo(function SessionListPane({ ) : null} {selectedCount > 0 ? ( -
    - +
    + {selectedCount} selected {selectedRunningCount > 0 ? (
    diff --git a/apps/desktop/src/renderer/components/terminals/SessionSnoozeControl.tsx b/apps/desktop/src/renderer/components/terminals/SessionSnoozeControl.tsx index 14bfa741b..1d7f43ea0 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionSnoozeControl.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionSnoozeControl.tsx @@ -3,54 +3,92 @@ import { createPortal } from "react-dom"; import { Moon } from "@phosphor-icons/react"; import type { OpenProjectBinding, TerminalSessionSummary } from "../../../shared/types"; import { useClampedFixedPosition } from "../../hooks/useClampedFixedPosition"; -import { SNOOZE_DURATION_OPTIONS, type SnoozeDurationKey } from "../../lib/sessionSnooze"; +import { + resolveSnoozePresets, + snoozeWakeDescription, + type SnoozeDurationKey, + type SnoozePreset, +} from "../../lib/sessionSnooze"; import { snoozeSessionForDuration, wakeSessionNow } from "./sessionLifecycleActions"; import { cn } from "../ui/cn"; /** - * Hover-revealed snooze affordance on a session row. Kept out of `SessionCard`'s - * render body so the hot Work list only pays for a single always-mounted button - * (revealed with CSS, not a hover state that would re-render the row) plus menu - * state that exists only after a click. + * Snooze affordance for a session row, living inside the row's status slot + * (`SessionStatusSlot`). Kept out of `SessionCard`'s render body so the hot + * Work list only pays for a single always-mounted button plus menu state that + * exists only after a click. + * + * Visibility is NOT this component's business any more: the status slot swaps + * the whole action cluster in on hover/focus, so a second hover rule here would + * fight it. The button is always painted; the slot decides whether the slot is. * * The menu is a locally-owned fixed popover clamped to the viewport, matching * `SessionContextMenu` — no document-level listener is added; the backdrop * element closes it. + * + * Its rows are NOT a constant: `resolveSnoozePresets` computes them against the + * wall clock, so the list is resolved on open (see `openMenu`) rather than + * built once at module load. */ export function SessionSnoozeControl({ session, snoozed, compact = false, runtimePin = null, + onOpenChange, }: { - session: Pick; + session: Pick; /** Already snoozed rows offer "Wake now" instead of a duration menu. */ snoozed: boolean; compact?: boolean; runtimePin?: OpenProjectBinding | null; + /** + * Reported on every open/close so the status slot can pin itself visible + * while the popover is up — the pointer is over the menu, not the row, and + * the hover-driven actions would otherwise fade out from under it. + */ + onOpenChange?: (open: boolean) => void; }) { const buttonRef = useRef(null); const [anchor, setAnchor] = useState<{ x: number; y: number } | null>(null); + // Resolved once per open rather than per render: the rows are time-dependent + // (see `resolveSnoozePresets`) and a list that reshuffled underneath an open + // menu would move the row the pointer is already travelling towards. + const [presets, setPresets] = useState([]); const { ref: menuRef, position } = useClampedFixedPosition(anchor); - const close = useCallback(() => setAnchor(null), []); + const close = useCallback(() => { + setAnchor(null); + onOpenChange?.(false); + }, [onOpenChange]); const openMenu = useCallback((event: React.MouseEvent) => { event.preventDefault(); event.stopPropagation(); const rect = buttonRef.current?.getBoundingClientRect(); + setPresets(resolveSnoozePresets(Date.now())); setAnchor(rect ? { x: rect.left, y: rect.bottom + 4 } : { x: event.clientX, y: event.clientY }); - }, []); + onOpenChange?.(true); + }, [onOpenChange]); const choose = useCallback( (key: SnoozeDurationKey) => { close(); + // Re-resolved at click time, not the deadline shown when the menu opened: + // a menu left open across the hour would otherwise write a deadline in + // the past. The time column can drift by the age of the open menu; the + // deadline may not. void snoozeSessionForDuration(session, key, Date.now(), runtimePin); }, [close, runtimePin, session], ); const label = snoozed ? "Wake session now" : "Snooze session"; + // The button's tooltip is the one place a snoozed row can state its wake time + // in full — the status slot beside it only has room for the "wakes in 3h" + // countdown. + const wakeDescription = snoozed ? snoozeWakeDescription(session.snoozedUntil) : null; + const title = wakeDescription ? `${label} · wakes ${wakeDescription}` : label; return ( <> @@ -61,13 +99,14 @@ export function SessionSnoozeControl({ aria-label={label} aria-haspopup={snoozed ? undefined : "menu"} aria-expanded={snoozed ? undefined : anchor != null} - title={label} + title={title} className={cn( - "inline-flex shrink-0 items-center justify-center rounded-md border border-white/[0.08] bg-bg/85 text-muted-fg/60 backdrop-blur-sm transition-colors", - "hover:border-white/[0.14] hover:text-fg/85 focus-visible:opacity-100", - // Revealed by the row's `group` hover; always focusable for keyboard users. - snoozed || anchor ? "opacity-100" : "opacity-0 focus:opacity-100 group-hover:opacity-100", - compact ? "h-4 w-4" : "h-5 w-5", + "inline-flex shrink-0 cursor-pointer items-center justify-center rounded-md bg-transparent text-muted-fg/70 transition-colors", + // Same hover pill as `SESSION_ACTION_BUTTON_CLASS` in + // SessionStatusSlot (spelled out rather than imported: that module + // renders THIS one, and importing back would close a module cycle). + "hover:bg-white/[0.06] hover:text-fg focus-visible:bg-white/[0.06] focus-visible:text-fg", + compact ? "px-1" : "px-1.5", )} onClick={(event) => { if (snoozed) { @@ -79,12 +118,12 @@ export function SessionSnoozeControl({ openMenu(event); }} > - + - {/* Portalled: the row's hover-action wrapper is `pointer-events-none` - until hovered, and a fixed child would inherit that and stop being - clickable the moment the pointer left the card. */} + {/* Portalled: the menu must outlive the row's hover state. Rendered in + place it would sit inside the status slot's opacity/position swap and + vanish the moment the pointer left the card to reach it. */} {anchor && typeof document !== "undefined" ? createPortal( <>
    event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} > - {SNOOZE_DURATION_OPTIONS.map((option) => ( + {/* Two columns: what you are choosing on the left, when it lands on + the right. The time column is the whole reason the labels stay + day-shaped ("Tomorrow", not "Until tomorrow 9am") — see + `SNOOZE_DURATION_OPTIONS`. */} + {presets.map((preset) => ( ))}
    diff --git a/apps/desktop/src/renderer/components/terminals/SessionStatusSlot.tsx b/apps/desktop/src/renderer/components/terminals/SessionStatusSlot.tsx new file mode 100644 index 000000000..3f22fd193 --- /dev/null +++ b/apps/desktop/src/renderer/components/terminals/SessionStatusSlot.tsx @@ -0,0 +1,257 @@ +import React from "react"; +import { + Alarm, + ArrowUUpLeft, + Check, + CheckCircle, + Circle, + CircleDashed, + Clock, + Moon, +} from "@phosphor-icons/react"; +import type { OpenProjectBinding, TerminalSessionSummary } from "../../../shared/types"; +import { + SESSION_TONE_TEXT_CLASS, + formatWorkingDuration, + type SessionStatusGlyph, + type SessionStatusPresentation, +} from "../../../shared/sessionStatusPresentation"; +import { isChatToolType } from "../../lib/sessions"; +import { + canonicalInputFromSummary, + sessionCanonicalUiState, +} from "../../lib/terminalAttention"; +import { cn } from "../ui/cn"; +import { SessionSnoozeControl } from "./SessionSnoozeControl"; +import { + settleSession, + unsettleSession, +} from "./sessionLifecycleActions"; + +/** + * The session row's right-hand swap slot: the status label at rest, the row's + * hover actions in its place on hover/focus, and NO layout shift between the + * two. + * + * The trick (ported from t3code's SidebarV2 row) is that exactly one of the two + * states is in flow at a time. At rest the label is in flow and the action + * cluster is absolutely positioned and transparent; on hover they swap — the + * cluster goes `static` and claims the width, the label goes absolute. Because + * the slot itself is `ml-auto` + `min-w-8`, the "where" line to its left never + * reflows when the actions appear. + * + * This is also the only place a session's status is rendered as words. Every + * hue and every glyph comes from `sessionStatusPresentation`, so the sidebar, + * the attention center and iOS cannot drift into three different ambers. + */ + +/** + * Glyph identity → Phosphor. Kept here rather than in the shared presentation + * module: that module is imported by main-process code and must stay free of + * renderer dependencies. + */ +function StatusGlyph({ glyph }: { glyph: SessionStatusGlyph }) { + switch (glyph) { + case "working": + return ; + case "done": + return ; + // Filled, not outlined: "your move" is the one state allowed to shout. + case "needs-you": + return ; + case "stale": + return ; + case "woke": + return ; + case "snoozed": + return ; + // `failed` deliberately has no glyph — red plus the word is already the + // loudest thing on the row. + default: + return null; + } +} + +/** + * Live elapsed copy for the states where "how long" is the question the row + * raises: `Working` (how long since it last said anything) and `Stale` (same + * measurement, past the silence threshold). One reference field — the last + * output, falling back to session start — so the two readings are continuous: + * a working row's ticker simply crosses the stale threshold and keeps counting. + */ +function useElapsedLabel(sinceIso: string | null | undefined, enabled: boolean): string { + const sinceMs = React.useMemo(() => { + const parsed = sinceIso ? Date.parse(sinceIso) : Number.NaN; + return Number.isFinite(parsed) ? parsed : null; + }, [sinceIso]); + const [nowMs, setNowMs] = React.useState(() => Date.now()); + + React.useEffect(() => { + if (!enabled || sinceMs == null) return undefined; + setNowMs(Date.now()); + const intervalId = window.setInterval(() => setNowMs(Date.now()), 1_000); + return () => window.clearInterval(intervalId); + }, [enabled, sinceMs]); + + if (!enabled || sinceMs == null) return ""; + return formatWorkingDuration(nowMs - sinceMs); +} + +/** + * The row action idiom, shared with `SessionSnoozeControl`'s trigger. + * + * The hover FILL is the load-bearing part: text that merely brightens reads as + * text, not as a control you can press. `bg-white/[0.06]` is the same pill the + * sidebar's own bare buttons and group-header actions use, so a row action and + * a header action feel like the same kind of thing. Focus-visible gets the same + * fill for keyboard users, who never see hover at all. + */ +export const SESSION_ACTION_BUTTON_CLASS = + "inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-muted-fg/70 transition-colors hover:bg-white/[0.06] hover:text-fg focus-visible:bg-white/[0.06] focus-visible:text-fg"; + +export function SessionStatusSlot({ + session, + presentation, + timestampLabel, + snoozed, + settled, + actionsEnabled, + compact = false, + runtimePin = null, +}: { + session: TerminalSessionSummary; + /** Resolved status, or null for a settled row (which shows its timestamp). */ + presentation: SessionStatusPresentation | null; + /** Relative time shown when there is no status word to show. */ + timestampLabel: string; + snoozed: boolean; + /** Settled rows offer un-settle instead of settle. */ + settled: boolean; + /** False while the owning lane is being torn down — no action may fire. */ + actionsEnabled: boolean; + compact?: boolean; + /** Runtime that owns this session when it differs from the active project. */ + runtimePin?: OpenProjectBinding | null; +}) { + // While the snooze popover is open the pointer has left the row, so the + // hover-driven actions would fade out from under the open menu. Pin the slot + // open for as long as the menu is. + const [snoozeMenuOpenRaw, setSnoozeMenuOpen] = React.useState(false); + // Derived, not raw: if the snooze button disappears while its menu is open + // (the row gets blocked mid-interaction) it unmounts without ever reporting + // `false`, and a stale `true` would permanently hide the status label. + const snoozeMenuOpen = snoozeMenuOpenRaw && actionsEnabled; + React.useEffect(() => { + if (!actionsEnabled) setSnoozeMenuOpen(false); + }, [actionsEnabled]); + + const elapsed = useElapsedLabel( + session.lastActivityAt ?? session.startedAt, + Boolean(presentation?.showsElapsed), + ); + const canonicalPhase = sessionCanonicalUiState(canonicalInputFromSummary(session)).phase; + const isActivelyRunning = canonicalPhase === "starting" + || canonicalPhase === "running" + || canonicalPhase === "stale"; + const canDismissNeedsYou = + canonicalPhase !== "needs_you" + || isChatToolType(session.toolType) + || Boolean(session.attentionRequestedAt); + const canSettle = settled || (!isActivelyRunning && canDismissNeedsYou); + + return ( + + {/* pointer-events-none is load-bearing, not decoration: on hover this + label becomes `absolute right-0 opacity-0`, which paints it directly + ON TOP of the now-in-flow action buttons. Without it, the invisible + label swallows every click aimed at Snooze/Settle. */} + + {presentation ? ( + + + {/* role="status" sits on the LABEL alone. Putting it on a wrapper + that also contains the ticking duration makes screen readers + announce the row once per second. */} + {presentation.label} + {elapsed ? ( + + {elapsed} + + ) : null} + + ) : ( + {timestampLabel} + )} + + + {actionsEnabled ? ( + // Going `static` on hover is what makes the swap free: the cluster + // claims real width exactly when the label gives it up. + + + {canSettle ? ( + + ) : null} + + ) : null} + + ); +} diff --git a/apps/desktop/src/renderer/components/terminals/TerminalsPage.test.tsx b/apps/desktop/src/renderer/components/terminals/TerminalsPage.test.tsx index 8f29c58e2..3a8abf8e0 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalsPage.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalsPage.test.tsx @@ -228,6 +228,8 @@ vi.mock("../../state/appStore", () => ({ laneDeleteProgressByLaneId: Record; switchRemoteProject: typeof workMocks.fns.switchRemoteProject; switchProjectToPath: typeof workMocks.fns.switchProjectToPath; + selectLane: typeof workMocks.fns.selectLane; + focusSession: typeof workMocks.fns.focusSession; setWorkViewState: typeof workMocks.fns.setWorkViewState; }) => T): T => selector({ @@ -236,6 +238,8 @@ vi.mock("../../state/appStore", () => ({ projectBinding: workMocks.projectBinding, switchRemoteProject: workMocks.fns.switchRemoteProject, switchProjectToPath: workMocks.fns.switchProjectToPath, + selectLane: workMocks.fns.selectLane, + focusSession: workMocks.fns.focusSession, setWorkViewState: workMocks.fns.setWorkViewState, project: workMocks.projectRoot ? { rootPath: workMocks.projectRoot } @@ -658,6 +662,66 @@ describe("TerminalsPage chat session activation", () => { expect(workMocks.currentWork.setSelectedSessionId).toHaveBeenCalledWith("chat-spawned-child"); }); + it("writes a foreign select-session event into the destination project state", async () => { + Object.defineProperty(window, "ade", { + configurable: true, + value: { builtInBrowser: { onEvent: vi.fn(() => vi.fn()) } }, + }); + const binding: OpenProjectBinding = { + kind: "remote", + key: "remote:target-studio:project-a", + targetId: "target-studio", + runtimeName: "Mac Studio", + projectId: "project-a", + rootPath: "/remote/repo-a", + displayName: "repo-a", + }; + + render(); + await screen.findByTestId("work-view-area"); + window.dispatchEvent( + new CustomEvent("ade:work:select-session", { + detail: { + sessionId: "chat-foreign", + laneId: "lane-foreign", + binding, + }, + }), + ); + + await waitFor(() => { + expect(workMocks.fns.switchRemoteProject).toHaveBeenCalledWith("target-studio", "project-a"); + expect(workMocks.fns.setWorkViewState).toHaveBeenCalledWith( + binding.key, + expect.any(Function), + ); + }); + expect(workMocks.fns.selectLane).toHaveBeenCalledWith("lane-foreign"); + expect(workMocks.fns.focusSession).toHaveBeenCalledWith("chat-foreign"); + expect(workMocks.currentWork.openSessionTab).not.toHaveBeenCalledWith("chat-foreign"); + expect(workMocks.currentWork.setSelectedSessionId).not.toHaveBeenCalledWith("chat-foreign"); + + const updateDestination = workMocks.fns.setWorkViewState.mock.calls.at(-1)?.[1] as + (previous: { + openItemIds: string[]; + selectedItemId: string | null; + activeItemId: string | null; + }) => { + openItemIds: string[]; + selectedItemId: string | null; + activeItemId: string | null; + }; + expect(updateDestination({ + openItemIds: [], + selectedItemId: null, + activeItemId: null, + })).toMatchObject({ + openItemIds: ["chat-foreign"], + selectedItemId: "chat-foreign", + activeItemId: "chat-foreign", + }); + }); + it("opens the Browser sidebar only for matching project open requests", async () => { workMocks.projectRoot = "/repo-one"; const browserEventListener: { diff --git a/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx b/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx index 18bf41205..18cb12ea5 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx @@ -5,7 +5,11 @@ import { useWorkSessions } from "./useWorkSessions"; import { SessionListPane } from "./SessionListPane"; import { WorkViewArea } from "./WorkViewArea"; import { WorkSidebar, type WorkSidebarContextTarget } from "./WorkSidebar"; -import { SessionContextMenu, type SessionContextMenuState } from "./SessionContextMenu"; +import { + SessionContextMenu, + type SessionContextMenuLaneActions, + type SessionContextMenuState, +} from "./SessionContextMenu"; import { SessionInfoPopover, type InfoPopoverState } from "./SessionInfoPopover"; import { ConfirmDialog, useConfirmDialog } from "../shared/InlineDialogs"; import type { AgentChatSession, OpenProjectBinding, TerminalResumeLaunchConfig, TerminalSessionSummary } from "../../../shared/types"; @@ -135,6 +139,8 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { const projectBinding = useAppStore((s) => s.projectBinding); const switchRemoteProject = useAppStore((s) => s.switchRemoteProject); const switchProjectToPath = useAppStore((s) => s.switchProjectToPath); + const selectLaneInStore = useAppStore((s) => s.selectLane); + const focusSessionInStore = useAppStore((s) => s.focusSession); const setWorkViewState = useAppStore((s) => s.setWorkViewState); const selectedLaneId = useAppStore((s) => s.selectedLaneId); const sortedLanes = useMemo(() => sortLanesForTabs(work.lanes), [work.lanes]); @@ -330,6 +336,9 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { e: React.MouseEvent, binding?: OpenProjectBinding | null, machineName?: string | null, + // Supplied only for a singleton lane's card, where no lane divider exists + // to right-click; the session menu then also carries the lane section. + laneActions?: SessionContextMenuLaneActions | null, ) => { setContextMenu({ session, @@ -337,6 +346,7 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { y: e.clientY, ...(binding ? { binding } : {}), ...(machineName ? { machineName } : {}), + ...(laneActions ? { laneActions } : {}), }); }, [], @@ -380,21 +390,69 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { // the buttons are dead (the event had no subscriber). useEffect(() => { const handler = (event: Event) => { - const detail = (event as CustomEvent<{ sessionId?: string; laneId?: string }>).detail; + const detail = (event as CustomEvent<{ + sessionId?: string; + laneId?: string; + /** + * Set only by callers that can target a session on ANOTHER machine — + * currently the command palette, whose thread search spans the same + * cross-machine union the Work sidebar shows. Without it a foreign + * session id would be focused against the locally-bound project and + * silently resolve to nothing (or, worse, a same-id local session). + */ + binding?: OpenProjectBinding | null; + }>).detail; const sessionId = detail?.sessionId; if (!sessionId) return; - // Callers (spawn cards, subagents pane) often don't know the target's - // lane. Resolve it from the loaded session list so cross-lane jumps - // land on the right lane instead of focusing an off-lane session. - const laneId = detail.laneId ?? work.sessions.find((s) => s.id === sessionId)?.laneId ?? null; - if (laneId) work.selectLane(laneId); - work.focusSession(sessionId); - work.openSessionTab(sessionId); - work.setSelectedSessionId(sessionId); + const focusActiveProject = () => { + // Callers (spawn cards, subagents pane) often don't know the target's + // lane. Resolve it from the loaded session list so cross-lane jumps + // land on the right lane instead of focusing an off-lane session. + const laneId = detail.laneId ?? work.sessions.find((s) => s.id === sessionId)?.laneId ?? null; + if (laneId) work.selectLane(laneId); + work.focusSession(sessionId); + work.openSessionTab(sessionId); + work.setSelectedSessionId(sessionId); + }; + const binding = detail.binding ?? null; + if (!binding) { + focusActiveProject(); + return; + } + // Foreign target: write the destination project's state directly after + // switching. Calling `work.*` here would use the hook instance captured + // before the switch and persist the selection into the old project. + const destinationProjectKey = binding.kind === "remote" ? binding.key : binding.rootPath; + const switching = binding.kind === "remote" + ? switchRemoteProject(binding.targetId, binding.projectId) + : switchProjectToPath(binding.rootPath); + void switching + .then(() => { + if (detail.laneId) selectLaneInStore(detail.laneId); + focusSessionInStore(sessionId); + setWorkViewState(destinationProjectKey, (previous) => ({ + ...previous, + openItemIds: previous.openItemIds.includes(sessionId) + ? previous.openItemIds + : [...previous.openItemIds, sessionId], + selectedItemId: sessionId, + activeItemId: sessionId, + })); + }) + .catch((error: unknown) => { + setSessionActionError(error instanceof Error ? error.message : String(error)); + }); }; window.addEventListener("ade:work:select-session", handler as EventListener); return () => window.removeEventListener("ade:work:select-session", handler as EventListener); - }, [work]); + }, [ + focusSessionInStore, + selectLaneInStore, + setWorkViewState, + switchProjectToPath, + switchRemoteProject, + work, + ]); const handleGoToLane = useCallback( ( diff --git a/apps/desktop/src/renderer/components/terminals/sessionLifecycleActions.ts b/apps/desktop/src/renderer/components/terminals/sessionLifecycleActions.ts index 7fccea917..1af0d2813 100644 --- a/apps/desktop/src/renderer/components/terminals/sessionLifecycleActions.ts +++ b/apps/desktop/src/renderer/components/terminals/sessionLifecycleActions.ts @@ -4,6 +4,10 @@ import type { TerminalSessionSummary, } from "../../../shared/types"; import { showToast } from "../app/toast/toastStore"; +import { + canonicalInputFromSummary, + sessionNeedsYou, +} from "../../lib/terminalAttention"; import { snoozeConfirmationLabel, snoozeDeadlineIso, @@ -96,6 +100,27 @@ export async function setSessionSettleOverride( } } +/** + * Settle one session through the binding-aware endpoint. Needs-you rows require + * an explicit dismissal flag so resolving their attention and filing them away + * remains one atomic lifecycle action. + */ +export async function settleSession( + session: TerminalSessionSummary, + pin?: OpenProjectBinding | null, +): Promise { + const options = sessionNeedsYou(canonicalInputFromSummary(session)) + ? { dismissPendingInput: true } + : undefined; + try { + await (pin + ? window.ade.sessions.settle(session.id, options, pin) + : window.ade.sessions.settle(session.id, options)); + } catch (error) { + reportFailure("Settle", session.id, error); + } +} + /** * Lift a declared settle. Both the Work row menu and the chat header chip route * through here, so the branch can never drift apart — and a failed write is diff --git a/apps/desktop/src/renderer/components/terminals/useWorkLaneContextMenu.tsx b/apps/desktop/src/renderer/components/terminals/useWorkLaneContextMenu.tsx index fef8cfbb0..a9fa7ed60 100644 --- a/apps/desktop/src/renderer/components/terminals/useWorkLaneContextMenu.tsx +++ b/apps/desktop/src/renderer/components/terminals/useWorkLaneContextMenu.tsx @@ -46,6 +46,77 @@ export type ForeignLaneContextTrigger = ( e: { preventDefault: () => void; clientX: number; clientY: number }, ) => void; +/** The behavioural half of the lane menu, minus the menu itself. */ +export type LaneMenuActions = { + onAdoptAttached: (laneId: string) => void; + onManage: (laneId: string) => void; + selectLane: (id: string) => void; + onRemoveFromSplit: (laneId: string) => void; + onCloseOtherSplits: (keepLaneId: string) => void; + onSelectAll: () => void; + onBatchManage: (laneIds: string[]) => void; + onStartChatInLane: (laneId: string) => void; +}; + +/** + * Wires the lane menu's actions once, for every surface that renders lane menu + * items: the lane divider's own menu here, and the "Lane ▸" submenu a singleton + * lane's session card carries. Items come from `buildLaneMenuGroups`; this is + * the other half of keeping those two surfaces from drifting apart. + * + * `onManageLane` is supplied by the host rather than resolved here because the + * manage dialog has to outlive the menu that opened it — the host owns the + * dialog state, this hook only routes the click. + */ +export function useLaneMenuActions(args: { + close: () => void; + onManageLane: (laneId: string) => void; +}): LaneMenuActions { + const { close, onManageLane } = args; + const navigate = useNavigate(); + const selectLane = useAppStore((s) => s.selectLane); + const projectStateKey = useAppStore(selectActiveProjectStateKey); + const setWorkViewState = useAppStore((s) => s.setWorkViewState); + + const goToLanesAction = useCallback( + (laneId: string | null, action: string, extras?: Record) => { + const init: Record = { action, ...(extras ?? {}) }; + if (laneId) init.laneId = laneId; + const params = new URLSearchParams(init); + if (laneId) selectLane(laneId); + void navigate(`/lanes?${params.toString()}`); + }, + [navigate, selectLane], + ); + + const startChatInLane = useStartChatInLane({ + projectStateKey, + setWorkViewState, + selectLane, + navigate, + }); + + return useMemo(() => ({ + onAdoptAttached: (laneId) => goToLanesAction(laneId, "adopt"), + onManage: (laneId) => { + close(); + onManageLane(laneId); + }, + selectLane: (laneId) => { + if (!laneId) return; + goToLanesAction(laneId, "split-open"); + }, + onRemoveFromSplit: (laneId) => goToLanesAction(laneId, "split-remove"), + onCloseOtherSplits: (keepLaneId) => goToLanesAction(keepLaneId, "split-close-others"), + onSelectAll: () => goToLanesAction(null, "select-all"), + onBatchManage: (laneIds) => { + if (!laneIds.length) return; + goToLanesAction(laneIds[0], "batch", { laneIds: laneIds.join(",") }); + }, + onStartChatInLane: startChatInLane, + }), [close, goToLanesAction, onManageLane, startChatInLane]); +} + export function useWorkLaneContextMenu(options?: { /** Work-sidebar lane pin. Omitted by callers that have no pinning surface. */ onToggleWorkPin?: (laneId: string) => void; @@ -119,23 +190,11 @@ export function useWorkLaneContextMenu(options?: { return () => document.removeEventListener("pointerdown", onPointerDown); }, [close, foreignMenuState, menuState]); - const goToLanesAction = useCallback( - (laneId: string | null, action: string, extras?: Record) => { - const init: Record = { action, ...(extras ?? {}) }; - if (laneId) init.laneId = laneId; - const params = new URLSearchParams(init); - if (laneId) selectLane(laneId); - void navigate(`/lanes?${params.toString()}`); - }, - [navigate, selectLane], - ); + const openManageDialog = useCallback((laneId: string) => { + setManagedLane({ laneId }); + }, []); + const laneMenuActions = useLaneMenuActions({ close, onManageLane: openManageDialog }); - const startChatInLane = useStartChatInLane({ - projectStateKey, - setWorkViewState, - selectLane, - navigate, - }); const startForeignChat = useCallback(() => { if (!foreignMenuState || !projectStateKey || !foreignMachineOnline) return; const laneId = foreignMenuState.lane.id; @@ -199,26 +258,7 @@ export function useWorkLaneContextMenu(options?: { lanesById={lanesById} visibleLaneIds={visibleLaneIds} onClose={close} - onAdoptAttached={(laneId) => goToLanesAction(laneId, "adopt")} - onManage={(laneId) => { - close(); - setManagedLane({ laneId }); - }} - selectLane={(laneId) => { - if (!laneId) return; - goToLanesAction(laneId, "split-open"); - }} - onRemoveFromSplit={(laneId) => goToLanesAction(laneId, "split-remove")} - onCloseOtherSplits={(keepLaneId) => goToLanesAction(keepLaneId, "split-close-others")} - onSelectAll={() => { - if (!menuState) return; - goToLanesAction(null, "select-all"); - }} - onBatchManage={(laneIds) => { - if (!laneIds.length) return; - goToLanesAction(laneIds[0], "batch", { laneIds: laneIds.join(",") }); - }} - onStartChatInLane={startChatInLane} + {...laneMenuActions} onToggleWorkPin={options?.onToggleWorkPin} workPinnedLaneIds={options?.workPinnedLaneIds} /> diff --git a/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts b/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts index ae9fc37fa..dfa984180 100644 --- a/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts +++ b/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts @@ -516,9 +516,18 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) sessionListOrganization: deeplinkViewOverride.sessionListOrganization ?? persisted.sessionListOrganization, workCollapsedSectionIds: deeplinkViewOverride.expandSectionId - ? (persisted.workCollapsedSectionIds ?? []).filter( - (sectionId) => sectionId !== deeplinkViewOverride.expandSectionId, - ) + ? [ + ...(persisted.workCollapsedSectionIds ?? []).filter( + (sectionId) => sectionId !== deeplinkViewOverride.expandSectionId, + ), + // The quiet shelves (`status:snoozed` / `status:settled`) default to + // COLLAPSED, so dropping their id is not enough to open one — absence + // is their closed state. They read an explicit `shelf-open:` marker + // instead (see `quietShelfOpenMarker` in SessionListPane), which this + // transient layer supplies for the navigation. Harmless for every + // other section: nothing consults a marker they do not use. + `shelf-open:${deeplinkViewOverride.expandSectionId}`, + ] : persisted.workCollapsedSectionIds, }; }, [deeplinkViewOverride, projectStateKey, workViewByProject]); diff --git a/apps/desktop/src/renderer/components/ui/MenuSubmenu.tsx b/apps/desktop/src/renderer/components/ui/MenuSubmenu.tsx new file mode 100644 index 000000000..16d2b8043 --- /dev/null +++ b/apps/desktop/src/renderer/components/ui/MenuSubmenu.tsx @@ -0,0 +1,305 @@ +import React, { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, + type CSSProperties, + type ReactNode, +} from "react"; + +/** + * Hover *intent*, not hover. A menu is a list you scan by dragging the pointer + * down it, so a submenu that opens the instant the cursor touches its row would + * flash open on every row you merely pass over. + */ +export const SUBMENU_OPEN_DELAY_MS = 180; +/** + * The diagonal-approach grace period. Leaving the trigger row toward the panel + * necessarily crosses rows that are neither, so the submenu has to survive a + * short gap with the pointer over nothing. A safe-triangle would be tighter but + * needs continuous pointer tracking; a close delay buys the same forgiveness for + * a fraction of the machinery, and it is what the row spacing here calls for. + */ +export const SUBMENU_CLOSE_DELAY_MS = 300; + +/** Viewport gutter kept clear on every side when the panel is placed. */ +const VIEWPORT_MARGIN = 8; +/** Panel overlaps its trigger row slightly so the pointer never crosses a gap. */ +const PANEL_OVERLAP = 3; + +export type MenuSubmenuProps = { + /** Row label. */ + label: ReactNode; + /** Right-aligned trailing hint on the row, rendered before the chevron. */ + hint?: ReactNode; + /** Trigger row classes — pass the host menu's own item class. */ + className?: string; + /** Trigger row inline style, for menus (the lane menu) that style inline. */ + style?: CSSProperties; + /** + * Hover fill for inline-styled hosts. The lane menu paints its own hover in + * JS rather than CSS, so the row has to be told what its hover looks like. + */ + hoverBackground?: string; + /** ARIA role for the row; menus with `role="menu"` should pass `menuitem`. */ + role?: string; + panelClassName?: string; + panelStyle?: CSSProperties; + /** Minimum panel width; defaults to the session menu's own min width. */ + panelMinWidth?: number; + "data-testid"?: string; + title?: string; + disabled?: boolean; + /** + * Fired every time the panel opens, before its content renders. Content that + * is a snapshot of the wall clock (snooze presets) resolves here so it is + * correct at open time rather than at mount time. + */ + onOpen?: () => void; + children: ReactNode; +}; + +/** + * A menu row that expands a second panel to its right on hover-intent, flipping + * to the left when the viewport has no room. + * + * The panel is `position: fixed` and rendered inline rather than portalled: a + * portal would escape the host menu's `onPointerDown` stop-propagation guard, + * and every menu in the app relies on that guard to keep a click inside itself + * from reaching the document-level dismiss listener. Fixed positioning already + * escapes the lane menu's `overflow-y: auto` clip, which was the only reason to + * want a portal. + */ +export function MenuSubmenu({ + label, + hint, + className, + style, + hoverBackground, + role, + panelClassName, + panelStyle, + panelMinWidth = 180, + "data-testid": testId, + title, + disabled = false, + onOpen, + children, +}: MenuSubmenuProps) { + const [open, setOpen] = useState(false); + const [position, setPosition] = useState<{ left: number; top: number } | null>(null); + const triggerRef = useRef(null); + const panelRef = useRef(null); + const openTimer = useRef | null>(null); + const closeTimer = useRef | null>(null); + // Set only when the panel was opened from the keyboard, so pointer users do + // not get focus yanked off the row they are still hovering. + const focusPanelRef = useRef(false); + + const clearTimers = useCallback(() => { + if (openTimer.current) { clearTimeout(openTimer.current); openTimer.current = null; } + if (closeTimer.current) { clearTimeout(closeTimer.current); closeTimer.current = null; } + }, []); + + useEffect(() => clearTimers, [clearTimers]); + + // Mirrors `open` so `onOpen` can fire exactly once per open without running + // inside a state updater, where a caller's setState would land mid-render. + const openRef = useRef(false); + + const openNow = useCallback(() => { + clearTimers(); + if (openRef.current) return; + openRef.current = true; + onOpen?.(); + setOpen(true); + }, [clearTimers, onOpen]); + + const closeNow = useCallback(() => { + clearTimers(); + openRef.current = false; + setOpen(false); + setPosition(null); + if (hoverBackground && triggerRef.current) { + triggerRef.current.style.background = "transparent"; + } + }, [clearTimers, hoverBackground]); + + const scheduleOpen = useCallback(() => { + if (disabled || open) return; + clearTimers(); + openTimer.current = setTimeout(() => { + openTimer.current = null; + openNow(); + }, SUBMENU_OPEN_DELAY_MS); + }, [clearTimers, disabled, open, openNow]); + + const scheduleClose = useCallback(() => { + clearTimers(); + closeTimer.current = setTimeout(() => { + closeTimer.current = null; + closeNow(); + }, SUBMENU_CLOSE_DELAY_MS); + }, [clearTimers, closeNow]); + + // Place after the panel has a measurable size, then reveal it. Measuring in a + // layout effect keeps the flip decision in the same frame as the paint, so a + // panel near the right edge never shows in the wrong place first. + useLayoutEffect(() => { + if (!open) return; + const trigger = triggerRef.current; + const panel = panelRef.current; + if (!trigger || !panel) return; + const rect = trigger.getBoundingClientRect(); + const width = panel.offsetWidth || panelMinWidth; + const height = panel.offsetHeight; + const viewportWidth = window.innerWidth || 0; + const viewportHeight = window.innerHeight || 0; + + let left = rect.right - PANEL_OVERLAP; + if (left + width > viewportWidth - VIEWPORT_MARGIN) { + left = Math.max(VIEWPORT_MARGIN, rect.left - width + PANEL_OVERLAP); + } + let top = rect.top - 4; + if (top + height > viewportHeight - VIEWPORT_MARGIN) { + top = Math.max(VIEWPORT_MARGIN, viewportHeight - VIEWPORT_MARGIN - height); + } + setPosition({ left, top }); + }, [open, panelMinWidth]); + + useEffect(() => { + if (!open || !focusPanelRef.current) return; + focusPanelRef.current = false; + focusFirstItem(panelRef.current); + }, [open]); + + const returnFocusAndClose = useCallback(() => { + closeNow(); + triggerRef.current?.focus(); + }, [closeNow]); + + return ( + <> + + {open ? ( +
    { + if (event.key === "Escape" || event.key === "ArrowLeft") { + event.preventDefault(); + event.stopPropagation(); + returnFocusAndClose(); + return; + } + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + moveFocus(panelRef.current, event.key === "ArrowDown" ? 1 : -1); + } + }} + > + {children} +
    + ) : null} + + ); +} + +function focusableItems(panel: HTMLElement | null): HTMLElement[] { + if (!panel) return []; + return Array.from(panel.querySelectorAll("button:not([disabled])")); +} + +function focusFirstItem(panel: HTMLElement | null): void { + focusableItems(panel)[0]?.focus(); +} + +function moveFocus(panel: HTMLElement | null, delta: number): void { + const items = focusableItems(panel); + if (!items.length) return; + const current = items.indexOf(document.activeElement as HTMLElement); + const next = current < 0 + ? (delta > 0 ? 0 : items.length - 1) + : (current + delta + items.length) % items.length; + items[next]?.focus(); +} + +/** + * Quiet section label. Deliberately the same weight as the sidebar's shelf + * labels: a menu heading is a wayfinding aid, not a row, and must never compete + * with the items under it. + */ +export function MenuSectionLabel({ children }: { children: ReactNode }) { + return ( +
    + {children} +
    + ); +} + +export function MenuSeparator() { + return
    ; +} diff --git a/apps/desktop/src/renderer/components/work/WorkSurfaceHeader.test.tsx b/apps/desktop/src/renderer/components/work/WorkSurfaceHeader.test.tsx index 2800e922c..6a4c88733 100644 --- a/apps/desktop/src/renderer/components/work/WorkSurfaceHeader.test.tsx +++ b/apps/desktop/src/renderer/components/work/WorkSurfaceHeader.test.tsx @@ -30,6 +30,13 @@ afterEach(() => { }); describe("WorkSurfaceHeader", () => { + it("uses the canonical 32px work-surface rail", () => { + render(); + const header = screen.getByTestId("surface-header"); + expect(header.className).toContain("h-8"); + expect(header.firstElementChild?.className).toContain("w-full"); + }); + it("renders the title and skips lane chip / git toolbar / cache badge when their flags are off", () => { render(); expect(screen.getByText("Some surface")).toBeTruthy(); diff --git a/apps/desktop/src/renderer/components/work/WorkSurfaceHeader.tsx b/apps/desktop/src/renderer/components/work/WorkSurfaceHeader.tsx index 348780f33..dab33a23a 100644 --- a/apps/desktop/src/renderer/components/work/WorkSurfaceHeader.tsx +++ b/apps/desktop/src/renderer/components/work/WorkSurfaceHeader.tsx @@ -162,7 +162,8 @@ export const WORK_SURFACE_HEADER_ACTION_BASE = export const WORK_SURFACE_HEADER_ACTION_IDLE = "border-white/[0.06] bg-white/[0.02] text-muted-fg/40 hover:border-white/[0.10] hover:text-fg/65"; -export const WORK_SURFACE_HEADER_CLASS = "space-y-1 px-2 py-1"; +/** Canonical 32px title rail shared by chat and CLI work surfaces. */ +export const WORK_SURFACE_HEADER_CLASS = "flex h-8 items-center px-2"; export type WorkSurfaceHeaderProps = { /** Primary surface title (chat name, CLI session label, etc.). */ @@ -258,7 +259,7 @@ export function WorkSurfaceHeader({ const tileDragProps = embeddedChrome?.dragHandleProps ?? null; return (
    -
    +
    {onToggleSessionsPane ? ( { expect(until - now).toBeGreaterThan(365 * 24 * 3_600_000); expect(snoozeWakeLabel(new Date(until).toISOString(), now)).toBe("wakes when asked"); }); + + it("targets the coming Monday 9am for the next-week preset", () => { + // Wednesday 2026-04-08 → Monday 2026-04-13. + expect(Date.parse(snoozeDeadlineIso("next-week", localMs(2026, 4, 8, 10)))) + .toBe(localMs(2026, 4, 13, 9)); + }); + + it("puts next week a FULL week out when today is already Monday", () => { + // Monday 2026-04-06 must not resolve to "in a few hours" the same morning. + expect(Date.parse(snoozeDeadlineIso("next-week", localMs(2026, 4, 6, 10)))) + .toBe(localMs(2026, 4, 13, 9)); + // Sunday is the other edge of the modulo: next Monday is tomorrow. + expect(Date.parse(snoozeDeadlineIso("next-week", localMs(2026, 4, 12, 10)))) + .toBe(localMs(2026, 4, 13, 9)); + }); +}); + +/** Hours local time skips between two instants — 1 across a spring-forward, 0 in UTC. */ +function dstShiftHours(fromMs: number, toMs: number): number { + return (new Date(fromMs).getTimezoneOffset() - new Date(toMs).getTimezoneOffset()) / 60; +} + +/** + * Regression guard for the classic snooze bug: advancing a deadline by + * `24 * 60 * 60 * 1000` instead of by a calendar day. A spring-forward day is + * 23 hours, so a fixed offset taken at 23:30 the night before skips clean over + * the next day and lands on the one after. + * + * These run against the MACHINE'S zone. Repointing `process.env.TZ` inside a + * vitest worker does not move V8's cached zone, so pinning one here would look + * rigorous while asserting nothing. The consequence is honest instead: in a + * DST zone (every dev box in a populated timezone) the assertions below fail on + * the naive implementation; on a UTC CI box there is no transition to trip over + * and they reduce to the ordinary calendar case. Both are stated explicitly so + * the elapsed-time expectations stay exact rather than being loosened. + */ +describe("snoozeDeadlineIso across a DST transition", () => { + // 2026-03-08 is the US spring-forward (02:00 EST → 03:00 EDT). + it("lands tomorrow 9am on the spring-forward day itself, from 23:30 the night before", () => { + const springForwardEve = localMs(2026, 3, 7, 23, 30); + const until = new Date(snoozeDeadlineIso("tomorrow", springForwardEve)); + expect(until.getDate()).toBe(8); + expect(until.getHours()).toBe(9); + // 9.5 wall-clock hours, minus whatever the zone skipped. A `+24h` deadline + // could not produce this figure at all — it would be past the 9th. + const lost = dstShiftHours(springForwardEve, until.getTime()); + expect(until.getTime() - springForwardEve).toBe((9.5 - lost) * 3_600_000); + }); + + it("lands the correct morning after the fall-back day's extra hour", () => { + // 2026-11-01 02:00 EDT → 01:00 EST: a 25-hour day. + const fallBackEve = localMs(2026, 10, 31, 23, 30); + const until = new Date(snoozeDeadlineIso("tomorrow", fallBackEve)); + expect(until.getDate()).toBe(1); + expect(until.getMonth()).toBe(10); + expect(until.getHours()).toBe(9); + }); + + it("keeps next week on a Monday when a transition falls inside the window", () => { + // Tuesday 2026-03-03 → Monday 2026-03-09, the day after spring forward. + const until = new Date(snoozeDeadlineIso("next-week", localMs(2026, 3, 3, 10))); + expect(until.getDay()).toBe(1); + expect(until.getDate()).toBe(9); + expect(until.getHours()).toBe(9); + }); + + it("puts tomorrow on the next calendar day from 23:30 on every day of a year", () => { + // Sweeps whichever transitions the running zone actually has, in both + // directions, from the hour of day where a fixed 24h offset does the most + // damage. Cheap, and it needs no knowledge of the zone's rules. + for (let dayIndex = 0; dayIndex < 365; dayIndex += 1) { + const at2330 = new Date(2026, 0, 1 + dayIndex, 23, 30, 0, 0).getTime(); + const expected = new Date(2026, 0, 2 + dayIndex, 9, 0, 0, 0).getTime(); + expect(Date.parse(snoozeDeadlineIso("tomorrow", at2330))).toBe(expected); + } + }); +}); + +describe("resolveSnoozePresets", () => { + it("offers every preset in the morning, open-ended last", () => { + const presets = resolveSnoozePresets(localMs(2026, 4, 8, 10)); + expect(presets.map((preset) => preset.key)).toEqual([ + "hour", + "evening", + "tomorrow", + "next-week", + "asked", + ]); + }); + + it("drops 'This evening' once 6pm is within an hour or already past", () => { + // 17:30 — the evening deadline would duplicate "In 1 hour". + expect(resolveSnoozePresets(localMs(2026, 4, 8, 17, 30)).map((preset) => preset.key)) + .toEqual(["hour", "tomorrow", "next-week", "asked"]); + // 21:00 — "This evening" would silently resolve to TOMORROW evening. + expect(resolveSnoozePresets(localMs(2026, 4, 8, 21)).map((preset) => preset.key)) + .toEqual(["hour", "tomorrow", "next-week", "asked"]); + // 16:59 is still more than an hour out, so the row survives. + expect(resolveSnoozePresets(localMs(2026, 4, 8, 16, 59)).map((preset) => preset.key)) + .toContain("evening"); + }); + + it("keeps the open-ended preset at every hour of the day", () => { + // The `asked` row has no clock semantics, so nothing may ever suppress it — + // it is the only preset backed by the session's own wake signals. + for (let hour = 0; hour < 24; hour += 1) { + const presets = resolveSnoozePresets(localMs(2026, 4, 8, hour)); + expect(presets[presets.length - 1]!.key).toBe("asked"); + expect(presets.find((preset) => preset.key === "asked")!.whenLabel).toBe("on a hand-raise"); + } + }); + + it("gives every row a time column that complements rather than repeats its label", () => { + const presets = resolveSnoozePresets(localMs(2026, 4, 8, 10)); + for (const preset of presets) { + // Day words live in the label column; the time column carries the clock + // (plus a weekday for next week, which names a different day). "In 1 + // hour" is a duration, not a clock time, so only clock times are barred. + expect(preset.label.toLowerCase()).not.toMatch(/\d\s*(am|pm)|\d:\d/); + expect(preset.whenLabel.toLowerCase()).not.toContain("tomorrow"); + } + expect(presets.find((preset) => preset.key === "tomorrow")!.whenLabel).toMatch(/9/); + expect(presets.find((preset) => preset.key === "next-week")!.whenLabel).toMatch(/Mon/); + }); + + it("hands each row the deadline it will actually write", () => { + const now = localMs(2026, 4, 8, 10); + for (const preset of resolveSnoozePresets(now)) { + expect(preset.untilIso).toBe(snoozeDeadlineIso(preset.key, now)); + expect(Date.parse(preset.untilIso)).toBeGreaterThan(now); + } + }); }); describe("snoozeWakeLabel", () => { @@ -69,6 +203,63 @@ describe("snoozeWakeLabel", () => { }); }); +describe("snoozeWakeDescription", () => { + // Wednesday 2026-04-08 10:00 local. + const now = localMs(2026, 4, 8, 10); + + it("gives a bare time for a wake later today", () => { + const description = snoozeWakeDescription(new Date(localMs(2026, 4, 8, 18)).toISOString(), now); + expect(description).toMatch(/\d/); + expect(description).not.toContain("tomorrow"); + expect(description).not.toMatch(/Wed|,/); + }); + + it("names tomorrow explicitly", () => { + expect(snoozeWakeDescription(new Date(localMs(2026, 4, 9, 9)).toISOString(), now)) + .toMatch(/^tomorrow /); + }); + + it("uses a weekday inside the coming week", () => { + expect(snoozeWakeDescription(new Date(localMs(2026, 4, 13, 9)).toISOString(), now)) + .toMatch(/^Mon /); + }); + + it("switches to a date past a week, where a weekday would be ambiguous", () => { + const description = snoozeWakeDescription( + new Date(localMs(2026, 4, 20, 9)).toISOString(), + now, + ); + expect(description).not.toMatch(/^tomorrow |^Mon /); + // The date branch is the only one that carries a comma ("Apr 20, 9:00 AM"), + // which keeps the assertion off the locale's month spelling. + expect(description).toMatch(/20/); + expect(description).toContain(","); + }); + + it("reads the ~100-year 'until asked' deadline as a condition, not a date", () => { + expect(snoozeWakeDescription(snoozeDeadlineIso("asked", now), now)).toBe("when you're asked"); + }); + + it("returns null without a deadline and 'now' once it has lapsed", () => { + expect(snoozeWakeDescription(null, now)).toBeNull(); + expect(snoozeWakeDescription("not-a-date", now)).toBeNull(); + expect(snoozeWakeDescription(new Date(now - 1).toISOString(), now)).toBe("now"); + }); + + it("counts calendar days, not 24h blocks, across a spring-forward", () => { + // Sat 2026-03-07 10:00 → Mon 2026-03-09 09:00 is two calendar days but, in + // a zone observing the 2026-03-08 spring-forward, only 46 hours of real + // time. Dividing the remaining milliseconds by 24h floors that to 1 and + // mislabels Monday as "tomorrow"; `calendarDayDelta` does not. + const beforeTransition = localMs(2026, 3, 7, 10); + expect(snoozeWakeDescription(new Date(localMs(2026, 3, 9, 9)).toISOString(), beforeTransition)) + .toMatch(/^Mon /); + // …and the genuine next day still reads as tomorrow. + expect(snoozeWakeDescription(new Date(localMs(2026, 3, 8, 9)).toISOString(), beforeTransition)) + .toMatch(/^tomorrow /); + }); +}); + describe("wakeReasonLabel", () => { it("uses specific operational copy per reason", () => { expect(wakeReasonLabel("needs_you")).toBe("needs approval"); diff --git a/apps/desktop/src/renderer/lib/sessionSnooze.ts b/apps/desktop/src/renderer/lib/sessionSnooze.ts index a35995ed6..d7a82a42b 100644 --- a/apps/desktop/src/renderer/lib/sessionSnooze.ts +++ b/apps/desktop/src/renderer/lib/sessionSnooze.ts @@ -18,18 +18,32 @@ import { * changing what the row's status dot says. */ -export type SnoozeDurationKey = "hour" | "evening" | "tomorrow" | "asked"; +export type SnoozeDurationKey = "hour" | "evening" | "tomorrow" | "next-week" | "asked"; export type SnoozeDurationOption = { key: SnoozeDurationKey; label: string; }; -/** Menu order is fixed: shortest window first, open-ended last. */ +/** + * Menu order is fixed: shortest window first, open-ended last. + * + * Labels name the DAY, never the clock time — the menu renders a separate time + * column (`SnoozePreset.whenLabel`) beside them, and "Until tomorrow 9am" next + * to "9:00 AM" says the same thing twice. The one exception is "Until I'm + * asked", which names an intent rather than a day and has no clock time to + * repeat. + * + * This is the STATIC superset — every key, in order, with no time attached. It + * is the vocabulary, not the menu: rendering surfaces go through + * `resolveSnoozePresets`, which resolves each row against the wall clock and + * drops the ones that have stopped making sense at the current time of day. + */ export const SNOOZE_DURATION_OPTIONS: readonly SnoozeDurationOption[] = [ - { key: "hour", label: "1 hour" }, - { key: "evening", label: "Until this evening" }, - { key: "tomorrow", label: "Until tomorrow 9am" }, + { key: "hour", label: "In 1 hour" }, + { key: "evening", label: "This evening" }, + { key: "tomorrow", label: "Tomorrow" }, + { key: "next-week", label: "Next week" }, { key: "asked", label: "Until I'm asked" }, ]; @@ -43,7 +57,19 @@ const INDEFINITE_LABEL_THRESHOLD_MS = 365 * DAY_MS; const EVENING_HOUR = 18; const MORNING_HOUR = 9; +/** `Date.prototype.getDay()` for Monday, the day "next week" always lands on. */ +const MONDAY = 1; +/** + * A local wall-clock hour, `dayOffset` CALENDAR days from `base`. + * + * Deliberately built from Y/M/D fields rather than by adding `dayOffset * + * DAY_MS`: a fixed millisecond offset lands on the wrong local day across a DST + * transition. A spring-forward day is 23 hours, so `23:30 + 24h` skips the + * whole next day, and "snooze until tomorrow 9am" would silently become the day + * after. The `Date` constructor normalises out-of-range day numbers (and month + * rollover) using the calendar, so this stays correct across every transition. + */ function atLocalHour(base: Date, hour: number, dayOffset = 0): Date { const next = new Date(base.getFullYear(), base.getMonth(), base.getDate() + dayOffset, hour, 0, 0, 0); return next; @@ -60,12 +86,22 @@ export function snoozeDeadlineIso(key: SnoozeDurationKey, nowMs: number = Date.n return new Date(nowMs + HOUR_MS).toISOString(); case "evening": { const evening = atLocalHour(now, EVENING_HOUR); - // Past 6pm already: this evening has gone, so roll to the next one. + // Past 6pm already: this evening has gone, so roll to the next one. The + // desktop menu suppresses the row entirely by then (see + // `resolveSnoozePresets`), but the CLI can still name the preset directly + // and must not get a deadline in the past. if (evening.getTime() <= nowMs) return atLocalHour(now, EVENING_HOUR, 1).toISOString(); return evening.toISOString(); } case "tomorrow": return atLocalHour(now, MORNING_HOUR, 1).toISOString(); + case "next-week": { + // Next Monday 9am — a FULL week out when today is already Monday, because + // "next week" must never resolve to "in a few hours". `|| 7` turns the + // zero-day case (today is Monday) into the following Monday. + const daysUntilMonday = (MONDAY - now.getDay() + 7) % 7 || 7; + return atLocalHour(now, MORNING_HOUR, daysUntilMonday).toISOString(); + } case "asked": default: return new Date(nowMs + INDEFINITE_MS).toISOString(); @@ -81,12 +117,76 @@ export function snoozeConfirmationLabel(key: SnoozeDurationKey): string { return "until this evening"; case "tomorrow": return "until 9am"; + case "next-week": + return "until next Monday"; case "asked": default: return "until you're asked"; } } +function timeOfDayLabel(date: Date): string { + return date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }); +} + +function weekdayLabel(date: Date): string { + return date.toLocaleDateString(undefined, { weekday: "short" }); +} + +/** + * A menu row: the fixed label, the time column beside it, and the deadline the + * row will write if clicked. + */ +export type SnoozePreset = SnoozeDurationOption & { + /** + * The menu's right-hand time column. Complements the label instead of + * repeating it: "Tomorrow" pairs with "9:00 AM", not "tomorrow 9:00 AM". The + * open-ended preset has no clock time, so it names its wake CONDITION + * instead — an empty column would read as a missing value rather than a + * deliberate one. + */ + whenLabel: string; + /** Deadline resolved against the `nowMs` the list was built with. */ + untilIso: string; +}; + +function presetWhenLabel(key: SnoozeDurationKey, untilIso: string): string { + if (key === "asked") return "on a hand-raise"; + const wake = new Date(untilIso); + const time = timeOfDayLabel(wake); + // "Next week" is the one preset whose label does not identify a day, so its + // column carries the weekday as well as the time. + return key === "next-week" ? `${weekdayLabel(wake)} ${time}` : time; +} + +/** + * The snooze menu, resolved against local time. + * + * Not a constant, because one of the rows only makes sense at certain hours: + * "This evening" is dropped once 6pm is within an hour (it would just duplicate + * "In 1 hour") or already past (a row labelled "This evening" that resolves to + * TOMORROW evening is a lie). Past that point the list simply starts at + * "Tomorrow". Callers must therefore resolve per-open and never hoist the + * result to module scope, or a long-lived process freezes that judgement at + * startup. + * + * "Until I'm asked" always survives: it has no clock semantics to go stale, and + * it is the only preset whose wake is driven by the session rather than the + * calendar (see `resolveSessionWakeReason`). + */ +export function resolveSnoozePresets(nowMs: number = Date.now()): readonly SnoozePreset[] { + const eveningMs = atLocalHour(new Date(nowMs), EVENING_HOUR).getTime(); + const eveningIsUseful = eveningMs - nowMs > HOUR_MS; + + const presets: SnoozePreset[] = []; + for (const option of SNOOZE_DURATION_OPTIONS) { + if (option.key === "evening" && !eveningIsUseful) continue; + const untilIso = snoozeDeadlineIso(option.key, nowMs); + presets.push({ ...option, whenLabel: presetWhenLabel(option.key, untilIso), untilIso }); + } + return presets; +} + function parseIsoMs(value: string | null | undefined): number | null { if (typeof value !== "string") return null; const trimmed = value.trim(); @@ -125,6 +225,43 @@ export function snoozeWakeLabel( return `wakes in ${Math.max(1, dayDelta)}d`; } +/** + * The absolute wake TIME, for menus and toasts: "9:00 AM", "tomorrow 9:00 AM", + * "Mon 9:00 AM", "Sep 3, 9:00 AM". + * + * Distinct from `snoozeWakeLabel` on purpose. The row label answers "how much + * longer" in the least space possible ("wakes in 3h") because it sits in a + * status slot that a hundred rows share. This answers "when, exactly", which is + * what you need when you are about to commit to a deadline or have just been + * told one — a toast that says "wakes in 18h" makes the reader do arithmetic. + * + * Day words come from `calendarDayDelta` rather than dividing the remaining + * milliseconds by 24h: across a spring-forward transition "tomorrow 9am" is + * only 23 hours out, and a millisecond division would report it as today. + */ +export function snoozeWakeDescription( + snoozedUntil: string | null | undefined, + nowMs: number = Date.now(), +): string | null { + const untilMs = parseIsoMs(snoozedUntil); + if (untilMs == null) return null; + const remaining = untilMs - nowMs; + if (remaining <= 0) return "now"; + // The open-ended preset parks the deadline ~100 years out; printing that date + // would be technically true and completely useless. + if (remaining >= INDEFINITE_LABEL_THRESHOLD_MS) return "when you're asked"; + + const wake = new Date(untilMs); + const time = timeOfDayLabel(wake); + const dayDelta = calendarDayDelta(nowMs, untilMs); + if (dayDelta === 0) return time; + if (dayDelta === 1) return `tomorrow ${time}`; + // Inside the coming week a weekday is the fastest thing to read; past it the + // weekday becomes ambiguous ("Mon" — which Monday?) so switch to a date. + if (dayDelta < 7) return `${weekdayLabel(wake)} ${time}`; + return `${wake.toLocaleDateString(undefined, { month: "short", day: "numeric" })}, ${time}`; +} + /** * Specific, operational copy for why a snoozed row came back. Deliberately not * "woke up" — the user needs to know what changed. diff --git a/apps/desktop/src/renderer/lib/terminalAttention.test.ts b/apps/desktop/src/renderer/lib/terminalAttention.test.ts index 4c15502c4..3170f8fc1 100644 --- a/apps/desktop/src/renderer/lib/terminalAttention.test.ts +++ b/apps/desktop/src/renderer/lib/terminalAttention.test.ts @@ -36,36 +36,59 @@ describe("terminalAttention", () => { expect(sanitizeTerminalInlineText("\u001b_Gi=31337,s=1,v=1,a=q,t=d,f=24;AAAA\u001b\\opencode ready")).toBe("opencode ready"); }); - it("treats idle chat sessions as a static ready state", () => { + it("collapses separator rules so a preview cannot draw a line across the row", () => { + // A run of rule characters fills the sidebar row edge to edge and renders + // as a horizontal line struck through the card — it reads as a broken + // layout rather than as text. + expect(sanitizeTerminalInlineText("---------------------------- done")).toBe("- done"); + expect(sanitizeTerminalInlineText("━━━━━━━━ output")).toBe("━ output"); + expect(sanitizeTerminalInlineText("____________ built")).toBe("_ built"); + }); + + it("leaves ordinary repeated punctuation alone", () => { + // Collapsing these would change the tone of a message, not just its + // geometry — they are speech, not furniture. + expect(sanitizeTerminalInlineText("done...")).toBe("done..."); + expect(sanitizeTerminalInlineText("really!!!")).toBe("really!!!"); + expect(sanitizeTerminalInlineText("## Heading")).toBe("## Heading"); + }); + + it("treats an idle chat as Done — finished, not asking for anything", () => { const dot = sessionStatusDot({ status: "running", lastOutputPreview: "Completed response", runtimeState: "idle", toolType: "claude-chat", }); - expect(dot.label).toBe("Ready"); - expect(dot.cls).toContain("bg-amber"); + expect(dot.label).toBe("Done"); + expect(dot.cls).toContain("bg-emerald"); }); - it("treats idle AI CLI sessions as needing attention", () => { + it("treats an idle AI CLI as Done, on the same emerald as an idle chat", () => { const dot = sessionStatusDot({ status: "running", lastOutputPreview: "Analyzed project state", runtimeState: "idle", toolType: "codex", }); - expect(dot.label).toBe("Idle"); - expect(dot.cls).toContain("bg-amber"); + expect(dot.label).toBe("Done"); + expect(dot.cls).toContain("bg-emerald"); }); - it("keeps plain shell sessions active when they simply go idle", () => { + it("leaves a plain shell at its prompt calm — emerald, never amber", () => { const dot = sessionStatusDot({ status: "running", lastOutputPreview: "admin@Mac test-4-6a625aeb %", runtimeState: "idle", toolType: "shell", }); - expect(dot.label).toBe("Running"); + // A shell sitting at a prompt resolves to the `idle` phase, which shares + // the emerald "Done" presentation with a resting chat. The label is a + // slightly loose fit for a shell — it is not "done" with anything — but + // the invariant this test actually guards is that it is NOT amber: the old + // dot special-cased tool types here, which is how amber ended up meaning + // five different things. One vocabulary, no per-tool exceptions. + expect(dot.label).toBe("Done"); expect(dot.cls).toContain("bg-emerald"); }); @@ -122,14 +145,14 @@ describe("terminalAttention", () => { }); describe("sessionStatusDot", () => { - it("returns a solid emerald dot for a running active session", () => { + it("returns a solid sky dot for a running active session", () => { const dot = sessionStatusDot({ status: "running", lastOutputPreview: "building project...", }); expect(dot.spinning).toBe(false); - expect(dot.cls).toContain("emerald"); - expect(dot.label).toBe("Running"); + expect(dot.cls).toContain("sky"); + expect(dot.label).toBe("Working"); }); it("keeps a prompt-looking running session non-interrupting", () => { @@ -138,11 +161,11 @@ describe("terminalAttention", () => { lastOutputPreview: "Confirm continue? (y/n)", }); expect(dot.spinning).toBe(false); - expect(dot.cls).toContain("emerald"); - expect(dot.label).toBe("Running"); + expect(dot.cls).toContain("sky"); + expect(dot.label).toBe("Working"); }); - it("returns a solid amber dot for an idle chat session", () => { + it("returns a solid emerald dot for an idle chat session", () => { const dot = sessionStatusDot({ status: "running", lastOutputPreview: "Completed response", @@ -150,11 +173,11 @@ describe("terminalAttention", () => { toolType: "claude-chat", }); expect(dot.spinning).toBe(false); - expect(dot.cls).toContain("amber"); - expect(dot.label).toBe("Ready"); + expect(dot.cls).toContain("emerald"); + expect(dot.label).toBe("Done"); }); - it("returns a solid amber dot with an idle label for idle AI CLI sessions", () => { + it("returns a solid emerald Done dot for idle AI CLI sessions", () => { const dot = sessionStatusDot({ status: "running", lastOutputPreview: "Analyzed project state", @@ -162,22 +185,22 @@ describe("terminalAttention", () => { toolType: "claude", }); expect(dot.spinning).toBe(false); - expect(dot.cls).toContain("amber"); - expect(dot.label).toBe("Idle"); + expect(dot.cls).toContain("emerald"); + expect(dot.label).toBe("Done"); }); - it("returns a solid red dot for an ended CLI session", () => { + it("returns a neutral dot for an ended CLI session", () => { const dot = sessionStatusDot({ status: "completed", lastOutputPreview: "Process exited with code 0", toolType: "claude", }); expect(dot.spinning).toBe(false); - expect(dot.cls).toContain("red"); + expect(dot.cls).toContain("white"); expect(dot.label).toBe("Ended"); }); - it("returns a solid red ended dot for a detached session", () => { + it("returns a neutral ended dot for a detached session", () => { const dot = sessionStatusDot({ status: "detached", lastOutputPreview: "Last preserved output", @@ -185,11 +208,11 @@ describe("terminalAttention", () => { toolType: "codex", }); expect(dot.spinning).toBe(false); - expect(dot.cls).toContain("red"); + expect(dot.cls).toContain("white"); expect(dot.label).toBe("Ended"); }); - it("returns a stopped red dot for a disposed session", () => { + it("returns a neutral stopped dot for a disposed session", () => { const dot = sessionStatusDot({ status: "disposed", lastOutputPreview: "Stopped by user", @@ -197,7 +220,10 @@ describe("terminalAttention", () => { toolType: "codex", }); expect(dot.spinning).toBe(false); - expect(dot.cls).toContain("red"); + // Neutral, NOT red: exit 130/143 is the user pressing stop. Spending the + // alarm hue on an outcome the user chose is what trains people to ignore + // red — see sessionStatusPresentation's header. + expect(dot.cls).toContain("white"); expect(dot.label).toBe("Stopped"); }); @@ -213,15 +239,15 @@ describe("terminalAttention", () => { expect(dot.label).toBe("Failed"); }); - it("returns a ready amber dot for a non-running agent chat session", () => { + it("returns an emerald Done dot for a non-running agent chat session", () => { const dot = sessionStatusDot({ status: "completed", lastOutputPreview: "Last response preview", toolType: "claude-chat", }); expect(dot.spinning).toBe(false); - expect(dot.cls).toContain("amber"); - expect(dot.label).toBe("Ready"); + expect(dot.cls).toContain("emerald"); + expect(dot.label).toBe("Done"); }); }); }); diff --git a/apps/desktop/src/renderer/lib/terminalAttention.ts b/apps/desktop/src/renderer/lib/terminalAttention.ts index 8f16dd655..a440d5714 100644 --- a/apps/desktop/src/renderer/lib/terminalAttention.ts +++ b/apps/desktop/src/renderer/lib/terminalAttention.ts @@ -6,6 +6,13 @@ import { type CanonicalSessionState, type SessionBadge, } from "../../shared/sessionCanonicalState"; +import { + sessionStatusPresentation, + SESSION_TONE_DOT_CLASS, + type SessionStatusOverlay, + type SessionStatusPresentation, +} from "../../shared/sessionStatusPresentation"; +import type { CanonicalSessionPhase } from "../../shared/sessionCanonicalState"; import { isChatToolType } from "./sessions"; export type TerminalRunIndicatorState = "none" | "running-active" | "running-needs-attention"; @@ -72,6 +79,25 @@ function normalizeInlineWhitespace(raw: string): string { return raw.replace(/\t/g, " ").replace(/\s+/g, " ").trim(); } +/** + * Rule characters repeated as a separator — `----`, `====`, `━━━━`, a markdown + * `---`, a banner of `####`. Agents and CLIs emit these constantly. + * + * Verbatim, a run like this fills a sidebar row edge to edge and renders as a + * horizontal line struck through the card, which reads as a broken layout + * rather than as text. Collapsing each run to a single character keeps the + * preview honest (the separator WAS there) without letting it draw furniture. + * + * Deliberately not a blanket "collapse any repeated character": real output + * legitimately contains `...`, `!!!`, and `???`, and flattening those would + * change the tone of a message rather than just its geometry. + */ +const RULE_RUN_REGEX = /([-=_~*#—–─-╿])\1{3,}/g; + +function collapseRuleRuns(raw: string): string { + return raw.replace(RULE_RUN_REGEX, "$1"); +} + export function sanitizeTerminalInlineText(raw: string | null | undefined, maxChars = 220): string { if (!raw) return ""; const stripped = raw @@ -83,7 +109,7 @@ export function sanitizeTerminalInlineText(raw: string | null | undefined, maxCh .replace(CHARSET_REGEX, "") .replace(TWO_CHAR_ESC_REGEX, "") .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, ""); - const normalized = normalizeInlineWhitespace(stripped); + const normalized = normalizeInlineWhitespace(collapseRuleRuns(stripped)); if (normalized.length <= maxChars) return normalized; return `${normalized.slice(0, Math.max(0, maxChars - 1)).trimEnd()}…`; } @@ -173,6 +199,23 @@ export function sessionInlineStatusLabel(session: SessionCanonicalUiInput): stri return null; } +/** + * The row's single status label — `{ label, tone, glyph }` from the shared + * presentation module, with the snooze/woke overlays already applied. + * + * This is what the sidebar's status slot renders, and it replaces the old + * scatter of capsule + inline label + dot + wake chip + woke chip, each of + * which decided its own copy and color. Callers pass the overlay explicitly + * because snooze lives outside the canonical phase by design — see + * `sessionStatusPresentation`'s header for why the two stay orthogonal. + */ +export function sessionStatusDisplay( + session: SessionCanonicalUiInput, + overlay: SessionStatusOverlay = {}, +): SessionStatusPresentation | null { + return sessionStatusPresentation(sessionCanonicalUiState(session).phase, overlay); +} + /** Yellow Work tab border — agent chats blocked on approval/question/`ade chat ask` only. */ export function sessionNeedsChatTabHighlight(args: { runtimeState?: TerminalRuntimeState; @@ -224,43 +267,63 @@ export type SessionStatusDot = { }; /** - * Map a session's canonical phase to CSS classes for rendering a status dot. - * Green = work happening · amber = your move (loud or quiet) · red = died · - * hollow ring = settled (visually "less than" every filled dot, matching the - * quietest tier). + * The compact status dot, for surfaces too dense for a word (the `compact` row + * variant, lane rollups). + * + * Derived from `sessionStatusPresentation` rather than switching on the phase + * itself, so the dot and the label physically cannot disagree. They used to: + * a stale session rendered a GREEN dot labelled "Running" while a separate + * capsule on the same row said "Stale" — the dot was asserting work was + * happening at the exact moment the row was explaining that none was. Anything + * that needs a color for a session state must come through here or through the + * presentation module; no third mapping. + * + * Settled keeps its hollow ring, which reads as visually "less than" every + * filled dot and matches its position as the quietest tier. */ -export function sessionStatusDot(session: SessionCanonicalUiInput): SessionStatusDot { +export function sessionStatusDot( + session: SessionCanonicalUiInput, + overlay: SessionStatusOverlay = {}, +): SessionStatusDot { const phase = sessionCanonicalUiState(session).phase; - switch (phase) { - case "starting": - case "running": - case "stale": - return { cls: "rounded-full bg-emerald-400", spinning: false, label: "Running" }; - case "needs_you": - return { - cls: "rounded-full bg-amber-300", - spinning: false, - label: "Needs you", - }; - case "ready": - return { cls: "rounded-full bg-amber-300", spinning: false, label: "Ready" }; - case "idle": - return idleRuntimeNeedsAttention(session.toolType) - ? { cls: "rounded-full bg-amber-300", spinning: false, label: "Idle" } - : { cls: "rounded-full bg-emerald-400", spinning: false, label: "Running" }; - case "settled": - return { - cls: "rounded-full border border-white/35 bg-transparent", - spinning: false, - label: "Settled", - }; - case "stopped": - return { cls: "rounded-full bg-red-400", spinning: false, label: "Stopped" }; - case "failed": - return { cls: "rounded-full bg-red-400", spinning: false, label: "Failed" }; - default: - return { cls: "rounded-full bg-red-400", spinning: false, label: "Ended" }; + if (phase === "settled") { + return { + cls: "rounded-full border border-white/35 bg-transparent", + spinning: false, + label: "Settled", + }; + } + const presentation = sessionStatusPresentation(phase, overlay); + if (presentation) { + return { + cls: `rounded-full ${SESSION_TONE_DOT_CLASS[presentation.tone]}`, + spinning: false, + label: presentation.label, + }; } + return legacySessionStatusDot(phase); +} + +/** + * Fallback for a phase with no presentation entry. + * + * Currently unreachable — `PHASE_PRESENTATION` is a `Record` over the full + * `CanonicalSessionPhase` union, so TypeScript already guarantees every phase + * resolves (only `settled` returns null, handled above). It exists so a phase + * added to the union later degrades to a visible dot instead of rendering + * nothing. + * + * Deliberately NEUTRAL rather than reproducing the old per-phase colours: an + * unknown state has, by definition, not earned a hue. The previous version of + * this fallback still mapped `ready`/`idle` to amber, which would have quietly + * reintroduced the exact second meaning for amber that this redesign removed. + */ +function legacySessionStatusDot(phase: CanonicalSessionPhase): SessionStatusDot { + return { + cls: `rounded-full ${SESSION_TONE_DOT_CLASS.neutral}`, + spinning: false, + label: phase, + }; } /** diff --git a/apps/desktop/src/renderer/webclient/shell/sessionLifecycleChrome.ts b/apps/desktop/src/renderer/webclient/shell/sessionLifecycleChrome.ts index ad99ab4da..a1a4bbfc9 100644 --- a/apps/desktop/src/renderer/webclient/shell/sessionLifecycleChrome.ts +++ b/apps/desktop/src/renderer/webclient/shell/sessionLifecycleChrome.ts @@ -24,8 +24,9 @@ export type SessionLifecycleChromeState = "ready" | "unsupported"; /** * Touch targets are 2rem (32px) rather than the desktop control's 20px, and the - * duration menu rows get a 44px minimum so the four snooze choices are - * separately tappable. + * duration menu rows get a 44px minimum so the snooze choices are separately + * tappable (the row count varies with the time of day — see + * `resolveSnoozePresets`). */ const STYLES = ` @media (pointer: coarse) { diff --git a/apps/desktop/src/shared/adeCliGuidance.test.ts b/apps/desktop/src/shared/adeCliGuidance.test.ts index 5ae342d29..7f4070be6 100644 --- a/apps/desktop/src/shared/adeCliGuidance.test.ts +++ b/apps/desktop/src/shared/adeCliGuidance.test.ts @@ -33,15 +33,12 @@ describe("ADE bootstrap guidance", () => { expect(bootstrap).toContain("tracked provider CLIs"); expect(bootstrap).toContain('ade chat note "running e2e shard 2/4"'); expect(bootstrap).toContain('ade chat ask ""'); - expect(bootstrap).toContain("ade chat settle --outcome"); - expect(bootstrap).toContain("the entire session is terminal"); - expect(bootstrap).toContain("Settle only when ALL are true"); - expect(bootstrap).toContain("Do NOT settle during discussion"); - expect(bootstrap).toContain("“let’s discuss”"); - expect(bootstrap).toContain("“for now”"); - expect(bootstrap).toContain("“don’t change yet”"); - expect(bootstrap).toContain("The runtime rejects settlement while structured work remains"); - expect(bootstrap).toContain("If uncertain, do not settle"); + // Settlement is user- and PR-merge-driven only: the bootstrap must tell the + // agent it CANNOT settle, and must never hand it a settle command again. + expect(bootstrap).toContain("You cannot settle or unsettle a session"); + expect(bootstrap).toContain("the user's call, or the automatic result of its PR merging"); + expect(bootstrap).not.toContain("ade chat settle --outcome"); + expect(bootstrap).toContain("ade session snooze --for "); // The skill index is still advertised so the model knows what exists. for (const skillName of adeBundledAgentSkills) { expect(bootstrap).toContain(`\`${skillName}\``); diff --git a/apps/desktop/src/shared/adeCliGuidance.ts b/apps/desktop/src/shared/adeCliGuidance.ts index 0d977142e..bf4c06683 100644 --- a/apps/desktop/src/shared/adeCliGuidance.ts +++ b/apps/desktop/src/shared/adeCliGuidance.ts @@ -14,15 +14,21 @@ export const adeBundledAgentSkills = [ "ade-search", ] as const; +/** + * The status protocol agents actually get. Note what is NOT here: settling. + * `ade chat settle` was removed in 2026-07 — deciding that work is finished is + * a subjective call agents are unreliable at, and a self-settling chat drops + * out of the user's active list on the agent's say-so. Rows leave the active + * list when the user settles them or when their PR merges. Keep this guidance + * pointed at note/ask; do not re-add settle instructions. + */ export const ADE_SESSION_STATUS_PROTOCOL_GUIDANCE = [ "ADE control protocol for truthful Work status:", '- Working: `ade chat note "running e2e shard 2/4"`; keep it current.', '- Blocked on user input without a structured ask: `ade chat ask ""`.', - "- Settlement means the entire session is terminal, not merely the current turn or CLI process.", - "- Settle only when ALL are true: delivered and verified; no work, check, approval, question, subagent, job, schedule, goal, plan item, or follow-up remains; the next reply would be new scope.", - "- Do NOT settle during discussion, planning, review, monitoring, or waiting. “let’s discuss”, “for now”, “don’t change yet”, open questions, and feedback invitations are non-terminal.", - '- Only then run `ade chat settle --outcome ""`. The runtime rejects settlement while structured work remains.', - "- If uncertain, do not settle; update `ade chat note` and leave it in Your move.", + '- Done: say so in your final message and leave a durable one-line result via `ade chat note ""`.', + "- You cannot settle or unsettle a session; `ade chat settle` / `ade session settle` no longer exist. Filing a row as done is the user's call, or the automatic result of its PR merging.", + "- Waiting on something you expect to take a while? `ade session snooze --for ` quiets the row without claiming the work is done, and a hand-raise wakes it early.", ].join("\n"); /** diff --git a/apps/desktop/src/shared/laneColorPalette.ts b/apps/desktop/src/shared/laneColorPalette.ts index afeaf84b3..466ffea5c 100644 --- a/apps/desktop/src/shared/laneColorPalette.ts +++ b/apps/desktop/src/shared/laneColorPalette.ts @@ -35,7 +35,28 @@ export const LANE_COLOR_PALETTE: readonly LaneColor[] = [ export const LANE_CLASSIC_COUNT = LANE_CLASSIC_COLORS.length; -export const LANE_FALLBACK_COLORS: readonly string[] = LANE_COLOR_PALETTE +/** + * ADE purple — `--color-accent` in `index.css`, and `LANE_CLASSIC_COLORS[0]` + * ("Violet"). RESERVED for the Primary lane, which exists on every ADE machine + * and is the one lane whose colour is worth learning by heart. Reserving it is + * what makes that possible: no auto-assigned lane may claim it (see + * `ALLOCATABLE_LANE_COLORS`), so purple in the sidebar always means Primary. + * + * It stays in `LANE_COLOR_PALETTE` on purpose — the manual picker may still + * offer it, and lanes that already hold it are left alone. + */ +export const PRIMARY_LANE_COLOR = "#a78bfa"; + +/** + * The pool auto-allocation draws from: every palette entry except the reserved + * Primary purple. + */ +export const ALLOCATABLE_LANE_COLORS: readonly LaneColor[] = LANE_COLOR_PALETTE + .filter((entry) => entry.hex.toLowerCase() !== PRIMARY_LANE_COLOR); + +// Fallback accents are auto-assigned too (a lane with no stored colour picks one +// by index), so they come off the allocatable pool rather than the full palette. +export const LANE_FALLBACK_COLORS: readonly string[] = ALLOCATABLE_LANE_COLORS .slice(0, 8) .map((entry) => entry.hex); @@ -45,21 +66,35 @@ export function nextAvailableLaneColor(usedColors: Iterable): string | n .map((color) => color.trim().toLowerCase()) .filter((color) => color.length > 0), ); - for (const entry of LANE_COLOR_PALETTE) { + for (const entry of ALLOCATABLE_LANE_COLORS) { if (!used.has(entry.hex.toLowerCase())) return entry.hex; } return null; } export function randomLaneColor(): string { - const index = Math.floor(Math.random() * LANE_COLOR_PALETTE.length); - return LANE_COLOR_PALETTE[index]?.hex ?? LANE_COLOR_PALETTE[0]!.hex; + const index = Math.floor(Math.random() * ALLOCATABLE_LANE_COLORS.length); + return ALLOCATABLE_LANE_COLORS[index]?.hex ?? ALLOCATABLE_LANE_COLORS[0]!.hex; } export function allocateLaneColor(usedColors: Iterable): string { return nextAvailableLaneColor(usedColors) ?? randomLaneColor(); } +/** + * The accent a lane renders with. Primary is LOCKED to ADE purple regardless of + * what its row stores: primaries created before the colour was reserved carry a + * null colour, and the whole point of the reservation is that Primary looks the + * same on every machine and in every project. + */ +export function resolveLaneAccentColor( + lane: { laneType?: string | null; color?: string | null } | null | undefined, +): string | null { + if (!lane) return null; + if (lane.laneType === "primary") return PRIMARY_LANE_COLOR; + return lane.color ?? null; +} + export function laneColorName(hex: string | null | undefined): string | null { if (!hex) return null; const lower = hex.toLowerCase(); diff --git a/apps/desktop/src/shared/sessionCanonicalState.ts b/apps/desktop/src/shared/sessionCanonicalState.ts index 17b4b73ce..a29a78aa0 100644 --- a/apps/desktop/src/shared/sessionCanonicalState.ts +++ b/apps/desktop/src/shared/sessionCanonicalState.ts @@ -71,9 +71,11 @@ export type CanonicalSessionInputs = { lastActivityAt?: string | null; exitCode?: number | null; /** - * Declared settle (agent `ade chat settle` or user action). Presence alone - * settles — new activity clears the column at the write site (turn start / - * PTY output), so no timestamp comparison happens here. + * Declared settle. Only two writers exist: the user (desktop row/bulk + * actions, `ade code`) and the deterministic PR-merge policy — agents lost + * `ade chat settle` in 2026-07 because "is this done" is not their call. + * Presence alone settles — new activity clears the column at the write site + * (turn start / PTY output), so no timestamp comparison happens here. */ settledAt?: string | null; /** Explicit lifecycle override. Cleared with `settledAt` on real activity. */ @@ -103,10 +105,10 @@ function isSilentPast(lastActivityAt: string | null | undefined, nowMs: number, * Canonical precedence (highest first): * 1. explicit/structured needs-input — pendingInputItemId or an * `ade chat ask` escalation (never outvoted by anything below), - * 2. settled — explicitly declared (agent/user) or forced by a "settled" - * override; presence wins over failure because a declared quiet is a - * human/agent judgment call. An "active" override suppresses this tier - * entirely. Cleared at the write site on any new activity, + * 2. settled — explicitly declared (user, or the PR-merge policy) or forced + * by a "settled" override; presence wins over failure because a declared + * quiet is a human judgment call. An "active" override suppresses this + * tier entirely. Cleared at the write site on any new activity, * 3. stopped — user/system-disposed PTY, * 4. failed — non-zero exit / killed / chat turn death, * 5. stale — status running but silent ≥ SESSION_STALE_AFTER_MS, @@ -176,8 +178,9 @@ export function canonicalSessionState(args: CanonicalSessionInputs): CanonicalSe return { phase: "ready", badge: null }; } // A clean process exit only says the CLI ended. Settlement is a lifecycle - // declaration made by the agent/user (or the lane PR-merge policy), never - // inferred from process mechanics. + // declaration made by the user (or the lane PR-merge policy), never + // inferred from process mechanics. There is no "derived clean-exit settle" + // anywhere in ADE — if you find a comment claiming otherwise, it is stale. return { phase: "ended", badge: null }; } diff --git a/apps/desktop/src/shared/sessionStatusPresentation.ts b/apps/desktop/src/shared/sessionStatusPresentation.ts new file mode 100644 index 000000000..d37e85c5a --- /dev/null +++ b/apps/desktop/src/shared/sessionStatusPresentation.ts @@ -0,0 +1,197 @@ +import type { CanonicalSessionPhase } from "./sessionCanonicalState"; + +/** + * The ONE presentation vocabulary for "how does a session's state look and + * read", shared by the Work sidebar, the attention center, the header rollup, + * desktop notifications, and — by documented mirror — the iOS widgets and Live + * Activity (`AgentRunPhase` in `apps/ios/ADE/Shared/ADEAgentActivityAttributes.swift`). + * + * `sessionCanonicalState.ts` owns WHAT state a session is in. This module owns + * WHAT THAT LOOKS LIKE. The split matters: the phase vocabulary is load-bearing + * for filing, filtering, and the push wire format, so presentation changes must + * never require touching it. + * + * ── The one-hue-one-meaning rule ──────────────────────────────────────────── + * + * Before this module, amber carried five unrelated meanings on a single row: + * `needs_you`, `ready`/`idle`, the stale-CLI warning, stopped-by-signal, and + * the cross-machine marker. A user cannot learn a color that means five things, + * so amber meant nothing and every row had to be read word by word. + * + * The rule now, and the reason each hue is spent where it is: + * + * blue work is happening, nothing is asked of you + * amber YOUR MOVE — and nothing else, ever + * emerald finished cleanly, you have not looked yet + * red it broke + * neutral true, but not actionable (stale, stopped, ended, snoozed) + * + * Two consequences worth stating, because both look like bugs otherwise: + * + * • `stopped` is neutral, not red. A SIGINT/SIGTERM exit (130/143) is the + * user pressing stop — spending the alarm hue on an outcome the user chose + * is what trained people to ignore red. Red now means only "it broke". + * + * • `ready`/`idle` are emerald "Done", not amber. They previously shared + * amber with `needs_you`, which made "finished, go look" and "blocked, + * go act" indistinguishable at a glance — the single worst confusion in + * the old row. + * + * The cross-machine marker keeps its amber tower glyph: it is a 10px monochrome + * identity mark inside a neutral chip, not a status label, and it never renders + * in the status slot this module owns. The two cannot collide. + */ +export type SessionStatusTone = "blue" | "amber" | "emerald" | "red" | "neutral"; + +/** + * Glyph identity, not an icon import. Each consumer maps these to its own icon + * set — Phosphor on desktop, SF Symbols on iOS — so this module stays free of + * renderer dependencies and can be imported by main-process code. + */ +export type SessionStatusGlyph = + | "working" + | "needs-you" + | "done" + | "stale" + | "failed" + | "woke" + | "snoozed" + | null; + +export type SessionStatusPresentation = { + /** Sentence-case label. Empty-string is never returned; use `null` presentation instead. */ + label: string; + tone: SessionStatusTone; + glyph: SessionStatusGlyph; + /** + * Whether the label should be followed by a live-ticking elapsed duration + * ("Working 14s"). Only true for phases where elapsed time is the useful + * fact — a failed run's age is noise. + */ + showsElapsed: boolean; + /** + * Whether this state should pull the eye. Drives the sidebar's recede rule: + * non-prominent rows fade to 70% so the few rows that want a human stand out. + * Working is deliberately NOT prominent — an agent mid-turn is not yet your + * problem (inbox-zero: prominence is a request for attention, not a progress + * report). + */ + prominent: boolean; +}; + +/** + * Settled rows carry no status label at all — they live in the collapsed + * settled tail, where the section itself is the status. Returning `null` rather + * than a "Settled" presentation keeps the row's status slot free for its + * timestamp, which is the only thing worth reading in the tail. + */ +const PHASE_PRESENTATION: Record = { + starting: { label: "Starting", tone: "blue", glyph: "working", showsElapsed: false, prominent: false }, + running: { label: "Working", tone: "blue", glyph: "working", showsElapsed: true, prominent: false }, + needs_you: { label: "Needs you", tone: "amber", glyph: "needs-you", showsElapsed: false, prominent: true }, + ready: { label: "Done", tone: "emerald", glyph: "done", showsElapsed: false, prominent: true }, + idle: { label: "Done", tone: "emerald", glyph: "done", showsElapsed: false, prominent: true }, + // Running but silent past the threshold. Neutral, not blue: the process is + // technically alive, but reporting it as work-in-progress is a lie the old + // green dot told for hours at a time. + // + // Elapsed is shown because "how long has it been silent" IS the question a + // stale row raises — bare "Stale" tells you to investigate without telling + // you how urgently. "Stale 4h" answers both at once. + stale: { label: "Stale", tone: "neutral", glyph: "stale", showsElapsed: true, prominent: false }, + failed: { label: "Failed", tone: "red", glyph: "failed", showsElapsed: false, prominent: true }, + stopped: { label: "Stopped", tone: "neutral", glyph: null, showsElapsed: false, prominent: false }, + ended: { label: "Ended", tone: "neutral", glyph: null, showsElapsed: false, prominent: false }, + settled: null, +}; + +/** + * Snooze and woke are VISIBILITY OVERLAYS, not phases — `canonicalSessionState` + * deliberately never reads their columns. They are resolved here, above the + * phase, because they are what the status slot should say when both are true: + * a snoozed row's whole story is when it comes back, and a woken row's is that + * it came back early and why. + * + * Precedence matches the filing rule in `isSessionFiledAsSnoozed`: a snooze + * yields to `needs_you`, so a raised hand is never buried by a ~100-year + * "until I'm asked" window. + */ +export type SessionStatusOverlay = { + /** Currently under an unexpired snooze (see `isSessionSnoozed`). */ + snoozed?: boolean; + /** Woke early and the user has not opened the row yet (see `sessionWokeMarker`). */ + woke?: boolean; + /** Compact "in 2h" copy for a snoozed row; omitted renders the bare glyph. */ + snoozeWakeLabel?: string | null; +}; + +export function sessionStatusPresentation( + phase: CanonicalSessionPhase, + overlay: SessionStatusOverlay = {}, +): SessionStatusPresentation | null { + // A raised hand outranks both overlays — same precedence as the filing rule, + // so where the row is filed and what it says can never disagree. + if (phase === "needs_you") return PHASE_PRESENTATION.needs_you; + + if (overlay.snoozed) { + return { + // The return ticket IS the status. Falling back to the bare word keeps + // the slot meaningful when the caller has no formatted label to hand. + label: overlay.snoozeWakeLabel?.trim() || "Snoozed", + tone: "neutral", + glyph: "snoozed", + showsElapsed: false, + prominent: false, + }; + } + + if (overlay.woke) { + return { label: "Woke", tone: "amber", glyph: "woke", showsElapsed: false, prominent: true }; + } + + return PHASE_PRESENTATION[phase]; +} + +/** + * Tailwind classes per tone, for the desktop renderer. Kept beside the tone + * definition rather than in the card so the attention center, the header + * rollup, and the sidebar cannot drift into three different ambers. + */ +export const SESSION_TONE_TEXT_CLASS: Record = { + blue: "text-sky-400", + amber: "text-amber-300", + emerald: "text-emerald-300", + red: "text-red-300", + neutral: "text-muted-fg/60", +}; + +/** + * Dot fills for the compact status dot (sidebar rows at `compact`, the lane + * rollup, dense list surfaces). `settled` has no entry — it renders a hollow + * ring, which reads as visually "less than" every filled dot and matches its + * position as the quietest tier. + */ +export const SESSION_TONE_DOT_CLASS: Record = { + blue: "bg-sky-400", + amber: "bg-amber-300", + emerald: "bg-emerald-400", + red: "bg-red-400", + neutral: "bg-white/30", +}; + +/** + * Compact elapsed copy for the "Working 14s" ticker: seconds under a minute, + * then minutes, then hours. Deliberately lossy above the hour — a turn that has + * run for 3h14m is reported as "3h", because past that point the exact figure + * stops changing any decision and the ticking digits are pure noise. + */ +export function formatWorkingDuration(elapsedMs: number): string { + if (!Number.isFinite(elapsedMs) || elapsedMs < 0) return ""; + const totalSeconds = Math.floor(elapsedMs / 1000); + if (totalSeconds < 60) return `${totalSeconds}s`; + const totalMinutes = Math.floor(totalSeconds / 60); + if (totalMinutes < 60) return `${totalMinutes}m`; + const totalHours = Math.floor(totalMinutes / 60); + if (totalHours < 24) return `${totalHours}h`; + return `${Math.floor(totalHours / 24)}d`; +} diff --git a/apps/ios/ADE/Shared/ADEAgentActivityAttributes.swift b/apps/ios/ADE/Shared/ADEAgentActivityAttributes.swift index 836bf285a..0f2fdead1 100644 --- a/apps/ios/ADE/Shared/ADEAgentActivityAttributes.swift +++ b/apps/ios/ADE/Shared/ADEAgentActivityAttributes.swift @@ -368,6 +368,27 @@ public enum PullRequestPhase: String, CaseIterable, Sendable { /// Phase of a single agent run, with the color / symbol / label mapping the /// widget and Dynamic Island render. Colors reuse `ADESharedTheme` so the Live /// Activity stays in lockstep with the lock-screen widget's palette. +/// +/// This is the iOS mirror of the desktop's one presentation vocabulary +/// (`apps/desktop/src/shared/sessionStatusPresentation.ts`). The hue rule is +/// the whole point of that module and must hold here too, because a user who +/// learns a color on the sidebar reads the same color on their Lock Screen: +/// +/// blue work is happening, nothing is asked of you +/// amber YOUR MOVE — and nothing else, ever +/// emerald finished cleanly, you have not looked yet +/// red it broke +/// neutral true, but not actionable (stale) +/// +/// Amber previously carried five unrelated meanings across ADE, so it meant +/// nothing. Anything that is merely *interesting* — a stale run, a sync in +/// flight, an offline host — is neutral. Spending amber on it is what trains +/// people to stop looking at the one color that should always mean "act". +/// +/// The raw values are the push / Live Activity wire format (see +/// `apps/desktop/src/shared/sessionCanonicalState.ts`). Presentation here is +/// free to change; the slugs are not — desktop versions already in the field +/// send them. public enum AgentRunPhase: String, CaseIterable, Sendable { case starting case running @@ -383,17 +404,30 @@ public enum AgentRunPhase: String, CaseIterable, Sendable { self == .waitingForApproval || self == .waitingForInput } + /// Whether the state should pull the eye. Mirrors `prominent` in the + /// desktop's `sessionStatusPresentation`: an outcome (done, failed) or a + /// raised hand earns colour and weight; work in flight does not, because + /// prominence is a request for attention, not a progress report. + public var isProminent: Bool { + needsAttention || self == .completed || self == .failed + } + public var isTerminal: Bool { self == .completed || self == .failed } public var tint: Color { switch self { + // `statusRunning` is blue (#60A5FA), not green: green/emerald is spent + // on `.completed` alone, so in-flight work and finished work can never + // be confused at a glance. case .starting, .running: return ADESharedTheme.statusRunning case .waitingForApproval: return ADESharedTheme.warningAmber case .waitingForInput: return ADESharedTheme.warningAmber case .completed: return ADESharedTheme.statusSuccess case .failed: return ADESharedTheme.statusFailed + // Neutral, not amber and not blue. A stale run is alive but silent — + // true, and worth reporting, but it is not asking you for anything. case .stale: return ADESharedTheme.statusIdle } } @@ -402,21 +436,29 @@ public enum AgentRunPhase: String, CaseIterable, Sendable { switch self { case .starting: return "hourglass" case .running: return "circle.dotted" + // iOS splits the desktop's single "needs-you" glyph in two: the + // Lock Screen can offer Approve/Deny inline, so it is worth saying up + // front which kind of answer is wanted. case .waitingForApproval: return "bell.badge.fill" case .waitingForInput: return "keyboard.badge.ellipsis" case .completed: return "checkmark.circle.fill" case .failed: return "xmark.octagon.fill" - case .stale: return "wifi.slash" + // A clock, not `wifi.slash`. Stale means the process is alive and has + // produced no output for hours — a silence problem, not a network one, + // and the old glyph sent people to check their connection. + case .stale: return "clock.badge.exclamationmark" } } - /// Short trailing phase label. Sentence case, matching ADE copy norms. + /// Short trailing phase label. Sentence case, matching ADE copy norms and + /// the desktop vocabulary word-for-word ("Working", not "Running"; "Done", + /// not "Completed"). public var label: String { switch self { case .starting: return "Starting" - case .running: return "Running" + case .running: return "Working" case .waitingForApproval, .waitingForInput: return "Needs you" - case .completed: return "Completed" + case .completed: return "Done" case .failed: return "Failed" case .stale: return "Stale" } diff --git a/apps/ios/ADE/Shared/ADESharedModels.swift b/apps/ios/ADE/Shared/ADESharedModels.swift index 6d938bacc..d06ee95c8 100644 --- a/apps/ios/ADE/Shared/ADESharedModels.swift +++ b/apps/ios/ADE/Shared/ADESharedModels.swift @@ -274,14 +274,17 @@ public enum AccountAttentionPhase: String, Codable, Hashable, Sendable { case merged case closed + /// Row copy for the Attention Drawer. Same words as `AgentRunPhase.label` + /// and the desktop sidebar — "Working", not "Running"; "Done", not + /// "Completed" — so one device never describes one session two ways. public var displayLabel: String { switch self { case .starting: return "Starting" - case .running: return "Running" + case .running: return "Working" case .needsYou: return "Needs you" case .blocked: return "Blocked" case .failed: return "Failed" - case .completed: return "Completed" + case .completed: return "Done" case .stale: return "Stale" case .checksFailing: return "Checks failing" case .reviewRequested: return "Review requested" diff --git a/apps/ios/ADE/Shared/ADESharedTheme.swift b/apps/ios/ADE/Shared/ADESharedTheme.swift index 0b21486ca..2eadd1063 100644 --- a/apps/ios/ADE/Shared/ADESharedTheme.swift +++ b/apps/ios/ADE/Shared/ADESharedTheme.swift @@ -101,22 +101,38 @@ public enum ADESharedTheme { // MARK: - Semantic status colors - /// Attention phase colors mirror the desktop attention center so the same - /// state reads identically on every ADE surface. + // Attention phase colors mirror the desktop attention center so the same + // state reads identically on every ADE surface. One hue, one meaning — + // see `AgentRunPhase` and `sessionStatusPresentation.ts` for why: + // + // statusRunning blue work is happening, nothing is asked of you + // warningAmber amber YOUR MOVE — and nothing else, ever + // statusSuccess emerald finished cleanly, unseen + // statusFailed red it broke + // statusIdle neutral true, but not actionable + + /// Blue (#60A5FA), deliberately not a green — emerald belongs to "done" + /// alone, so in-flight and finished work never share a colour. public static let statusRunning = Color(red: 0x60 / 255.0, green: 0xA5 / 255.0, blue: 0xFA / 255.0) // #60A5FA public static let statusFailed = Color(red: 0xF8 / 255.0, green: 0x71 / 255.0, blue: 0x71 / 255.0) // #F87171 public static let statusReview = Color(red: 0xA7 / 255.0, green: 0x8B / 255.0, blue: 0xFA / 255.0) // #A78BFA public static let statusSuccess = Color(red: 0x34 / 255.0, green: 0xD3 / 255.0, blue: 0x99 / 255.0) // #34D399 + /// Reserved for states that are literally waiting on the user. If you are + /// reaching for this to say "in progress", "syncing", "stale" or "offline", + /// reach for `statusIdle` instead — amber that means five things means none. public static let warningAmber = Color(red: 0xFB / 255.0, green: 0xBF / 255.0, blue: 0x24 / 255.0) // #FBBF24 + /// Alias for the same "your move" amber, read from attention surfaces. public static let statusAttention = warningAmber - /// Neutral gray used for idle / pending. + /// Neutral gray for everything that is true but not actionable — idle, + /// pending, stale, syncing, offline. public static let statusIdle = Color(red: 0x71 / 255.0, green: 0x71 / 255.0, blue: 0x7A / 255.0) - /// Connection-dot color mapping. + /// Connection-dot color mapping. Syncing is neutral rather than amber: a + /// transport catching up asks nothing of the user. public static func connectionColor(for status: String) -> Color { switch status.lowercased() { case "connected": return statusSuccess - case "syncing": return statusAttention + case "syncing": return statusIdle default: return statusFailed } } diff --git a/apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerModel.swift b/apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerModel.swift index 1643d471f..c146fa8f5 100644 --- a/apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerModel.swift +++ b/apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerModel.swift @@ -2,9 +2,17 @@ import Combine import Foundation import SwiftUI +/// Presentation bucket for a drawer row — what glyph and hue it wears. +/// +/// `awaitingInput` is the only amber kind, and that is deliberate: amber means +/// "your move" and nothing else (see `AgentRunPhase` and the desktop's +/// `sessionStatusPresentation`). `blocked` is a separate, neutral kind for +/// exactly that reason — it used to share `awaitingInput`, which put a raised +/// hand and an unmet dependency under one bell and one colour. @available(iOS 17.0, *) public enum AttentionKind: String, Codable, Hashable, Sendable { case awaitingInput + case blocked case failed case ciFailing case reviewRequested @@ -119,6 +127,7 @@ public struct AttentionItem: Identifiable, Equatable { private static func defaultPhaseLabel(for kind: AttentionKind) -> String { switch kind { case .awaitingInput: return "Needs you" + case .blocked: return "Blocked" case .failed: return "Failed" case .ciFailing: return "Checks failing" case .reviewRequested: return "Review" @@ -127,7 +136,10 @@ public struct AttentionItem: Identifiable, Equatable { case .open: return "Open" case .completed: return "Done" case .merged: return "Merged" - case .stale: return "Offline" + // "Stale", not "Offline": the run is reachable and silent. Calling it + // offline points at the network, which is the one thing that is not + // wrong here. + case .stale: return "Stale" } } } @@ -541,10 +553,13 @@ public final class AttentionDrawerModel: ObservableObject { case .reviewRequested: return 3 case .mergeReady: return 4 case .running: return 5 - case .stale: return 6 - case .open: return 7 - case .completed: return 7 - case .merged: return 7 + // Blocked sorts below live work and above a silent one: it is not + // asking for anything, but it has not gone quiet either. + case .blocked: return 6 + case .stale: return 7 + case .open: return 8 + case .completed: return 8 + case .merged: return 8 } } @@ -570,7 +585,7 @@ public final class AttentionDrawerModel: ObservableObject { guard let phase = nonEmpty(phase)?.lowercased() else { return nil } switch phase { case "starting": return "Starting" - case "running": return "Running" + case "running": return "Working" case "planning", "plan": return "Planning" case "development", "developing", "implementation", "implementing": return "Building" case "testing", "test": return "Testing" @@ -580,7 +595,7 @@ public final class AttentionDrawerModel: ObservableObject { case "waiting_for_approval", "needs_approval": return "Needs approval" case "waiting_for_input", "awaiting_input", "needs_you": return "Needs reply" case "blocked": return "Blocked" - case "completed", "done": return "Completed" + case "completed", "done": return "Done" case "failed", "error": return "Failed" case "stale": return "Stale" default: return nil @@ -618,9 +633,15 @@ public final class AttentionDrawerModel: ObservableObject { let kind: AttentionKind let collection: AttentionCollection switch source.phase { - case .needsYou, .blocked: + case .needsYou: kind = .awaitingInput - collection = source.phase == .blocked ? .live : .needsYou + collection = .needsYou + // Blocked already filed itself under `live` rather than `needsYou` — + // the drawer never thought it was the user's move. It now looks the + // part too, instead of borrowing the amber bell from a raised hand. + case .blocked: + kind = .blocked + collection = .live case .failed: kind = .failed collection = .needsYou diff --git a/apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerSheet.swift b/apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerSheet.swift index dbd639ba5..3d5570eeb 100644 --- a/apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerSheet.swift +++ b/apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerSheet.swift @@ -706,7 +706,10 @@ private struct AttentionDrawerActionRow: View { } .buttonStyle(.plain) - case .running, .open, .completed, .merged, .stale: + // Nothing to approve, rerun, or restart on any of these — a blocked row + // included, which is the point: it is waiting on something that is not + // a button in this drawer. + case .running, .blocked, .open, .completed, .merged, .stale: Button(action: open) { AttentionDrawerActionLabel("Open", systemImage: "arrow.right", variant: .secondary) } @@ -856,18 +859,26 @@ private enum AttentionIcon { static func symbol(for kind: AttentionKind) -> String { switch kind { case .awaitingInput: return "bell.badge.fill" + case .blocked: return "hourglass" case .failed: return "xmark.octagon.fill" case .ciFailing: return "exclamationmark.triangle.fill" case .reviewRequested: return "eye.fill" case .mergeReady: return "checkmark.seal.fill" - case .running: return "waveform.path.ecg" + // The dashed circle the widgets and the desktop sidebar use for work + // in flight, rather than a heartbeat trace only this surface knew. + case .running: return "circle.dotted" case .open: return "arrow.triangle.pull" case .completed: return "checkmark.circle.fill" case .merged: return "arrow.triangle.merge" - case .stale: return "wifi.slash" + // A clock, not `wifi.slash`: a stale run is reachable and silent, so + // the question is how long it has been quiet, not whether the network + // dropped. + case .stale: return "clock.badge.exclamationmark" } } + /// Amber lives on exactly one kind here — `awaitingInput` — and everything + /// that merely reports a fact takes a hue that makes no claim on the user. static func tint(for kind: AttentionKind) -> Color { switch kind { case .awaitingInput: return ADESharedTheme.warningAmber @@ -875,7 +886,7 @@ private enum AttentionIcon { case .reviewRequested: return ADESharedTheme.statusReview case .mergeReady, .completed, .merged: return ADESharedTheme.statusSuccess case .running, .open: return ADESharedTheme.statusRunning - case .stale: return ADESharedTheme.statusIdle + case .blocked, .stale: return ADESharedTheme.statusIdle } } } diff --git a/apps/ios/ADE/Views/Work/WorkRootComponents.swift b/apps/ios/ADE/Views/Work/WorkRootComponents.swift index 71bff01ef..97e4fc647 100644 --- a/apps/ios/ADE/Views/Work/WorkRootComponents.swift +++ b/apps/ios/ADE/Views/Work/WorkRootComponents.swift @@ -623,10 +623,8 @@ struct WorkSessionListRow: View { && canonicalPhase == .settled } - private var snoozeOptions: [(duration: WorkSnoozeDuration, deadline: Date)] { - WorkSnoozeDuration.allCases.compactMap { duration in - duration.deadline().map { (duration, $0) } - } + private var snoozeOptions: [WorkSnoozeOption] { + workSnoozeOptions() } var body: some View { diff --git a/apps/ios/ADE/Views/Work/WorkSessionCanonicalState.swift b/apps/ios/ADE/Views/Work/WorkSessionCanonicalState.swift index 7c0eff655..849f54c92 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionCanonicalState.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionCanonicalState.swift @@ -461,20 +461,24 @@ func resolveSessionWakeReason( /// The snooze windows offered on iOS. Keys, copy, and deadline math mirror the /// desktop `SNOOZE_DURATION_OPTIONS` / `snoozeDeadlineIso` exactly, so the same /// choice means the same instant on both surfaces. Menu order is fixed: -/// shortest window first, open-ended last. +/// shortest window first, open-ended last. `thisEvening` is omitted from the +/// resolved menu once 18:00 is within an hour or already past, matching +/// desktop's `resolveSnoozePresets`. enum WorkSnoozeDuration: String, CaseIterable, Identifiable { case oneHour case thisEvening case tomorrowMorning + case nextWeek case untilAsked var id: String { rawValue } var label: String { switch self { - case .oneHour: return "1 hour" - case .thisEvening: return "Until this evening" - case .tomorrowMorning: return "Until tomorrow 9am" + case .oneHour: return "In 1 hour" + case .thisEvening: return "This evening" + case .tomorrowMorning: return "Tomorrow" + case .nextWeek: return "Next week" case .untilAsked: return "Until I'm asked" } } @@ -484,6 +488,7 @@ enum WorkSnoozeDuration: String, CaseIterable, Identifiable { case .oneHour: return "clock" case .thisEvening: return "sunset" case .tomorrowMorning: return "sunrise" + case .nextWeek: return "calendar" case .untilAsked: return "hand.raised" } } @@ -509,12 +514,44 @@ enum WorkSnoozeDuration: String, CaseIterable, Identifiable { return evening > now ? evening : atLocalHour(Self.eveningHour, dayOffset: 1) case .tomorrowMorning: return atLocalHour(Self.morningHour, dayOffset: 1) + case .nextWeek: + // Calendar weekday values are Sunday = 1, Monday = 2. A choice made on + // Monday means the following Monday, never a few hours later today. + let monday = 2 + let today = calendar.component(.weekday, from: now) + let daysUntilMonday = (monday - today + 7) % 7 + return atLocalHour(Self.morningHour, dayOffset: daysUntilMonday == 0 ? 7 : daysUntilMonday) case .untilAsked: return calendar.date(byAdding: .day, value: workSnoozeIndefiniteDays, to: now) } } } +struct WorkSnoozeOption: Equatable { + let duration: WorkSnoozeDuration + let deadline: Date +} + +/// Resolve the native menu from one wall-clock snapshot so filtering and +/// deadlines cannot disagree around the 17:00 / 18:00 boundary. +func workSnoozeOptions( + now: Date = Date(), + calendar: Calendar = .current +) -> [WorkSnoozeOption] { + let eveningIsUseful: Bool = { + guard let evening = calendar.date(bySettingHour: 18, minute: 0, second: 0, of: now) else { + return false + } + return evening.timeIntervalSince(now) > 60 * 60 + }() + + return WorkSnoozeDuration.allCases.compactMap { duration in + if duration == .thisEvening && !eveningIsUseful { return nil } + guard let deadline = duration.deadline(from: now, calendar: calendar) else { return nil } + return WorkSnoozeOption(duration: duration, deadline: deadline) + } +} + extension TerminalSessionSummary { /// The row's snooze columns as the shared derivation input. var snoozeState: SessionSnoozeState { diff --git a/apps/ios/ADE/Views/Work/WorkSessionGrouping.swift b/apps/ios/ADE/Views/Work/WorkSessionGrouping.swift index 009a54b5f..7df190cbb 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionGrouping.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionGrouping.swift @@ -719,7 +719,7 @@ func workSessionGroupsByStatus( groups.append(WorkSessionGroup(id: "status:pinned", label: "Pinned", icon: .statusDot, tint: ADEColor.accent, sessions: pinned)) } if !running.isEmpty { - groups.append(WorkSessionGroup(id: "status:running", label: "Running", icon: .statusDot, tint: ADEColor.success, sessions: running)) + groups.append(WorkSessionGroup(id: "status:running", label: "Working", icon: .statusDot, tint: ADEColor.info, sessions: running)) } if !ended.isEmpty { groups.append(WorkSessionGroup(id: "status:ended", label: "Ended", icon: .statusDot, tint: ADEColor.textMuted, sessions: ended)) diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 0201b3d8c..dc71aa9f4 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -1,6 +1,7 @@ import XCTest import AVFoundation import SQLite3 +import SwiftUI import UIKit @testable import ADE @@ -10928,6 +10929,76 @@ final class ADETests: XCTestCase { ) } + func testAgentRunPhaseLabelsMatchTheDesktopVocabulary() { + // The widgets mirror `sessionStatusPresentation.ts` word-for-word: the + // in-flight phase reads "Working" (not "Running") and the terminal one + // reads "Done" (not "Completed"), so the Lock Screen and the Work sidebar + // never describe the same session with two different words. + XCTAssertEqual(AgentRunPhase.starting.label, "Starting") + XCTAssertEqual(AgentRunPhase.running.label, "Working") + XCTAssertEqual(AgentRunPhase.waitingForApproval.label, "Needs you") + XCTAssertEqual(AgentRunPhase.waitingForInput.label, "Needs you") + XCTAssertEqual(AgentRunPhase.completed.label, "Done") + XCTAssertEqual(AgentRunPhase.failed.label, "Failed") + XCTAssertEqual(AgentRunPhase.stale.label, "Stale") + } + + func testAgentRunPhaseRawValuesArePinnedToThePushWireFormat() { + // These slugs are the Live Activity / APNs wire format sent by every + // desktop version already in the field. Presentation may change; renaming + // a raw value would silently downgrade real payloads to `.running`. + XCTAssertEqual( + AgentRunPhase.allCases.map(\.rawValue), + ["starting", "running", "waiting_for_approval", "waiting_for_input", "completed", "failed", "stale"] + ) + } + + func testAgentRunPhaseSpendsAmberOnlyOnYourMove() { + // One hue, one meaning. Amber previously carried five unrelated states + // across ADE, which is why it stopped meaning anything. + for phase in AgentRunPhase.allCases { + let isYourMove = phase == .waitingForApproval || phase == .waitingForInput + XCTAssertEqual( + phase.tint == ADESharedTheme.warningAmber, + isYourMove, + "\(phase.rawValue) must \(isYourMove ? "" : "not ")be amber" + ) + } + + // Work in flight is blue and "done" is emerald — never the same hue, or + // "still going" and "finished" collide at a glance. + XCTAssertEqual(AgentRunPhase.running.tint, ADESharedTheme.statusRunning) + XCTAssertEqual(AgentRunPhase.starting.tint, ADESharedTheme.statusRunning) + XCTAssertEqual(AgentRunPhase.completed.tint, ADESharedTheme.statusSuccess) + XCTAssertNotEqual(AgentRunPhase.running.tint, AgentRunPhase.completed.tint) + XCTAssertEqual(AgentRunPhase.failed.tint, ADESharedTheme.statusFailed) + // Stale is neutral: alive but silent is true, not actionable. + XCTAssertEqual(AgentRunPhase.stale.tint, ADESharedTheme.statusIdle) + } + + func testAgentRunPhaseStaleReadsAsSilenceNotDisconnection() { + // A stale run is one that has produced no output for hours — the process + // is alive. `wifi.slash` sent people to check their network. + XCTAssertTrue( + AgentRunPhase.stale.symbol.hasPrefix("clock"), + "stale must use a clock glyph, got \(AgentRunPhase.stale.symbol)" + ) + XCTAssertEqual(AgentRunPhase.running.symbol, "circle.dotted") + XCTAssertEqual(AgentRunPhase.completed.symbol, "checkmark.circle.fill") + } + + func testAgentRunPhaseProminenceGoesOnlyToStatesThatWantAHuman() { + // Mirrors the sidebar's recede rule: prominence is a request for + // attention, not a progress report. + XCTAssertTrue(AgentRunPhase.waitingForApproval.isProminent) + XCTAssertTrue(AgentRunPhase.waitingForInput.isProminent) + XCTAssertTrue(AgentRunPhase.completed.isProminent) + XCTAssertTrue(AgentRunPhase.failed.isProminent) + XCTAssertFalse(AgentRunPhase.starting.isProminent) + XCTAssertFalse(AgentRunPhase.running.isProminent) + XCTAssertFalse(AgentRunPhase.stale.isProminent) + } + @MainActor func testSyncMergedRelayCandidatesFoldsAdvertisedFreshestFirst() { let service = SyncService(database: makeDatabase(baseURL: makeTemporaryDirectory())) diff --git a/apps/ios/ADETests/AttentionDrawerModelTests.swift b/apps/ios/ADETests/AttentionDrawerModelTests.swift index e60c9ad56..eedb2f966 100644 --- a/apps/ios/ADETests/AttentionDrawerModelTests.swift +++ b/apps/ios/ADETests/AttentionDrawerModelTests.swift @@ -936,12 +936,101 @@ final class AttentionDrawerModelTests: XCTestCase { } func testAccountAttentionPhaseLabelsUseUnifiedVocabulary() { - XCTAssertEqual(AccountAttentionPhase.running.displayLabel, "Running") + // Same words as `AgentRunPhase.label` and the desktop sidebar. The + // drawer and the Lock Screen sit on one device; two names for one + // session state is the bug this vocabulary exists to prevent. + XCTAssertEqual(AccountAttentionPhase.running.displayLabel, "Working") XCTAssertEqual(AccountAttentionPhase.needsYou.displayLabel, "Needs you") XCTAssertEqual(AccountAttentionPhase.checksFailing.displayLabel, "Checks failing") XCTAssertEqual(AccountAttentionPhase.reviewRequested.displayLabel, "Review requested") XCTAssertEqual(AccountAttentionPhase.mergeReady.displayLabel, "Ready to merge") - XCTAssertEqual(AccountAttentionPhase.completed.displayLabel, "Completed") + XCTAssertEqual(AccountAttentionPhase.completed.displayLabel, "Done") + XCTAssertEqual(AccountAttentionPhase.stale.displayLabel, "Stale") + XCTAssertEqual(AgentRunPhase.running.label, AccountAttentionPhase.running.displayLabel) + XCTAssertEqual(AgentRunPhase.completed.label, AccountAttentionPhase.completed.displayLabel) + } + + func testWorkspaceAgentPhaseFallbackUsesUnifiedVocabulary() { + let model = AttentionDrawerModel(defaults: defaults) + let now = Date() + let agents = [ + AgentSnapshot( + sessionId: "working", + provider: "codex", + title: "Working copy", + status: "running", + awaitingInput: false, + lastActivityAt: now, + elapsedSeconds: 20, + preview: nil, + progress: nil, + phase: "running", + toolCalls: 1 + ), + AgentSnapshot( + sessionId: "done-phase", + provider: "claude", + title: "Done copy", + status: "running", + awaitingInput: false, + lastActivityAt: now.addingTimeInterval(-1), + elapsedSeconds: 30, + preview: nil, + progress: nil, + phase: "completed", + toolCalls: 2 + ), + ] + + model.rebuild(from: WorkspaceSnapshot( + generatedAt: now, + agents: agents, + prs: [], + connection: "connected" + )) + + XCTAssertEqual(model.liveItems.map(\.sessionId), ["working", "done-phase"]) + XCTAssertEqual(model.liveItems.first(where: { $0.sessionId == "working" })?.subtitle, "Working") + XCTAssertEqual(model.liveItems.first(where: { $0.sessionId == "done-phase" })?.subtitle, "Done") + } + + func testBlockedItemIsNotFiledOrColouredAsYourMove() { + // `blocked` used to borrow `awaitingInput`'s amber bell while filing + // itself under `live` — the row said "act on me" in colour and "just + // watching" in placement. `needsInbox` excludes it, so neutral is the + // honest reading. + let model = AttentionDrawerModel(defaults: defaults) + let now = Date() + let machine = AccountAttentionMachine( + machineKey: "studio", + accountMachineKey: "account-studio", + name: "Studio Mac", + online: true, + lastSeenAt: now + ) + let blocked = AccountAttentionItem( + id: "blocked", + revision: 1, + fingerprint: "blocked:1", + kind: .agent, + eventKind: .agentRunning, + phase: .blocked, + machine: machine, + project: .init(projectId: "ade", name: "ADE"), + title: "Waiting on a dependency", + preview: "Blocked", + privacyPreview: "Agent blocked", + destination: .session(sessionId: "session-blocked", itemId: nil, eventId: nil), + occurredAt: now, + updatedAt: now + ) + + XCTAssertFalse(blocked.needsInbox, "blocked never reaches the inbox") + + model.rebuild(from: AccountAttentionSnapshot(revision: 1, generatedAt: now, items: [blocked])) + + XCTAssertEqual(model.liveItems.first?.kind, .blocked) + XCTAssertTrue(model.items.isEmpty, "blocked must not land in the Needs you collection") } private func makeAccountItem( diff --git a/apps/ios/ADETests/WorkSessionCanonicalStateTests.swift b/apps/ios/ADETests/WorkSessionCanonicalStateTests.swift index 874e5ffc2..8a5a8db47 100644 --- a/apps/ios/ADETests/WorkSessionCanonicalStateTests.swift +++ b/apps/ios/ADETests/WorkSessionCanonicalStateTests.swift @@ -915,6 +915,19 @@ final class WorkSessionCanonicalStateTests: XCTestCase { XCTAssertEqual(groups.last?.sessions.map(\.id), ["s-calm"]) } + func testStatusGroupUsesTheSharedWorkingVocabulary() { + let running = makeSession(status: "running", runtimeState: "running", toolType: "claude-chat") + let groups = workSessionGroupsByStatus( + sessions: [running], + chatSummaries: [:], + archivedSessionIds: [] + ) + + XCTAssertEqual(groups.map(\.id), ["status:running"]) + XCTAssertEqual(groups.first?.label, "Working") + XCTAssertEqual(groups.first?.sessions.map(\.id), [running.id]) + } + // MARK: - Re-deriving the groups when a deadline lapses // // Expiry stays derived from the clock, but `WorkRootScreen` CACHES the @@ -1384,6 +1397,15 @@ final class WorkSessionCanonicalStateTests: XCTestCase { 1, "tomorrow 9am is the next calendar day in the user's own time zone" ) + + let nextWeek = XCTUnwrap2(WorkSnoozeDuration.nextWeek.deadline(from: morning, calendar: calendar)) + XCTAssertEqual(calendar.component(.weekday, from: nextWeek), 2, "next week always lands on Monday") + XCTAssertEqual(calendar.component(.hour, from: nextWeek), 9) + XCTAssertEqual( + calendar.dateComponents([.day], from: calendar.startOfDay(for: morning), to: calendar.startOfDay(for: nextWeek)).day, + 7, + "choosing next week on Monday means the following Monday" + ) } /// Past 18:00 "this evening" has gone, so it rolls to the next one rather @@ -1402,6 +1424,27 @@ final class WorkSessionCanonicalStateTests: XCTestCase { ) } + func testSnoozeMenuSuppressesThisEveningOnceItDuplicatesOneHour() { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "America/Los_Angeles")! + let afternoon = calendar.date(from: DateComponents(year: 2026, month: 7, day: 7, hour: 16, minute: 30))! + let boundary = calendar.date(from: DateComponents(year: 2026, month: 7, day: 7, hour: 17))! + let night = calendar.date(from: DateComponents(year: 2026, month: 7, day: 7, hour: 21))! + + XCTAssertEqual( + workSnoozeOptions(now: afternoon, calendar: calendar).map(\.duration), + [.oneHour, .thisEvening, .tomorrowMorning, .nextWeek, .untilAsked] + ) + XCTAssertEqual( + workSnoozeOptions(now: boundary, calendar: calendar).map(\.duration), + [.oneHour, .tomorrowMorning, .nextWeek, .untilAsked] + ) + XCTAssertEqual( + workSnoozeOptions(now: night, calendar: calendar).map(\.duration), + [.oneHour, .tomorrowMorning, .nextWeek, .untilAsked] + ) + } + /// "Until I'm asked" parks the row far enough out that the row copy reads as /// open-ended instead of counting down to a date a century away. func testUntilAskedRendersAsOpenEndedRowCopy() { diff --git a/apps/ios/ADEWidgets/ADEAgentActivityWidget.swift b/apps/ios/ADEWidgets/ADEAgentActivityWidget.swift index 2347e3c1a..c3c4b41d6 100644 --- a/apps/ios/ADEWidgets/ADEAgentActivityWidget.swift +++ b/apps/ios/ADEWidgets/ADEAgentActivityWidget.swift @@ -214,8 +214,11 @@ struct AgentRunsPresentation { + secondaryRuns.count + secondaryPrs.count self.overflowCount = max(0, activeCount + safeState.prs.count - represented) - self.isStale = ownershipAccepted - && (isStale || safeState.runs.contains { $0.resolvedPhase == .stale }) + // ActivityKit push staleness only. A run whose *phase* is `.stale` used + // to fold in here, which greyed the whole glance and showed + // "Reconnecting" because a single agent had gone quiet — a silence, not + // a transport failure. The stale run says so on its own row. + self.isStale = ownershipAccepted && isStale self.machineName = ownershipAccepted ? attributes.machineName : "ADE" self.accountWide = accountWide self.ownershipAccepted = ownershipAccepted @@ -223,10 +226,14 @@ struct AgentRunsPresentation { } /// Tint of the glance — attention amber wins, otherwise the primary run's - /// phase color, dimmed toward idle when stale. + /// phase color, dimmed toward idle when the push feed itself is stale. + /// + /// Amber is checked first, and deliberately outranks staleness: "your move" + /// is the one thing the glance may never swallow, and a late push does not + /// make a raised hand less true. var tint: Color { - if isStale { return ADESharedTheme.statusIdle } if waitingCount > 0 { return ADESharedTheme.warningAmber } + if isStale { return ADESharedTheme.statusIdle } if let primaryPr, primary == nil { return primaryPr.resolvedPhase.tint } return primary?.resolvedPhase.tint ?? ADESharedTheme.statusIdle } @@ -254,7 +261,7 @@ struct AgentRunsPresentation { let trimmed = machineName.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } if activeCount > runs.count { - return "\(trimmed) · \(activeCount) running" + return "\(trimmed) · \(activeCount) working" } return trimmed } @@ -278,12 +285,23 @@ struct AgentRunsPresentation { return target.deepLinkURL ?? workspace } + /// Focus order, matching the desktop sidebar's prominence rule: the states + /// that want a human come first, and merely-in-flight work recedes. + /// + /// `.completed` therefore outranks `.running` — a finished run you have not + /// looked at is a result, while a running one is a progress report. This is + /// safe here in a way it would not be on the Lock Screen widget: the Live + /// Activity roster is the host's current runs, so a `.completed` row is + /// freshly finished rather than an outcome from hours ago. + /// + /// `.stale` sits last on its own — it is neither an outcome nor live work. private static func priority(_ phase: AgentRunPhase) -> Int { switch phase { case .waitingForApproval, .waitingForInput: return 0 case .failed: return 1 - case .starting, .running: return 2 - case .completed, .stale: return 3 + case .completed: return 2 + case .starting, .running: return 3 + case .stale: return 4 } } @@ -388,13 +406,15 @@ private struct AgentRunsLockScreenView: View { } if presentation.activeCount == 0, let phase = presentation.primary?.resolvedPhase { if phase == .failed { return "Agent work failed" } - if phase == .completed { return "Agent work completed" } + if phase == .completed { return "Agent work done" } } if presentation.runs.isEmpty && !presentation.prs.isEmpty { return presentation.prs.count == 1 ? "1 pull request updated" : "\(presentation.prs.count) pull requests updated" } + // "working", not "running" — same word the row labels and the desktop + // sidebar use for the in-flight phase. let count = presentation.activeCount - return count == 1 ? "1 agent running" : "\(count) agents running" + return count == 1 ? "1 agent working" : "\(count) agents working" } } @@ -470,8 +490,12 @@ private struct AgentActivityRunHero: View { .padding(.horizontal, compact ? 5 : 8) .padding(.vertical, compact ? 3 : 7) .background( + // The stronger fill goes to the states that want a human — needs + // you, done, failed. In-flight work recedes, mirroring the desktop + // sidebar's rule that prominence is a request for attention, not a + // progress report. RoundedRectangle(cornerRadius: compact ? 7 : 10, style: .continuous) - .fill(phase.tint.opacity(phase.needsAttention || phase == .failed ? 0.16 : 0.1)) + .fill(phase.tint.opacity(phase.isProminent ? 0.16 : 0.1)) ) .overlay( RoundedRectangle(cornerRadius: compact ? 7 : 10, style: .continuous) @@ -658,9 +682,12 @@ private struct AgentRunRow: View { Spacer(minLength: 4) + // Colour is reserved for the states that want a human: a failed or + // finished run keeps its hue, working / starting / stale recede to + // secondary so the few rows that matter stand out. Text(phase.label) .font(compact ? .system(size: 9, weight: .semibold) : .caption2.weight(.semibold)) - .foregroundStyle(phase.needsAttention ? phase.tint : .secondary) + .foregroundStyle(phase.isProminent ? phase.tint : .secondary) .lineLimit(1) } .contentShape(Rectangle()) diff --git a/apps/ios/ADEWidgets/ADELockScreenWidget.swift b/apps/ios/ADEWidgets/ADELockScreenWidget.swift index 4c932662f..0b73d7051 100644 --- a/apps/ios/ADEWidgets/ADELockScreenWidget.swift +++ b/apps/ios/ADEWidgets/ADELockScreenWidget.swift @@ -345,12 +345,12 @@ private struct LockScreenPriorityStatus { } else if let first = running.first { self = .init( kind: .running, - title: running.count == 1 ? "1 agent running" : "\(running.count) agents running", + title: running.count == 1 ? "1 agent working" : "\(running.count) agents working", detail: hideDetails ? "Agent work is in progress" : Self.runningDetail(first), - inlineText: "ADE · \(running.count) running", + inlineText: "ADE · \(running.count) working", count: running.count, symbol: "circle.dotted", - shortLabel: "RUN", + shortLabel: "WORK", tint: ADESharedTheme.statusRunning, destinationURL: Self.sessionURL(first.sessionId), metrics: metrics @@ -377,7 +377,10 @@ private struct LockScreenPriorityStatus { count: 0, symbol: "arrow.triangle.2.circlepath", shortLabel: "SYNC", - tint: ADESharedTheme.statusAttention, + // Neutral, not amber. A sync in flight is true but not + // actionable — nobody is being asked for anything — and amber + // is spent only on "your move". + tint: ADESharedTheme.statusIdle, destinationURL: Self.workspaceURL, metrics: metrics ) @@ -390,7 +393,13 @@ private struct LockScreenPriorityStatus { count: 0, symbol: "wifi.slash", shortLabel: "OFF", - tint: ADESharedTheme.statusFailed, + // Neutral, like the other two "Mac offline" branches above. + // Red is the alarm hue and now means only "it broke" — a host + // that is simply not reachable is true, but it is not a + // failure, and three offline states in one widget wearing two + // different colours is exactly how a hue stops meaning + // anything. + tint: ADESharedTheme.statusIdle, destinationURL: Self.workspaceURL, metrics: metrics ) @@ -482,7 +491,7 @@ private struct LockScreenPriorityStatus { ) -> [Metric] { var result: [Metric] = [] if runningCount > 0 { - result.append(.init(id: "running", label: "\(runningCount) run", symbol: "circle.dotted")) + result.append(.init(id: "running", label: "\(runningCount) working", symbol: "circle.dotted")) } if waitingCount > 0 { result.append(.init(id: "waiting", label: "\(waitingCount) wait", symbol: "bell.fill")) @@ -508,12 +517,21 @@ private struct LockScreenPriorityStatus { } } + /// Glyph / label / hue for the focus row. Amber appears exactly once here, + /// on `.needsYou`, and that is the whole rule (see `AgentRunPhase` and the + /// desktop's `sessionStatusPresentation`). private static func presentation( for phase: AccountAttentionPhase ) -> (kind: Kind, symbol: String, label: String, tint: Color) { switch phase { - case .needsYou, .blocked: + case .needsYou: return (.awaitingInput, "bell.badge.fill", "YOU", ADESharedTheme.warningAmber) + // Blocked is neutral, not amber: `AccountAttentionItem.needsInbox` + // deliberately excludes it, so it never reaches the inbox and is not + // the user's move. Painting it amber made "go act" and "waiting on + // something else" the same colour. + case .blocked: + return (.idle, "hourglass", "HOLD", ADESharedTheme.statusIdle) case .failed: return (.failed, "xmark.octagon.fill", "FAIL", ADESharedTheme.statusFailed) case .checksFailing, .changesRequested: @@ -523,11 +541,18 @@ private struct LockScreenPriorityStatus { case .mergeReady: return (.mergeReady, "checkmark.seal.fill", "READY", ADESharedTheme.statusSuccess) case .starting, .running: - return (.running, "waveform.path.ecg", "LIVE", ADESharedTheme.statusRunning) + // Same dashed-circle glyph the snapshot path and the desktop + // sidebar use for work in flight; the widget used to show a + // heartbeat trace here and a dotted circle two branches down. + return (.running, "circle.dotted", "WORK", ADESharedTheme.statusRunning) case .open: return (.idle, "arrow.triangle.pull", "OPEN", ADESharedTheme.statusRunning) + // Stale is a silence, not a disconnection: the run is alive and has + // said nothing for hours. A clock asks "how long?", which is the + // question the row actually raises; `wifi.slash` sent people to check + // their network. case .stale: - return (.offline, "wifi.slash", "OFF", ADESharedTheme.statusIdle) + return (.idle, "clock.badge.exclamationmark", "STALE", ADESharedTheme.statusIdle) case .completed: return (.idle, "checkmark.circle.fill", "DONE", ADESharedTheme.statusSuccess) case .merged: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4913cbfcc..5831beaa8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -166,7 +166,7 @@ forced. The brain's own log stream is written by **Optional ADE account auth.** `ade login` preserves the local-browser loopback OAuth path, but selects the account-directory device authorization bridge for explicit `--headless`, SSH, display-less hosts, or a failed browser launch. The brain generates and retains the device redemption secret, polls the bridge, and persists the resulting refresh-capable session under `account.session.v1`. For a JWT access token, its decoded `exp` claim is authoritative over the OAuth `expires_in` bookkeeping: status reports that expiry, and `getAccessToken()` refreshes inside the two-minute skew even when an older stored session record claims a later expiry. Tokens without a usable JWT expiry retain the stored `expiresAt` fallback. The desktop and brain share the encrypted session file and may race a rotating refresh credential; after an OAuth `invalid_grant`, the loser re-reads persistence and retries once only when another process has written a different refresh token. Other refresh failures are not replayed, and raw tokens are never logged. `ADE_ACCOUNT_TOKEN` takes precedence without starting a login flow: JWT access credentials are used through their declared expiry, while refresh credentials are exchanged and rotated only in memory. `ade account token create` wraps the current interactive refresh credential with its public issuer/client context in a versioned secret envelope, so a newly provisioned agent or CI host needs no local Clerk configuration. Legacy raw opaque refresh tokens retain local-config compatibility and return migration guidance when that config is absent. Distributed CLI/brain binaries and packaged Electron set `ADE_RUNTIME_PACKAGED=1` before account services start. In that mode, a Clerk issuer or JWKS URL under `*.clerk.accounts.dev`, plus the exact ADE development directory override, is rejected atomically in favor of the complete built-in production OAuth, attestation, and directory configuration; a non-development custom issuer remains valid, and source checkouts retain their existing override behavior. Persisted sessions pinned to a development issuer/client, sessions carrying a development `iss` access-token claim, and equivalent `ADE_ACCOUNT_TOKEN` credentials are rejected before token return, refresh, userinfo, or directory use. A rejected environment credential is treated as absent by status, access-token resolution, interactive login, device login, and durable-token provisioning, so it cannot block a new production sign-in. When the credential store supports atomic updates, a persisted development session is compare-and-deleted, then persistence is re-read exactly once: a peer-written acceptable production replacement is returned in the same status call. Without compare-and-delete support, ADE leaves the stored value untouched to avoid erasing a peer write but continues to report that development session as signed out. `ADE_ALLOW_DEVELOPMENT_CLERK=1` is the explicit packaged-build escape hatch for controlled development testing. The desktop Account page exposes one honest browser continuation because the bridge opens the generic hosted account flow rather than selecting a provider; the browser presents whichever methods are enabled. Native iOS uses ClerkKit's transferable OAuth result to distinguish new accounts from returning users. Its identifier-first email path starts sign-in, falls back to sign-up only for Clerk's precise account-not-found codes, sends the sign-up email verification code, and verifies against the matching sign-in or sign-up attempt. Account status exposes `loopback`, `device`, or `env-token`; signed-out state never gates local projects, `ade code`, local pairing, or PIN workflows. When product analytics is enabled, a known signed-in account produces one quota-counted PostHog `$identify` using only a one-way account hash plus plan, platform, and app version; explicit sign-out rotates the analytics anonymous identity. -**Action surface.** First-class command families cover lanes (including `ade lanes link-linear-issue` / `detach-linear-issue` for post-creation Linear issue linking, and `ade lanes create-from-linear` / `batch-create-from-linear` to spin up one or many issue lanes — optionally launching an agent chat with `--start-chat`), git, diffs, files, PRs, shells, chats (including `ade chat create --prompt` for a persistent Work chat followed by an initial chat message, `ade chat send` / `message` / `steer` / `wait` for peer chat delivery and status polling, `ade chat read ` for recent transcript messages, `ade chat note` / `ask` / `settle` / `unsettle` for the current Work row, `ade chat scheduled-work create --cron "" --prompt "" [--once]` for durable provider-neutral scheduling, `ade chat create --from-linear-issue `, `ade chat attach-linear-issue` / `detach-linear-issue` / `linear-issues` for session-scoped issue attachment, and `--parent ` / `--no-parent` to control child-chat lineage — a chat created via `ade chat create` / `ade new --mode chat` defaults its parent to `$ADE_CHAT_SESSION_ID` (the spawning agent's own chat, injected into every tracked agent shell) so it lists in the parent's subagents panel instead of becoming an orphan, and `--no-parent` opts out), agents, CTO, Linear (the write bridge an attached CLI agent uses: `ade linear attach` / `detach` / `issues` / `issue` / `comment` / `set-state` / `assign` / `label`, with `--this-session` resolving the issue id from `$ADE_LINEAR_ISSUE_IDS` so a launched agent needs no Linear token — see [features/linear-integration/README.md](./features/linear-integration/README.md#session-scoped-issue-attachment-and-cli-context-injection)), tests, proof, settings, the iOS Simulator (`ade ios-sim` / `ade ios` / `ade simulator` — see [features/ios-simulator/README.md](./features/ios-simulator/README.md)), the Cursor Cloud bridge (`ade cursor cloud agents | runs | artifacts | repos | models | me` — talks directly to `@cursor/sdk` without going through the ADE runtime endpoint), the App Control bridge for Electron apps (`ade app-control` / `ade app` / `ade electron` — `launch`, `connect`, `stop`, `status`, `screenshot`, `snapshot`, `inspect`, `select`, `click`, `type`, `scroll`, `key`, `targets`, `attach`, `logs`, `terminal write`, `terminal signal` — see [features/computer-use/app-control.md](./features/computer-use/app-control.md)), the chat-scoped terminal (`ade terminal list` / `read` / `write` / `signal` / `active`), universal search (`ade search ""` over chats, terminals, PRs, commits, branches, lanes, files, and Linear — see [features/search/README.md](./features/search/README.md)), and a generic `ade actions run ` escape hatch for every registered ADE service action. The chat action surface includes `chat.createSession`, `chat.sendMessage` (low-level normal-turn send), `chat.messageSession` (normalized peer delivery: auto, queue, wake, interrupt-replace), `chat.readTranscript`, `chat.createScheduledWork`, `chat.listScheduledWork`, `chat.getScheduledWorkState`, `chat.cancelScheduledWork`, `chat.setScheduledWorkPaused`, and model-catalog actions; the session action surface includes caller-scoped `requestSessionAttention`, `setSessionStatusNote`, `settleSelfSession`, `unsettleSelfSession`, `settleSessions`, `unsettleSessions`, `setSettleOverride`, `snoozeSession`, `snoozeSessions`, `wakeSession`, `wakeSessions`, and `clearWokeMarker`. A bound agent may target only its own eligible session, and an omitted lifecycle target is injected from that binding. `chat.messageSession` remains the reviewed primitive for deliberately messaging another ADE chat through routing semantics. The action allow-list adds three domains for these surfaces: `app_control` (every public method on `AppControlService`), `terminal` (`list`, `read`, `write`, `signal`, `activeForChat` against `ptyService`), named iOS Simulator actions for launch, live view, inspection, input, and Preview Lab workflows, and `search` (`query`, `indexStatus`, and the CTO-only `rebuildIndex` against `searchService`; session-bound non-CTO callers get chat/terminal hits scoped to their own session). +**Action surface.** First-class command families cover lanes (including `ade lanes link-linear-issue` / `detach-linear-issue` for post-creation Linear issue linking, and `ade lanes create-from-linear` / `batch-create-from-linear` to spin up one or many issue lanes — optionally launching an agent chat with `--start-chat`), git, diffs, files, PRs, shells, chats (including `ade chat create --prompt` for a persistent Work chat followed by an initial chat message, `ade chat send` / `message` / `steer` / `wait` for peer chat delivery and status polling, `ade chat read ` for recent transcript messages, `ade chat note` / `ask` for the current Work row (settling is not agent-reachable — see below), `ade chat scheduled-work create --cron "" --prompt "" [--once]` for durable provider-neutral scheduling, `ade chat create --from-linear-issue `, `ade chat attach-linear-issue` / `detach-linear-issue` / `linear-issues` for session-scoped issue attachment, and `--parent ` / `--no-parent` to control child-chat lineage — a chat created via `ade chat create` / `ade new --mode chat` defaults its parent to `$ADE_CHAT_SESSION_ID` (the spawning agent's own chat, injected into every tracked agent shell) so it lists in the parent's subagents panel instead of becoming an orphan, and `--no-parent` opts out), agents, CTO, Linear (the write bridge an attached CLI agent uses: `ade linear attach` / `detach` / `issues` / `issue` / `comment` / `set-state` / `assign` / `label`, with `--this-session` resolving the issue id from `$ADE_LINEAR_ISSUE_IDS` so a launched agent needs no Linear token — see [features/linear-integration/README.md](./features/linear-integration/README.md#session-scoped-issue-attachment-and-cli-context-injection)), tests, proof, settings, the iOS Simulator (`ade ios-sim` / `ade ios` / `ade simulator` — see [features/ios-simulator/README.md](./features/ios-simulator/README.md)), the Cursor Cloud bridge (`ade cursor cloud agents | runs | artifacts | repos | models | me` — talks directly to `@cursor/sdk` without going through the ADE runtime endpoint), the App Control bridge for Electron apps (`ade app-control` / `ade app` / `ade electron` — `launch`, `connect`, `stop`, `status`, `screenshot`, `snapshot`, `inspect`, `select`, `click`, `type`, `scroll`, `key`, `targets`, `attach`, `logs`, `terminal write`, `terminal signal` — see [features/computer-use/app-control.md](./features/computer-use/app-control.md)), the chat-scoped terminal (`ade terminal list` / `read` / `write` / `signal` / `active`), universal search (`ade search ""` over chats, terminals, PRs, commits, branches, lanes, files, and Linear — see [features/search/README.md](./features/search/README.md)), and a generic `ade actions run ` escape hatch for every registered ADE service action. The chat action surface includes `chat.createSession`, `chat.sendMessage` (low-level normal-turn send), `chat.messageSession` (normalized peer delivery: auto, queue, wake, interrupt-replace), `chat.readTranscript`, `chat.createScheduledWork`, `chat.listScheduledWork`, `chat.getScheduledWorkState`, `chat.cancelScheduledWork`, `chat.setScheduledWorkPaused`, and model-catalog actions; the session action surface includes caller-scoped `requestSessionAttention` and `setSessionStatusNote`, the agent-reachable snooze family (`snoozeSession`, `snoozeSessions`, `wakeSession`, `wakeSessions`, `clearWokeMarker`), and a CTO-only settle family (`settleSession`, `unsettleSession`, `settleSessions`, `unsettleSessions`, `setSettleOverride`). The caller-scoped `settleSelfSession` / `unsettleSelfSession` pair was removed in 2026-07: deciding that work is finished is a subjective judgment agents are unreliable at, so the only settle writers left are user surfaces (the desktop renderer's remote-runtime client and `ade code`, both of which authenticate at cto role) and the deterministic PR-merge policy, which calls `sessionService` directly. A bound agent may target only its own eligible session, and an omitted lifecycle target is injected from that binding. `chat.messageSession` remains the reviewed primitive for deliberately messaging another ADE chat through routing semantics. The action allow-list adds three domains for these surfaces: `app_control` (every public method on `AppControlService`), `terminal` (`list`, `read`, `write`, `signal`, `activeForChat` against `ptyService`), named iOS Simulator actions for launch, live view, inspection, input, and Preview Lab workflows, and `search` (`query`, `indexStatus`, and the CTO-only `rebuildIndex` against `searchService`; session-bound non-CTO callers get chat/terminal hits scoped to their own session). Fresh-chat kickoff uses `chat.messageSession(kind: "auto")` after creation. For mixed versions, the runtime recognizes the ADE ≤1.2.41 shape that sends @@ -178,7 +178,7 @@ update/restart requirement instead of leaving the created session blank. Scheduled work is part of that typed chat family. `ade chat scheduled-work create` requires exactly one of `--in `, `--at `, or `--cron ""`; the generic `chat.createScheduledWork` action uses the equivalent `delaySeconds`, `runAt`, or `cron` field. Relative and absolute forms are one-shot, while cron uses the ADE brain machine's local timezone and defaults recurring (`--once` / `recurring: false` selects one occurrence). Creation returns the brain's IANA timezone plus the scheduled item's absolute `nextRunAt`, and both typed and generic CLI text output show brain-local and ISO values for verification. List/cancel call `chat.listScheduledWork` / `chat.cancelScheduledWork`; `ade chat schedules --pause|--resume` calls `chat.setScheduledWorkPaused`, and omitting the flag calls `chat.getScheduledWorkState` for pause, next-wake, and active-job state. Create writes a durable provider-neutral row for any chat runtime or ADE-tracked provider CLI. A due chat row stays armed while a foreground turn is active, then starts a wake turn at the next safe boundary; live CLIs wait for a provider-specific visible composer boundary, and ended CLIs resume before delivery. For a session-bound agent, the daemon defaults an omitted target to the caller's own eligible session and denies cross-session, untracked-shell, or unbound-external scheduling. Management reads come from the KV-backed scheduler snapshot rather than reconstructed transcript events; provider-owned cancellation is reported as requested vs confirmed instead of being hidden optimistically. -`ade session` is the sibling family for *filing* a session rather than talking to it: `ade session show --text` prints settle/snooze state and the wake reason, `ade session snooze --for 1h` (or `--until `) parks a row, `ade session wake [--reason timer|needs_you|error|turn_complete|manual]` brings it back, `ade session settle [--outcome ""]` marks it complete while `--keep-active` pins it active instead (the only unsettle available to a row settled by the derived clean-exit rule), `ade session unsettle ` returns it to the active lifecycle, and `ade session clear-woke ` drops the "woke early" marker after visiting the row. Every subcommand takes the session id as a positional, accepts `--session `, and falls back to `$ADE_CHAT_SESSION_ID`, so a bound agent can file itself. Duration grammar and the 30-day cap live in `apps/ade-cli/src/sessionSnoozeDuration.ts` and are shared verbatim with `ade code`'s `/session …` commands. +`ade session` is the sibling family for *filing* a session rather than talking to it: `ade session show --text` prints settle/snooze state and the wake reason, `ade session snooze --for 1h` (or `--until `) parks a row, `ade session wake [--reason timer|needs_you|error|turn_complete|manual]` brings it back, and `ade session clear-woke ` drops the "woke early" marker after visiting the row. There is no `ade session settle` / `unsettle` (or `ade chat settle` / `unsettle`): those were removed in 2026-07 and now fail with an explanation pointing at the two remaining paths — the user settling the row in ADE, and the `autoSettleLaneSessionsOnPrMerge` policy. Nothing derives a settle either; a clean process exit leaves a row `ended`, never `settled`. Every subcommand takes the session id as a positional, accepts `--session `, and falls back to `$ADE_CHAT_SESSION_ID`, so a bound agent can file itself. Duration grammar and the 30-day cap live in `apps/ade-cli/src/sessionSnoozeDuration.ts` and are shared verbatim with `ade code`'s `/session …` commands. Personal chat is an explicit machine-only variant of the typed chat family: `ade chat list|create|show|read|send|interrupt|archive|unarchive|delete --personal`. It connects to the running brain, invokes `personalChats.call`, and rejects lane/Linear project flags rather than falling back to `--headless` project dispatch. ADE Code remains a project Work TUI and intentionally has no personal-chat UI in this release. diff --git a/docs/features/ade-code/README.md b/docs/features/ade-code/README.md index 94e570e74..6f6db93ff 100644 --- a/docs/features/ade-code/README.md +++ b/docs/features/ade-code/README.md @@ -24,7 +24,7 @@ Point Cursor’s browser inspector at the served page for layout debugging. The | `apps/ade-cli/src/cli.ts` | Resolves the built or source TUI entry and forwards the parsed launch context to `runAdeCodeCli`. | | `apps/ade-cli/src/adeRpcServer.ts` | Runtime JSON-RPC and ADE action dispatcher used by the TUI/CLI. After a successful user-issued meaningful mutation it records one local usage event, attributing `ade-code` / `ade-cli` clients to `tui`; agent-owned run/step/chat calls and read-only actions are excluded. | | `apps/ade-cli/src/tuiClient/cli.tsx` | TUI entry: argv parsing, project discovery, connection bootstrap, Ink mount. Built to `apps/ade-cli/dist/tuiClient/cli.mjs`. | -| `apps/ade-cli/src/tuiClient/app.tsx` | Primary Ink/React surface: navigation, composer, drawers, right pane, session lifecycle, slash command dispatch. It joins chat/terminal lists with `session.list` lifecycle fields and dispatches `/chat ask`, `/chat note`, `/chat settle`, and `/chat unsettle` through the session action domain; settling a row that is awaiting input or explicitly requesting attention asks the backend to dismiss that pending input in the same settlement transaction. The target-addressable `/session snooze` / `wake` / `settle` / `unsettle` / `keep-active` commands are parsed in `sessionLifecycle.ts` and dispatched from here, with `components/Drawer.tsx` and `components/RightPane.tsx` rendering the resulting snooze/woke row markers. Owns startup reconnect/retry UI, the debounced/cached `@` mention loader, cursor-relative `/command` + `@file` trigger detection via the shared `apps/desktop/src/shared/composerTriggers.ts` module (mid-sentence slash completion on Tab/Enter, colored `@file`/`/command` chip tokens painted into the prompt rows through `segmentPromptLineText` + `findConfirmedComposerTokens`), smart-link prompt styling/summary strips, terminal mode restoration on exit/heartbeat shutdown, and the `Ctrl+Y` "copy ADE deeplink" handler which resolves the focused lane / PR row through `buildDeeplinkForRow` and copies the canonical `ade://...` URL to the system clipboard. It also owns cache-first chat revisits and the two-stage older-history path: drain the already-hydrated local snapshot buffer, then request byte-cursor pages with bounded retry while preserving the cursor on transient failure. Also backs `/skills` by listing Agent Skill roots from project, user, inherited, and bundled ADE locations, independent of the active provider. | +| `apps/ade-cli/src/tuiClient/app.tsx` | Primary Ink/React surface: navigation, composer, drawers, right pane, session lifecycle, slash command dispatch. It joins chat/terminal lists with `session.list` lifecycle fields and dispatches `/chat ask`, `/chat note`, `/chat settle`, and `/chat unsettle` through the session action domain (settle/unsettle land on the cto-gated `session.settleSession` / `session.unsettleSession` — the agent-facing `*SelfSession` pair was removed in 2026-07); settling a row that is awaiting input or explicitly requesting attention asks the backend to dismiss that pending input in the same settlement transaction. The target-addressable `/session snooze` / `wake` / `settle` / `unsettle` / `keep-active` commands are parsed in `sessionLifecycle.ts` and dispatched from here, with `components/Drawer.tsx` and `components/RightPane.tsx` rendering the resulting snooze/woke row markers. Owns startup reconnect/retry UI, the debounced/cached `@` mention loader, cursor-relative `/command` + `@file` trigger detection via the shared `apps/desktop/src/shared/composerTriggers.ts` module (mid-sentence slash completion on Tab/Enter, colored `@file`/`/command` chip tokens painted into the prompt rows through `segmentPromptLineText` + `findConfirmedComposerTokens`), smart-link prompt styling/summary strips, terminal mode restoration on exit/heartbeat shutdown, and the `Ctrl+Y` "copy ADE deeplink" handler which resolves the focused lane / PR row through `buildDeeplinkForRow` and copies the canonical `ade://...` URL to the system clipboard. It also owns cache-first chat revisits and the two-stage older-history path: drain the already-hydrated local snapshot buffer, then request byte-cursor pages with bounded retry while preserving the cursor on transient failure. Also backs `/skills` by listing Agent Skill roots from project, user, inherited, and bundled ADE locations, independent of the active provider. | | `apps/ade-cli/src/tuiClient/attentionPane.ts` | Account-first `/attention` model: loads through machine-global `attention.call`, groups the shared Attention contract, derives exact ADE links, labels machine fallback honestly, and sends account-owner/source-revision-fenced seen mutations only after navigation succeeds. | | `apps/ade-cli/src/tuiClient/components/AttentionPaneView.tsx` | Calm right-pane rendering for Needs you, Failing or blocked, Done unreviewed, Live now, and Recent groups, including machine/project context, offline last-known labels, recovery copy, and keyboard hints. | | `apps/ade-cli/src/tuiClient/promptSmartLinks.ts` | ADE Code's capability-adapted smart-link helpers. Formats a one-row violet provider/label strip from the shared `smartLinks.ts` catalog and makes character Backspace/Delete remove the whole URL when the cursor intersects it; the prompt still contains and sends the canonical raw URL. | @@ -49,7 +49,7 @@ Point Cursor’s browser inspector at the served page for layout debugging. The | `apps/ade-cli/src/tuiClient/aggregate.ts` | Pure derivations on top of the chat event stream. Produces `AggregatedBlock`s (assistant text, connector-aware tool calls, files changed, web/image/plan/compaction groups, runtime-activity rows for subagent and activity envelopes, queued steers) and `derivePendingSteers`, consumed by `ChatView` and the right-pane steer view. MCP app/server identity replaces generic tool labels and image lifecycle updates collapse by item id. | | `apps/ade-cli/src/tuiClient/bracketedPaste.ts` | Bracketed-paste parser/formatter for terminal-control mode and multi-line forwarded input. Normalizes pasted newlines and wraps multi-line user input in bracketed-paste markers before writing it into provider CLI PTYs. | | `apps/ade-cli/src/tuiClient/closedCliSessions.ts` | Converts live or ended tracked CLI terminal sessions into chat-like summaries, retains the persisted settled/status/attention/failure fields, filters ended rows out of open chat lists, derives resumability/provider metadata, projects the scheduler-backed pause/jobs/next-wake state fetched by the Ink root, and maps user-initiated closes (0/130/143) to the neutral idle glyph instead of a failure state. | -| `apps/ade-cli/src/tuiClient/sessionLifecycle.ts` | ADE Code's half of the session-lifecycle surface: argument parsing for the `/session …` slash commands plus the text-only row markers the drawer and the right-pane chat list render. Everything semantic is imported, never re-derived — `isSessionSnoozed` / `isSessionFiledAsSnoozed` from `apps/desktop/src/shared/sessionCanonicalState.ts`, wake-label copy (`snoozeWakeLabel` → "wakes in 3h" / "wakes tomorrow" / "wakes when asked" / "wakes now") and woke-reason copy (`sessionWokeMarker` → "needs approval" / "errored" / "turn finished") from `apps/desktop/src/renderer/lib/sessionSnooze`, and duration grammar from `sessionSnoozeDuration.ts`. Snooze stays a visibility overlay: nothing in the module reads or writes a canonical phase. `SNOOZE_CHOICES` / `resolveSnoozeChoice` / `resolveSnoozeFreeText` back the duration picker. | +| `apps/ade-cli/src/tuiClient/sessionLifecycle.ts` | ADE Code's half of the session-lifecycle surface: argument parsing for the `/session …` slash commands plus the text-only row markers the drawer and the right-pane chat list render. Everything semantic is imported, never re-derived — `isSessionSnoozed` / `isSessionFiledAsSnoozed` from `apps/desktop/src/shared/sessionCanonicalState.ts`, wake-label copy (`snoozeWakeLabel` → "wakes in 3h" / "wakes tomorrow" / "wakes when asked" / "wakes now") and woke-reason copy (`sessionWokeMarker` → "needs approval" / "errored" / "turn finished") from `apps/desktop/src/renderer/lib/sessionSnooze`, and duration grammar from `sessionSnoozeDuration.ts`. Snooze stays a visibility overlay: nothing in the module reads or writes a canonical phase. `resolveSnoozeChoices` / `resolveSnoozeChoice` / `resolveSnoozeFreeText` back the duration picker. `resolveSnoozeChoices(nowMs)` is a function rather than the module-level constant it replaced, and deliberately so: the shared `resolveSnoozePresets` suppresses "This evening" once 18:00 is within an hour or past, and a list derived once at import would freeze that decision at process start — a TUI launched at 2pm would still offer "This evening" at 11pm. | | `apps/ade-cli/src/sessionSnoozeDuration.ts` | Snooze duration parsing shared by the `ade session snooze` planner in `cli.ts` and the TUI's `/session snooze`. Extracted rather than duplicated so there is exactly one answer to "what does `1.5h` mean" and exactly one cap (`MAX_SNOOZE_MS`, 30 days — beyond that it is almost certainly a typo, and no scheduler exists that could walk the deadline back). Grammar: an integer or one-decimal amount plus a unit suffix (`30m`, `1h`, `1.5h`, `4h`, `1d`, `1w`); a bare number reads as minutes. It returns a result union (`{ ok: true, ms }` \| `{ ok: false, code: "invalid" \| "too-short" \| "too-long", message }`) instead of throwing, so each surface dresses the failure in its own voice: `cli.ts` re-throws a `CliUsageError` with the flag-worded `message`, while the TUI switches on `code` to write terminal copy that never mentions a flag the user did not type. | | `apps/ade-cli/src/tuiClient/adeApi.ts` | Typed wrappers over the runtime action domains used by the Ink root, including the session lifecycle calls `snoozeSession`, `wakeSession`, `setSessionSettleOverride`, and `clearSessionWokeMarker` (all mapping onto the `session` action domain) and the `TuiSessionLifecycleFields` type. `enrichChatSessionsWithLifecycle` / `enrichTerminalSessionsWithLifecycle` carry `settleOverride`, `snoozedUntil`, `snoozedAt`, `wokeAt`, and `wokeReason` onto enriched rows so the drawer and right pane render markers without a second read. Chat hydration requests a 1,000-event / 256 KiB recent window and exposes the append-stable `getChatEventHistoryPage` byte-cursor wrapper for older pages. Both history calls use the runtime's canonical single object envelope instead of positional options that one-argument runtime wrappers would discard. | | `apps/ade-cli/src/tuiClient/olderHistory.ts` | Bounded transcript-window policy for ADE Code. It initially paints the newest 500 snapshot events, drains the contiguous local remainder before network paging, dedupes page seams, and keeps at most 60,000 resident events. At the cap, scrollback becomes a sliding window that retains the newly requested older side and marks the view detached; `End` rehydrates the authoritative recent tail and folds in buffered live events. The cursor stays retryable on `unavailable`, and the underfill/near-top policy triggers loading without requiring an extra scroll event. | @@ -244,13 +244,13 @@ Right pane (open contextual content): | `/chat delete` | Delete the active chat after confirmation. | | `/chat ask ` | Escalate a blocking question from the active chat or tracked CLI session; creates the loud `Needs you` state. | | `/chat note [note]` | Update the active session's Work status line; omit the note to clear it. | -| `/chat settle [outcome]` | Mark the active session settled; if it is awaiting input or explicitly requesting attention, dismiss that pending input in the same backend transaction. An outcome becomes the `done:` status line. | +| `/chat settle [outcome]` | Mark the active session settled; if it is awaiting input or explicitly requesting attention, dismiss that pending input in the same backend transaction. An outcome becomes the `done:` status line. `ade code` is a *user* surface (it connects at cto role), which is why it keeps settle even though the agent-facing `ade chat settle` was removed in 2026-07. | | `/chat unsettle` | Remove the active session's settled state. | | `/session snooze [session-id] [30m\|1h\|4h\|1d]` | Snooze a session out of the attention list until a deadline. Omitting the duration opens the choice list; free text goes through the shared duration grammar (`30m`, `1h`, `1.5h`, `1d`, `1w`; a bare number means minutes), capped at 30 days. | | `/session wake [session-id]` | Wake a snoozed session back into the attention list. | | `/session settle [session-id] [outcome]` | Mark a session settled, declaring the settle at the override tier. | -| `/session unsettle [session-id]` | Return a session to the derived lifecycle rules. | -| `/session keep-active [session-id]` | Pin a session active so it never settles on its own. This is the only way to un-settle a clean-exit row, which derives its settle and has no timestamp for unsettle to clear. | +| `/session unsettle [session-id]` | Clear a session's declared settle plus any `settled` pin. | +| `/session keep-active [session-id]` | Write the `active` settle-override pin, holding the row in the active list even if something later declares a settle on it (e.g. the PR-merge policy). Note that nothing *derives* a settle: a clean CLI exit leaves the row `ended`, never `settled`. | | `/attention` | Open account-wide Attention in the right pane. Signed-out or degraded mode is labeled as connected-machine-only; `Enter` opens the exact destination and `R` refreshes. | | `/tag ` | Tag the active Claude chat (Claude only). | | `/output-style [style]` | List or select the active Claude output style (Claude only). | diff --git a/docs/features/agents/README.md b/docs/features/agents/README.md index 0041a1b0f..23c1efb4e 100644 --- a/docs/features/agents/README.md +++ b/docs/features/agents/README.md @@ -12,7 +12,7 @@ The former worker/hiring agents were removed. There is one persistent identity | `apps/desktop/src/main/services/cto/ctoMemoryService.ts` | The CTO's smart-memory file store (`MEMORY.md`, `thread-state.md`, daily logs, search, injection sections). | | `apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts` | CTO operator tools for chat spawning, lanes/PRs/git/tests, Linear reads/writes, and the `saveMemory` / `searchMemory` / `readMemory` memory tools. | | `apps/desktop/src/main/services/agentTools/agentToolsService.ts` | Detects external CLI tools on PATH. | -| `apps/ade-cli/src/cli.ts` | Agent-focused `ade` command surface and text/JSON output formatters. Includes the `ade ios-sim` (alias `ade ios`, `ade simulator`) family — see [iOS Simulator feature](../ios-simulator/README.md), the `ade --socket app-control ...` driver for live Electron apps, and the `ade --socket browser ...` driver for the in-app browser. `ade secrets list|get|set|delete` is the typed surface for encrypted project-scoped ADE secrets that agents may read when the user names a secret. `ade new chat --mode chat|cli --lane --provider codex --model --reasoning-effort --no-fast --permissions full-auto --type --prompt "..."` mirrors the desktop New Chat toggle. Both chat and agent-provider CLI modes default `orchestrationParentSessionId` from `ADE_CHAT_SESSION_ID` (`--parent ` overrides, `--no-parent` opts out) and accept `--type` / `--spawn-type`; chat mode stores the fields on the child chat and applies its completion-report policy, while CLI mode sends them as tracked-session lineage without claiming attached-terminal ownership. Plain shell launches omit lineage. See [Chat › Spawn types and completion reporting](../chat/README.md#spawn-types-and-completion-reporting). `ade chat read --text` reads recent transcript messages. The agent-writable Work status surface is `ade chat note ""`, `ade chat ask ""`, `ade chat settle [--outcome ""]`, and `ade chat unsettle`. Each accepts `--session `; injected SDK guidance supplies the concrete current id and tells the agent to pass it explicitly, while the RPC boundary can still bind an omitted id to a session-bound caller and rejects cross-session writes. `ade chat scheduled-work create --in 12m --prompt ""` creates a relative one-shot; `--at ` creates an absolute one-shot; `--cron "" [--once]` creates a brain-local cron. Creation text output reports the brain timezone and both local/ISO next-run values; list/cancel use the same runtime management store and cancellation path as desktop Settings. `ade chat ... --personal` lists, creates, reads, sends to, interrupts, archives, and deletes machine-owned projectless chats through the running brain. `ade lanes link-linear-issue --linear-issue-json '{...}'` (aliases `link-linear`, `linear-link`) links Linear issues to an existing lane. | +| `apps/ade-cli/src/cli.ts` | Agent-focused `ade` command surface and text/JSON output formatters. Includes the `ade ios-sim` (alias `ade ios`, `ade simulator`) family — see [iOS Simulator feature](../ios-simulator/README.md), the `ade --socket app-control ...` driver for live Electron apps, and the `ade --socket browser ...` driver for the in-app browser. `ade secrets list|get|set|delete` is the typed surface for encrypted project-scoped ADE secrets that agents may read when the user names a secret. `ade new chat --mode chat|cli --lane --provider codex --model --reasoning-effort --no-fast --permissions full-auto --type --prompt "..."` mirrors the desktop New Chat toggle. Both chat and agent-provider CLI modes default `orchestrationParentSessionId` from `ADE_CHAT_SESSION_ID` (`--parent ` overrides, `--no-parent` opts out) and accept `--type` / `--spawn-type`; chat mode stores the fields on the child chat and applies its completion-report policy, while CLI mode sends them as tracked-session lineage without claiming attached-terminal ownership. Plain shell launches omit lineage. See [Chat › Spawn types and completion reporting](../chat/README.md#spawn-types-and-completion-reporting). `ade chat read --text` reads recent transcript messages. The agent-writable Work status surface is `ade chat note ""` and `ade chat ask ""` (settling was removed — see below). Each accepts `--session `; injected SDK guidance supplies the concrete current id and tells the agent to pass it explicitly, while the RPC boundary can still bind an omitted id to a session-bound caller and rejects cross-session writes. `ade chat scheduled-work create --in 12m --prompt ""` creates a relative one-shot; `--at ` creates an absolute one-shot; `--cron "" [--once]` creates a brain-local cron. Creation text output reports the brain timezone and both local/ISO next-run values; list/cancel use the same runtime management store and cancellation path as desktop Settings. `ade chat ... --personal` lists, creates, reads, sends to, interrupts, archives, and deletes machine-owned projectless chats through the running brain. `ade lanes link-linear-issue --linear-issue-json '{...}'` (aliases `link-linear`, `linear-link`) links Linear issues to an existing lane. | | `apps/ade-cli/src/services/account/accountAuthService.ts` | Optional ADE account auth for humans, remote agents, and CI: loopback OAuth, account-directory device authorization, shared `account.session.v1` refresh storage, JWT-`exp`-authoritative access-token refresh, one cross-process refresh-rotation recovery attempt after `invalid_grant`, and ephemeral `ADE_ACCOUNT_TOKEN` credentials. | | `apps/ade-cli/src/adeRpcServer.ts`, `apps/ade-cli/src/multiProjectRpcServer.ts`, `apps/ade-cli/src/runtimeRoles.ts` | Private ADE action RPC and caller-role boundary. A runtime default role is a ceiling, not an identity grant: `resolveSessionBoundRole` clamps any chat-bound caller that would otherwise inherit a daemon-wide `cto` role to `agent`, preserves an explicitly declared `orchestrator`, and never lets a client request above the runtime ceiling. The servers apply that identity to action filtering and machine-level CTO-only calls. `adeRpcServer` also builds lane-scoped ADE guidance / `ADE_AGENT_SKILLS_DIRS` for CLI launches, injects an explicitly enabled and verified direct Computer Use MCP client into tracked Codex launches, and returns GitHub + ADE PR URLs from PR creation tools when available. `start_cli_session` validates optional `orchestrationParentSessionId` / `spawnKind`, stores them in agent-provider `TerminalResumeMetadata`, and leaves `chatSessionId` for true attached-terminal ownership. A trusted caller carrying a chat-session binding may read or mutate only its own chat/terminal/session through scoped actions; omitted self-targets are injected from the binding, while an explicit peer target is denied. Human/dev CLI callers without a session binding keep the documented project-wide read surface. Cross-session delivery remains intentionally available only through the reviewed `chat.messageSession` route. Scope failures are authorization failures, not missing host capabilities: the server returns JSON-RPC `policyDenied` with `data.kind = "session_scope_denied"` plus `method`, `callerSessionId`, `requestedSessionId`, and `alternativeAction` (when one exists), and `ade/actions/call` preserves that data through the tool envelope. The human message must not reuse `Unsupported ... method`, because clients reserve that wording for old-host compatibility fallback. | | `apps/desktop/src/main/services/builtInBrowser/builtInBrowserActorCapabilities.ts`, `desktopBridgeServer.ts`; `apps/ade-cli/src/services/builtInBrowser/desktopBridgeClient.ts` | Browser-automation security boundary. ADE issues an opaque in-memory capability for each chat-owned agent/terminal; the runtime strips caller routing and carries the token over a separately authenticated bridge, then Electron validates it in the issuing process and restores only its bound browser scope. | @@ -71,14 +71,22 @@ into the Work list: - `ade chat ask "Which account should I use?"` creates a loud, persisted `Needs you` state, clears settle, and sends a time-sensitive push. The next user turn clears the ask. -- `ade chat settle --outcome "opened PR #841, CI green"` moves the resting row - into Settled and uses the outcome as its `done:` status line. -- `ade chat unsettle` returns the row to the active lifecycle. - -`buildAdeCliGuidance` exposes these commands in the injected agent prompt so the -state change is an explicit agent decision, not a transcript-text or terminal -marker heuristic. Settlement means the original objective is fully delivered -and verified; a completed turn or clean CLI exit alone remains active/ended. +- `ade session snooze --for 1h` quiets a row the agent is waiting on + without claiming the work is done; a hand-raise wakes it early. + +Agents deliberately **cannot settle**. `ade chat settle`, `ade chat unsettle`, +`ade session settle`, and `ade session unsettle` were removed in 2026-07: +whether work is actually finished is a subjective judgment agents are +unreliable at, and a chat that settles itself disappears from the user's active +list on the agent's say-so. Invoking a removed command exits 2 with a message +naming the two remaining paths — the user settling the row in ADE, and the +deterministic `autoSettleLaneSessionsOnPrMerge` policy. Nothing derives a +settle either: a completed turn or a clean CLI exit leaves the row +active/ended, never settled. + +`buildAdeBootstrapGuidance` exposes the surviving commands in the injected +agent prompt (`ADE_SESSION_STATUS_PROTOCOL_GUIDANCE`), including the explicit +"you cannot settle or unsettle a session" line. SDK-backed Claude, Codex, Cursor, Droid, and OpenCode chats receive `ADE_CHAT_SESSION_ID` plus `ADE_DEFAULT_ROLE=agent` (or `orchestrator` for an orchestration lead), and their persistent guidance names the concrete diff --git a/docs/features/cto/README.md b/docs/features/cto/README.md index 12b8bf562..67930b8a0 100644 --- a/docs/features/cto/README.md +++ b/docs/features/cto/README.md @@ -123,8 +123,8 @@ The CTO reaches both through `ctoOperatorTools.ts`: | Tool | Purpose | | --- | --- | | `getSessionLifecycle` | Read the settle/snooze slice for any ADE session (chat or tracked CLI). | -| `settleSession` / `unsettleSession` | Declare a session done (optionally with a one-line `outcome`) or return it to the active lifecycle. | -| `setSessionSettleOverride` | Pin the tri-state override; `"active"` is what forces a derived clean-exit settle back to active. | +| `settleSession` / `unsettleSession` | Declare a session done (optionally with a one-line `outcome`) or return it to the active lifecycle. These write `settle_source = "operator"`. The CTO is the one agent that retains this, as a user-configured operator acting on *other* rows; ordinary worker agents lost self-settlement in 2026-07 (see [terminals-and-sessions](../terminals-and-sessions/README.md)). | +| `setSessionSettleOverride` | Pin the tri-state override; `"active"` is the keep-active pin that suppresses a declared settle. | | `snoozeSession` | Hide a row until a deadline (`untilIso` or `durationMinutes`). The session keeps running. | | `wakeSession` | "Clear a snooze on an ADE session so it resurfaces now. No-op when the session was not snoozed." Records `wokeReason` (default `manual`). | diff --git a/docs/features/lanes/README.md b/docs/features/lanes/README.md index 15ac5b14c..371acfabe 100644 --- a/docs/features/lanes/README.md +++ b/docs/features/lanes/README.md @@ -87,7 +87,7 @@ Renderer components: | `renderer/components/lanes/laneColorPalette.ts` | Curated lane color palette split into `LANE_CLASSIC_COLORS` and `LANE_RAINBOW_COLORS`, then combined as `LANE_COLOR_PALETTE`, plus helpers (`getLaneAccent`, `colorsInUse`, `nextAvailableColor`, `laneColorName`). The first 8 classic hexes form `LANE_FALLBACK_COLORS`, the legacy index-based fallback used for lanes that don't have an explicit color assigned. | | `renderer/components/lanes/LaneAccentDot.tsx` | Tiny accent dot used everywhere a lane is mentioned (lane list, tabs, PR rows, AppShell PR toasts). Resolves color via `getLaneAccent` so a lane without an explicit color falls back to a deterministic fallback hex. | | `renderer/components/lanes/LaneColorPicker.tsx` | Reusable grouped swatch picker used inside `CreateLaneDialog` and `ManageLaneDialog`. Shows Rainbow above Classic, disables swatches already in use by other lanes (passed in as `usedColors`), and offers a clear button. | -| `renderer/components/lanes/LaneContextMenu.tsx` | Right-click menu on the lane list. Hosts the inline grouped color swatches that call `lanes.updateAppearance` directly, path/link/web actions, start-chat, manage/adopt, split-tab actions, and batch manage. Work callers may additionally pass their own Work-sidebar pin toggle and pin ids; that optional action is deliberately separate from the Lanes tab's pins. | +| `renderer/components/lanes/LaneContextMenu.tsx`, `laneContextMenuItems.tsx` | Right-click menu on the lane list. `buildLaneMenuGroups` is the single action inventory shared with the Work sidebar's singleton-session **Lane** submenu; both render the same start-chat/pin, navigation, copy, split, appearance, and manage/adopt/batch groups. Copy, split, and appearance open as pointer-safe keyboard submenus; color swatches call `lanes.updateAppearance` directly. Work callers may pass their own Work-sidebar pin toggle and pin ids, deliberately separate from the Lanes tab's pins. | | `renderer/components/lanes/LaneBranchDrift.tsx` | Branch-drift renderer surface. `useLaneBranchDrift(laneId)` reads `branchDrift` straight off the lane in the app store, so it costs nothing and stays exactly as fresh as the rest of the lane's git state. `LaneBranchDriftChip` is the compact always-visible chip that `WorkSurfaceHeader` renders next to the lane chip while a lane is drifted. `LaneBranchDriftStrip` is the fuller warning strip, shown only once something is about to act on the branch; `armLaneBranchDriftWarning(laneId)` is the imperative arming call, backed by a module-level armed-lane set plus a `useSyncExternalStore` subscription. Arm sites are `AgentChatPane.submit` and `ChatGitToolbar`'s PR button / `handlePr`; the strip itself renders above the composer in `AgentChatPane`. See [Branch drift](#branch-drift). | | `renderer/components/lanes/LaneStackPane.tsx` | Stack graph sidebar, integration source chips, canvas jump | | `renderer/components/lanes/LaneDiffPane.tsx` | Lane diff list + per-file stage/unstage/discard; file content uses shared `AdeDiffViewer` (commit comparisons read-only; working-tree file can be editable when unstaged) | diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index 6f3650403..51e3e3807 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -1225,6 +1225,12 @@ same priority model with compact count/status treatments. The iOS app still updates the shared snapshot and calls `WidgetCenter.shared.reloadAllTimelines()` after snapshot writes. +Agent rows mirror desktop's shared status vocabulary: blue `Working`, amber +`Needs you`, emerald `Done`, red `Failed`, and neutral `Stale`. Amber is +reserved for the one state asking the user to act. Syncing, offline hosts, +blocked work, and a live-but-silent stale run remain neutral; stale uses a +clock rather than a network-offline glyph. + Home Screen widgets and Control Center widgets are intentionally not registered. ActivityKit and Dynamic Island **are** now registered — see the Live Activity section below. @@ -1358,6 +1364,11 @@ Each row uses the shared item destination and actions: - **CI failing / review requested / merge ready** — exact PR tab navigation. - **Completed / merged** — retained in Recent until seen or dismissed. +The drawer uses the same one-hue-one-meaning contract as the widget and desktop +sidebar. `blocked` is a neutral Live item with an Open action, distinct from the +amber `awaitingInput` kind; running uses the shared dotted-circle glyph, and a +stale run says `Stale` with a clock rather than claiming the host is offline. + Acknowledgments write through the account relay so desktop, ADE Notch, and mobile settle together. A device never executes a current-host App Intent for an item that originated on another machine; those items expose exact Open or @@ -1598,6 +1609,9 @@ desktop: deterministic approval/question/`ade chat ask` is `Needs you`; an explicit settle applies only while the session is at rest; failed and stopped remain distinct; a clean process exit is merely ended; and a running row with a real activity timestamp at least three hours old is `Stale`, not settled. +`AgentRunPhase` mirrors `shared/sessionStatusPresentation.ts`: work in flight is +blue `Working`, only a raised hand is amber, a clean unseen outcome is emerald +`Done`, failure is red, and stale/non-actionable truth is neutral. The replicated `terminal_sessions` lifecycle columns flow through `Database.swift`, the active-project session summaries, and diff --git a/docs/features/terminals-and-sessions/README.md b/docs/features/terminals-and-sessions/README.md index 38f82cb91..549b7fd1f 100644 --- a/docs/features/terminals-and-sessions/README.md +++ b/docs/features/terminals-and-sessions/README.md @@ -243,19 +243,41 @@ Shared types and IPC: input so a typo can never be mistaken for "clear"; `"clear"` / `"none"` / `""` / null are the explicit clear sentinels, and clients that cannot encode a JSON null (iOS) send the string. +- `apps/desktop/src/shared/sessionStatusPresentation.ts` — the shared + phase-to-label/glyph/tone/prominence vocabulary consumed by the Work sidebar + and Attention Center and mirrored by iOS widgets/Attention Drawer. Blue means + work in flight, amber is reserved exclusively for `Needs you`, emerald is a + clean unseen outcome, red is failure, and neutral is true but non-actionable. + It also owns the short working-duration formatter; renderer icon components + map its dependency-free glyph ids to platform symbols. - `apps/desktop/src/renderer/lib/sessionSnooze.ts` — the desktop half of snooze presentation. The derivations themselves live in `shared/sessionCanonicalState.ts` and are shared with `ade code` and iOS; this module owns the re-export plus the client-side deadline math behind the duration menu. - `SNOOZE_DURATION_OPTIONS` is fixed-order, shortest window first: `1 hour`, - `Until this evening` (local 18:00), `Until tomorrow 9am` (local 09:00), and + `SNOOZE_DURATION_OPTIONS` is the fixed-order static superset, shortest window + first: `In 1 hour`, `This evening` (local 18:00), `Tomorrow` (local 09:00), + `Next week` (next Monday 09:00, a full week out when today is Monday), and `Until I'm asked`. The last one has no clock deadline, so it parks the row ~100 years out — only a hand-raise (needs-you / error / turn complete) brings it back, and any deadline past a year reads as open-ended rather than a - countdown. `snoozeDeadlineIso`, `snoozeConfirmationLabel`, `snoozeWakeLabel` + countdown. Labels name the DAY, never the clock time, because every menu + renders a separate time column beside them. + `resolveSnoozePresets(nowMs)` is what the menus actually render: it resolves + each row's `whenLabel` (the time column: "9:00 AM", "Mon 9:00 AM", "on a + hand-raise") and `untilIso` against local time, and drops `This evening` once + 18:00 is within an hour (it would duplicate `In 1 hour`) or already past (the + row would silently mean *tomorrow* evening). `Until I'm asked` is never + suppressed and always sorts last. Callers must not cache the result at module + load — a long-lived process would freeze the suppression decision at startup. + Day arithmetic advances by calendar day, never by `+24h` in milliseconds: a + spring-forward day is 23 hours, so a fixed offset from 23:30 skips the whole + next day. + `snoozeDeadlineIso`, `snoozeConfirmationLabel`, `snoozeWakeLabel` ("wakes in 3h" / "wakes tomorrow" / "wakes when asked" / "wakes now"), - `sessionWokeMarker`, and `nextSnoozeDeadlineMs` are the exported helpers; - nothing here reads or writes a canonical phase. + `snoozeWakeDescription` (the absolute wake time for menus and toasts — + "9:00 AM" / "tomorrow 9:00 AM" / "Mon 9:00 AM" / "Apr 20, 9:00 AM" / + "when you're asked"), `sessionWokeMarker`, and `nextSnoozeDeadlineMs` are the + 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 @@ -266,13 +288,23 @@ Shared types and IPC: no-ops. Exports `snoozeSessionForDuration`, `wakeSessionNow`, `setSessionSettleOverride`, and `clearSessionWokeMarker`. - `apps/desktop/src/renderer/components/terminals/SessionSnoozeControl.tsx` — - hover-revealed snooze affordance on a session row, kept out of `SessionCard`'s - render body so the hot Work list pays only for a single always-mounted button - (revealed with CSS, not a hover state that would re-render the row) plus menu - state that exists only after a click. The duration menu is a locally-owned - fixed popover clamped to the viewport like `SessionContextMenu`, with no - document-level listener. Already-snoozed rows offer **Wake now** instead of - the duration list. + snooze affordance mounted in `SessionStatusSlot`'s action cluster. The hot + Work list pays only for a single always-mounted button plus menu state that + exists after a click. The duration menu is a locally-owned fixed popover + clamped to the viewport like `SessionContextMenu`, with no document-level + listener. Already-snoozed rows offer **Wake now** instead of the duration + list. +- `apps/desktop/src/renderer/components/terminals/SessionStatusSlot.tsx` — + the row's single status surface and no-layout-shift hover/focus action swap. + It maps shared presentation glyph ids to Phosphor icons, ticks Working/Stale + elapsed time, and replaces the status label with snooze plus binding-aware + settle/un-settle controls while the row is hovered or keyboard-focused. +- `apps/desktop/src/renderer/components/terminals/SessionHoverCard.tsx` — + one-second hover-intent detail pane positioned to the right of the full-bleed + source row. Direct row-to-row movement after a card opens hands off + immediately; any other entry earns a fresh delay. It renders icon-led facts, + 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 @@ -636,35 +668,18 @@ Renderer surfaces: landing after a project-tab switch cannot suppress the new scope's first lane read. - `apps/desktop/src/renderer/components/terminals/SessionCard.tsx` — - per-session card (status dot, title, preview line, tool type, lane, - delta chips). Any session with `orchestrationParentSessionId` renders a - small deterministic lineage identicon immediately left of the status dot; - its tooltip names the parent when available, and clicking it opens the - parent without selecting the child card. Chat summaries with a future - `nextWakeAt` render a compact - `⏰ ` chip that refreshes while the row is mounted; paused or - overdue schedules omit the chip because their session summary reports no - future wake. The canonical phase drives a filled status dot or the hollow - settled ring plus one-word attention capsules: amber `Needs you`, red - `Failed`, and an outlined muted `Stale` after three hours without activity. - The preview line prefers an escalated `ade chat ask` question, then - `statusNote` (prefixed `done:` when settled), then a sanitized 120-character - `lastOutputPreview`, then summary, then goal. Terminal output fallback strips - ANSI/control sequences, collapses whitespace, and stays plain text rather - than linkifying output controlled by a subprocess. Long-running - non-chat CLI/shell rows can additionally show the separate 24-hour process - cleanup warning pip described below. The - A row that woke early shows a compact woke marker naming the reason until it - is opened, and mounts `SessionSnoozeControl` for the hover-revealed - snooze/wake affordance. The - card also reports its multi-select state via `isMultiSelected`. While - the card's lane is mid background AI auto-naming it swaps the preview - line for an "Auto-naming lane underway…" status and warm-highlights the - title when it changes (subscribes to `laneNamingStore.ts`). Sessions - carrying `importedFrom` provenance render an `Imported` badge. The optional - `disabledReason` blocks selection, dragging, and the context menu while - rendering a centered progress overlay; Work uses it for sessions whose lane - is being deleted. + full-bleed three-line Work row. Line one adapts machine, pin, singleton lane, + spawn lineage, drifted branch, singleton PR, diff, and last-activity identity + around `SessionStatusSlot`; line two is the title; line three keeps the + sanitized preview, Claude TTL, failure exit code, and provider mark. Grouped + lane headers own repeated machine/PR identity, while a lane with exactly one + session has no redundant header and promotes the lane identity onto the card. + After one second `SessionHoverCard` carries the lower-frequency metadata + removed from the row (including clickable PR and parent-thread facts). The + title still warm-highlights when background AI naming lands, and + `disabledReason` blocks selection, dragging, and the context menu during lane + deletion. Selection/hover use the row background; non-prominent lifecycle + states recede instead of spending lane-tinted card surfaces. - `apps/desktop/src/renderer/state/laneNamingStore.ts` — ephemeral, renderer-only zustand store tracking which lanes have an AI auto-naming pass in flight. `setLaneNaming(laneId, on)` is the @@ -951,18 +966,19 @@ Renderer surfaces: the new viewport, and restore focus/scroll without waiting for a resize event that will never come. - `apps/desktop/src/renderer/components/terminals/SessionContextMenu.tsx` - and `SessionInfoPopover.tsx` — right-click actions and info overlay. - Ended chat sessions get an additional "Delete chat" action wired to - `ade.agentChat.delete`. Fixed-position context menus measure their - rendered size and clamp to the renderer viewport before opening near - a pointer edge. The menu carries one exhaustive lifecycle block holding every - action that changes where the sidebar files the row: **Snooze…** expands into - the four durations (or **Wake now**, with the wake label, when the row is - already snoozed), and settle actions operate only on explicit declarations. - A declared settle has a `settled_at` for Unsettle to clear, while - `settleOverride` can explicitly pin either state. A row - reaching the end of that block with nothing rendered would be a row the user - cannot un-hide, which is why the block is kept exhaustive. + and `SessionInfoPopover.tsx` — grouped right-click actions and explicit info + overlay. The context menu sections identity, Lifecycle, Go to, Copy, optional + singleton-lane actions, and fenced destructive rows; Copy, Snooze, and Lane + are pointer/keyboard submenus. Ended chat sessions get Delete chat wired to + `ade.agentChat.delete`. Fixed-position menus measure and clamp to the renderer + viewport. +- `apps/desktop/src/renderer/components/terminals/LanePrBadge.tsx`, + `LaneActionsSubmenu.tsx`, and + `apps/desktop/src/renderer/components/ui/MenuSubmenu.tsx` — shared compact PR + state/deep-link badge, the singleton session row's lane submenu, and the + pointer-safe/keyboard-accessible submenu primitive. `LaneActionsSubmenu` + renders the same `buildLaneMenuGroups()` definitions as the lane divider's + context menu, so the two surfaces cannot drift. - `apps/desktop/src/renderer/lib/sessionListCache.ts` — shared renderer cache for `ade.sessions.list` calls, keyed by `projectRoot/laneId/status`. Ordinary callers coalesce compatible in-flight reads; forced reads bypass @@ -1224,8 +1240,11 @@ does not fail on desktop; it surfaces as changeset-apply errors on the phone. a still-running session with no activity for three hours is `stale` but is not settled. Chat idle between turns is the quiet `ready` phase. Explicit Settle/Unsettle is available from the session context menu and multi-select - footer. `ade chat settle --outcome ...` also stores the outcome as - `statusNote`. A `needs_you` row becomes **Dismiss & settle**. For an SDK + footer — and, since 2026-07, *only* from user surfaces plus the PR-merge + policy. `ade chat settle` / `ade session settle` were removed because + "is this work finished" is a subjective judgment agents are unreliable at, + and a self-settling chat drops out of the user's active list on the agent's + say-so. A `needs_you` row becomes **Dismiss & settle**. For an SDK chat, the service interrupts Claude/Codex/OpenCode/Cursor/Droid work, cancels live and restored waiters, removes Codex plan follow-ups, persists idle state, and only then settles. A tracked CLI may dismiss an explicit @@ -1249,9 +1268,10 @@ does not fail on desktop; it surfaces as changeset-apply errors on the phone. settle; `"active"` is the explicit keep-active pin; `null` returns the row to the declared rules. An explicit settle drops a stale `"active"` pin, and unsettle drops only a `"settled"` pin. `attention_source` and - `settle_source` record whether the current declaration came from an agent, - user, provider request, operator, or lane PR merge; Session Info displays - that provenance. + `settle_source` record where the current declaration came from. The + `settle_source` enum still carries `agent_explicit` for rows settled before + the 2026-07 removal of agent settlement; new settles are only `user`, + `operator` (CTO), or `pr_merge`. Session Info displays that provenance. 8. **Snooze and early wake** — snooze is a synced **visibility overlay**, never a lifecycle phase: `canonicalSessionState()` does not read it, only the @@ -1282,20 +1302,22 @@ does not fail on desktop; it surfaces as changeset-apply errors on the phone. Agent- and operator-reachable entry points: - CLI — `ade session snooze --for 1h|--until |--until-asked`, - `ade session wake [--reason ...]`, - `ade session settle [--outcome ...] [--keep-active]`, - `ade session unsettle [--keep-active]`, `ade session clear-woke `, + `ade session wake [--reason ...]`, `ade session clear-woke `, `ade session show --text`, and `ade session actions --text` for the raw action list. Each takes the id as a positional, accepts `--session `, and falls back to `$ADE_CHAT_SESSION_ID`, so a bound - agent can file itself. `--keep-active` on either settle or unsettle writes - the `"active"` override rather than touching `settled_at`. Duration grammar - and the 30-day cap come from `sessionSnoozeDuration.ts`, shared verbatim - with `ade code`'s `/session …` commands. - - Action registry (`session` domain) — `snoozeSession`, `snoozeSessions`, - `wakeSession`, `wakeSessions`, `setSettleOverride`, `clearWokeMarker`, - alongside the existing `settleSelfSession` / `unsettleSelfSession` / - `settleSessions` / `unsettleSessions`. + agent can file itself. Duration grammar and the 30-day cap come from + `sessionSnoozeDuration.ts`, shared verbatim with `ade code`'s + `/session …` commands. `ade session settle | unsettle | keep-active` and + `ade chat settle | unsettle` are **retired**: they exit 2 with a message + naming the two remaining paths (the user, and the PR-merge policy). + - Action registry (`session` domain) — agent-reachable: `snoozeSession`, + `snoozeSessions`, `wakeSession`, `wakeSessions`, `clearWokeMarker`. + CTO-only (i.e. reachable from the desktop renderer's remote-runtime client + and `ade code`, both of which authenticate at cto role, but refused for a + session-bound agent): `settleSession`, `unsettleSession`, `settleSessions`, + `unsettleSessions`, `setSettleOverride`. The caller-scoped + `settleSelfSession` / `unsettleSelfSession` pair no longer exists. - Sync (`session.*` remote commands) — the only path for mobile and the hosted web client, which have no local DB. Advertised in `hello_ok.features.commandRouting.actions`, and listed in diff --git a/docs/features/terminals-and-sessions/ui-surfaces.md b/docs/features/terminals-and-sessions/ui-surfaces.md index 9a290dc9b..94fa23c62 100644 --- a/docs/features/terminals-and-sessions/ui-surfaces.md +++ b/docs/features/terminals-and-sessions/ui-surfaces.md @@ -101,7 +101,7 @@ Lists sessions grouped by one of three modes (controlled by Each group uses a `StickyGroupHeader` with collapsed-state persistence via `workCollapsedLaneIds` / `workCollapsedSectionIds`. There are **two** quiet tiers, not one. Settled is a lifecycle phase; Snoozed sits one tier above it and -is a visibility overlay — a snoozed row keeps its own status dot and is simply +is a visibility overlay — a snoozed row keeps its canonical status and is simply filed elsewhere. Status and time views render one final section per tier, while lane view renders collapsible snoozed and settled tails inside each lane. Rows in either tail are always reachable; there is no separate visibility filter @@ -109,6 +109,23 @@ because Status grouping already exposes the complete lifecycle. Every global section and per-lane tail starts collapsed. User-expanded state is persisted, and collapsed tails do not contribute ids to shift-range selection. +Every disclosure has one visual shape and one toggle contract: clicking either +the label or far-right chevron toggles it, while the label remains the only +keyboard/assistive-technology stop. A collapsed header folds its count into the +label (`Lane name (3)`, `Settled (12)`); an expanded header drops the count +because its rows are visible. Fully quiet lanes and the global Snoozed/Settled +shelves are closed-by-default three-state disclosures: no marker means never +touched and closed, `shelf-open:*` / `lane-open:*` means explicitly opened, and +removing the marker closes them again. Notification deep links add the open +marker so a Settled destination cannot remain hidden. + +Lane groups with two or more sessions render an accent-coloured lane name, +optional machine/PR markers, then indent the cards beside a lane-tinted rail. +A lane with exactly one session suppresses the redundant divider and rail; the +full-width card carries lane identity and PR state instead, and its context +menu inherits the lane actions. The singleton/header transition participates +in the same layout animation as lane reordering. + Filing comes from `sessionFilingBucket`, 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 @@ -195,18 +212,18 @@ the handoff fails. Also renders: -- draft-kind switcher (chat vs terminal) at the top -- group selector (Lane / Status / Time) and lane filter (`LaneCombobox`) -- search input +- a 32 px toolbar with borderless Search, Filters, and New chat actions; Search + opens the command palette and displays the configured shortcut +- an expandable filter panel with group selector (Lane / Status / Time), lane + sort, status/tool chips, Has PR / Dirty, and `LaneCombobox` - the actual list of `SessionCard` rows (memoized) -- an "Open new" button that sets `draftKind` and routes to - `WorkStartSurface` -- an Add Lane button that opens `CreateLaneDialogHost` in-place. The +- a bottom **New lane** action that opens `CreateLaneDialogHost` in-place. The Work flow uses the host's `close-on-create` behavior: it closes as soon as the lane row is created, then runs lane environment setup detached from the sidebar component and leaves a sticky retry toast if setup fails. -- a bulk-action footer that appears when `selectedSessionIds` is +- a compact selection toolbar beneath the header that appears when + `selectedSessionIds` is non-empty: "Stop N" for running PTYs, "Settle N", "Delete N" for deletable ended rows, "Stop & delete N" when the selection includes a running runtime, and a clear-selection X. The footer @@ -226,76 +243,64 @@ a parent even when search, lane filtering, or a collapsed group hides its row. ### `SessionCard.tsx` -Three rows: - -1. **Status dot + title + relative time** — `sessionStatusDot()` and - `primarySessionLabel()` drive these. The relative time comes from - `relativeTimeCompact`. Disposed CLI rows render a small inline - "Stopped" label immediately before the red status dot rather than an - attention capsule. Settled rows use a hollow ring; every non-settled phase - uses the filled green/amber/red vocabulary. When - `orchestrationParentSessionId` is present, a small deterministic - `ChatSubagentGlyph` appears immediately left of the status dot. - `SessionListPane` supplies the parent title from its unfiltered session - index for the tooltip; clicking the glyph stops card selection and routes - to the parent through `navigateToSpawnedChat`. The glyph is a mouse - shortcut inside the existing card button; a spawned chat's **View parent - thread** header control remains the keyboard/assistive-technology route. -2. **Preview line** (conditional) — when the card's lane is mid +The full card is one full-bleed row with three lines: + +1. **Where + status** — an adaptive identity slot on the left and + `SessionStatusSlot` on the right. The identity slot can carry the owning + machine, a pin, the lane identity for a singleton lane, spawned-chat + lineage, a branch only when it differs from the lane's declared branch, and + the lane PR for a singleton. When none of those apply, session delta or + last-activity time is the floor, so the line never renders empty. A grouped + lane owns machine identity and PR state in its header; child rows do not + repeat them. Lane identity always uses the lane accent and `LaneIcon`; + branch identity is always muted and uses `BranchIcon`. +2. **Title** — `primarySessionLabel()` is the prominent element. When the + card's lane is mid background AI auto-naming (`useLaneNaming(lane.id)` from - `renderer/state/laneNamingStore.ts` is true), this row instead shows an - italic "Auto-naming lane underway…" status. Otherwise it shows + `renderer/state/laneNamingStore.ts` is true), a title change gets a short + lane-accent highlight. +3. **Preview + quiet metadata** — while auto-naming it shows + "Auto-naming lane underway…"; otherwise it shows an explicit `attentionMessage` first, then `statusNote` (`done: …` in the settled tier), then a sanitized `lastOutputPreview`, then `session.summary`, then `session.goal`. Output fallback is plain text (never linkified), capped at 120 characters, and strips ANSI/control sequences plus - repeated whitespace via `sanitizeTerminalInlineText`. The title (row 1) gets a brief warm - accent-tinted highlight whenever its displayed text changes — e.g. when - the deterministic/seed name is replaced by the background AI name — - skipped on first mount. -3. **Tool type + lane + badges** — `ToolLogo`, `shortToolTypeLabel`, - lane icon/name, `ClaudeCacheTtlBadge` (Claude chat only), delta chips - from `useSessionDelta`, exit code badge. - -Hover actions include the info button and `SessionSnoozeControl`, the snooze -affordance. It lives in its own component rather than inside the card body so -the hot Work list pays only for one always-mounted button revealed with CSS — -a hover *state* would re-render the row — plus menu state that exists only after -a click. Its duration menu is a locally-owned fixed popover clamped to the -viewport, with no document-level listener; an already-snoozed row offers **Wake -now** instead. A row whose snooze ended early shows a compact woke marker naming -the reason (`needs_you` / `error` / `turn_complete` / `timer` / `manual`) until -it is opened, at which point `TerminalsPage` clears the marker — opening *is* -the acknowledgement. Ended agent CLI sessions are -continued from the transcript surface by typing into the lightweight -composer; the card itself does not expose a separate continuation action. + repeated whitespace via `sanitizeTerminalInlineText`. The trailing cluster + is limited to Claude cache TTL, a non-stop exit code, and `ToolLogo`; delta + moved to line one so preview text owns the width it needs. + +`SessionStatusSlot` is the card's only permanent status vocabulary. It resolves +words, glyphs, tone, prominence, and elapsed-time behavior through +`shared/sessionStatusPresentation.ts`. Working and stale tick once per second +from last activity. On row hover or keyboard focus the status swaps, without +reflow, for `SessionSnoozeControl` and the context-appropriate Settle or +Un-settle action. An open snooze menu pins the action slot visible. A row whose +snooze ended early shows the shared Woke presentation until it is opened, at +which point `TerminalsPage` clears the marker — opening is the acknowledgement. + +After one second of uninterrupted hover, `SessionHoverCard` opens over the +content area to the right of the sidebar. It uses icon-led fact rows rather +than `Label: value` text and carries details intentionally removed from the hot +row: lane, machine, branch, provider, PR, parent thread, orchestration role, +spawn kind, live-child count, import provenance, grid membership, Claude tag, +next scheduled wake, non-zero exit, and the long-running process cleanup hint. +PR and parent-thread facts are clickable. Moving directly from a row whose card +is open to another session row opens the next card immediately; entering from +blank space, the content pane, or a row whose timer never fired starts a fresh +one-second delay. Pointer leave before the delay, list scroll, and resize cancel +the card. The portal is viewport-clamped and fades/slides right from the source +row, with motion disabled under reduced-motion preferences. When `disabledReason` is set, the card disables selection, dragging, and its context menu, lowers opacity, and renders a centered spinner/status overlay. This is used while the card's owning lane is being deleted. -The selected card adds a left accent border and elevated background. -Cards in the multi-selection set (`isMultiSelected`) reuse the same -accent and add a subtle ring so shift / meta click selection reads -clearly even when the primary single-selection points elsewhere. - -`sessionCapsuleBadge()` renders only three interrupt-relevant facts next to the -title: amber `Needs you`, red `Failed`, or outlined `Stale` with a clock. -Stale means a still-running session with a real `lastActivityAt` at least three -hours old; it remains in Running and is not automatically settled. Ready chats -and idle CLIs sit in Your move without a capsule. This is the two-tier -attention contract: the list keeps quiet resting work visible, while only -canonical `needs_you` contributes to the Work-tab highlight, notifications, and -Dock badge. `useAppWideSessionAttention` owns that count at `AppShell` rather -than inside Work, so Files, PRs, Lanes, and other project routes continue to -receive PTY/chat/session events. It cancels old refreshes on project switch and -clears the badge when the project closes. - -This lifecycle capsule is distinct from the amber process-cleanup warning pip. -`getStaleRunningCliSessionAgeHours(session)` flags non-chat, non-run-owned -CLI/shell processes only after 24 hours without output and powers both the -inline cleanup hint and the AppShell toast. It can coexist with the canonical -three-hour `Stale` capsule. +Selected and hovered rows spend the reserved background surface; multi-select +adds a subtle ring. Non-prominent states (working, starting, stale, stopped, +ended, snoozed, and settled) recede until hovered, while Needs you, Done, and +Failed keep full weight. Only canonical `needs_you` contributes to the Work-tab +highlight, notifications, and Dock badge. `useAppWideSessionAttention` owns +that count at `AppShell`, so it remains live outside Work. ## Work view: `WorkViewArea.tsx` @@ -741,11 +746,15 @@ Launch commands are built by `apps/desktop/src/shared/cliLaunch.ts`: ## Context menu: `SessionContextMenu.tsx` -Right-click menu with branches per session type: +The right-click menu uses one grouped, liquid-glass menu vocabulary: -- All rows: Rename (inline text input, sets `manuallyNamed: true`), Go to lane, - Copy session ID, and optional pin/deeplink/grid actions supplied by the - parent. +- The unlabelled identity block carries Rename (inline text input, + `manuallyNamed: true`), optional Set tag, pin, and grid removal. +- **Lifecycle** carries runtime stop, Snooze/Wake, and Settle/Un-settle. +- **Go to** carries lane and optional web navigation. +- **Copy** is a hover/keyboard submenu for the session ID and deep link. +- Destructive Stop & delete / Delete chat / Delete session actions are fenced + into the final red block. - Chat: Set tag… (running Claude only), Settle/Unsettle when at rest, **Dismiss & settle** for `Needs you`, and Delete chat. Dismissal routes through the backend settlement transaction; it interrupts the provider and @@ -757,10 +766,12 @@ Right-click menu with branches per session type: prompt shows the disabled **Resolve input to settle** row. Every row also carries one exhaustive lifecycle block holding each action that -changes where the sidebar files it. **Snooze…** expands in place into the four -durations (`1 hour`, `Until this evening`, `Until tomorrow 9am`, -`Until I'm asked`); an already-snoozed row instead shows **Wake now** with its -wake label. Settle actions operate on explicit declarations: a declared settle +changes where the sidebar files it. **Snooze…** expands in place into the +durations resolved by `resolveSnoozePresets` (`In 1 hour`, `This evening`, +`Tomorrow`, `Next week`, `Until I'm asked` — each with a time column beside it, +and `This evening` dropped once 18:00 is within an hour or past, so the row +count varies with the time of day); an already-snoozed row instead shows +**Wake now** with its wake label. Settle actions operate on explicit declarations: a declared settle has `settledAt` for **Unsettle** to clear and can additionally be pinned with **Keep active**, while `settleOverride` explicitly pins either state. The block is kept exhaustive on purpose: a @@ -770,6 +781,12 @@ un-hide. All writes go through five-second undo toast, so the row menu, the hover `SessionSnoozeControl`, and the chat header chips can never disagree about what an action does. +A singleton lane has no divider to right-click, so its session menu adds a +**Lane** hover submenu. `LaneActionsSubmenu` renders the exact +`buildLaneMenuGroups()` model used by `LaneContextMenu`; it does not transcribe +the actions. Menu subpanels share `MenuSubmenu`'s 180 ms open delay, 300 ms +pointer-safe close grace, viewport clamping, and keyboard navigation. + The rename input uses a local state and submits via `sessions.updateMeta({ title, manuallyNamed: true })`. Errors bubble up to `renameError` in `TerminalsPage`. @@ -955,10 +972,12 @@ nothing when no delta is available. `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. -- A derived settle has no `settledAt`, so plain Unsettle does nothing to it. Any - new lifecycle affordance must branch on declared vs derived or it will ship a - row the user cannot lift out of the quiet tier — the `"active"` keep-active - pin is the only unsettle those rows have. +- 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 + `"active"` keep-active pin exists to hold a row out of the quiet tier when + something later declares a settle on it (the PR-merge policy), not to rescue + an inferred one. - Refresh ordering for launches — use the synchronous `ptyCreate` / chat-create result to `openSessionTab` before the background forced refresh, then merge any stale persisted row with the optimistic row. diff --git a/docs/features/web-client/README.md b/docs/features/web-client/README.md index 64a4d8cf4..f9bf36aa1 100644 --- a/docs/features/web-client/README.md +++ b/docs/features/web-client/README.md @@ -333,7 +333,8 @@ Browser shell and routes: `data-ade-session-lifecycle="ready" | "unsupported"`. Under `@media (pointer: coarse)` the snooze button becomes a 2rem touch target with `opacity: 1`, the row's `pointer-events-none` action wrapper is re-enabled, and - the four snooze duration menu rows get a 44px minimum height. + the snooze duration menu rows get a 44px minimum height (the row count varies + with the time of day — see `resolveSnoozePresets`). - `apps/desktop/src/renderer/webclient/shell/webRoutes.ts` - thin web route layer over `apps/desktop/src/shared/deeplinks.ts`. From fd3266484eafd0f2a350b7f8adb186115dd17e96 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 30 Jul 2026 20:15:58 -0400 Subject: [PATCH 2/2] fix: address Work sidebar review findings --- apps/ade-cli/src/adeRpcServer.test.ts | 1 + apps/ade-cli/src/cli.test.ts | 4 ++ apps/ade-cli/src/cli.ts | 7 ++- apps/desktop/resources/ade-cli-help.txt | 60 +++++++++++++++---- .../main/services/lanes/laneService.test.ts | 16 +++++ .../src/main/services/lanes/laneService.ts | 9 ++- .../components/app/CommandPalette.test.tsx | 19 ++++++ .../components/app/commandPaletteThreads.tsx | 10 ++-- .../components/chat/AgentChatPane.tsx | 6 +- .../components/chat/ChatSurfaceShell.tsx | 4 +- .../settings/ChatAppearancePreview.tsx | 2 +- .../terminals/LaneActionsSubmenu.tsx | 1 + .../components/terminals/SessionCard.test.tsx | 25 ++++++-- .../components/terminals/SessionCard.tsx | 19 ++++-- .../components/terminals/SessionHoverCard.tsx | 8 ++- .../terminals/useWorkLaneContextMenu.tsx | 5 +- .../renderer/components/ui/MenuSubmenu.tsx | 6 ++ apps/desktop/src/shared/laneColorPalette.ts | 3 +- .../AttentionDrawerModel.swift | 5 +- .../ADETests/AttentionDrawerModelTests.swift | 28 +++++++++ .../ADEWidgets/ADEAgentActivityWidget.swift | 9 ++- 21 files changed, 202 insertions(+), 45 deletions(-) diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index b0acb2dcb..25f20a08c 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -1587,6 +1587,7 @@ describe("adeRpcServer", () => { { action: "settleSelfSession", args: { sessionId: "chat-1", outcome: "Shipped" } }, { action: "unsettleSelfSession", args: { sessionId: "chat-1" } }, { action: "settleSessions", args: { sessionIds: ["chat-1"] } }, + { action: "unsettleSessions", args: { sessionIds: ["chat-1"] } }, { action: "setSettleOverride", args: { sessionId: "chat-1", override: "settled" } }, ]) { const denialResult = await callTool(handler, "run_ade_action", settleAttempt); diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index dac133398..c0d82b06c 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -3131,6 +3131,10 @@ describe("ADE CLI", () => { expect(help.text).toContain("'chat settle' / 'chat unsettle' were removed"); expect(help.text).toContain("--session "); } + expect(() => buildCliPlan(["chat", "settle"])) + .toThrow(/only the user .* settles a session.*chat note/i); + expect(() => buildCliPlan(["chat", "unsettle"])) + .toThrow(/only the user .* settles a session.*chat note/i); }); it.each([ diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 6db9edb9a..5a55812e8 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -623,7 +623,7 @@ const TOP_LEVEL_HELP = `${ADE_BANNER} $ ade history list | show | commits | export Inspect ADE operation timeline and lane commits $ ade chat list | create | send | ask | note | interrupt Work with ADE agent chats - $ ade session show | snooze | wake | clear-woke File a session's lifecycle (snooze until a deadline) + $ ade session show | snooze | wake | clear-woke Manage a session's lifecycle (snooze until a deadline) $ ade linear attach | comment | set-state | issue | graphql Read and write attached Linear issues $ ade github app-auth login | status | clear Authorize the machine ADE GitHub App (device flow) @@ -6937,6 +6937,11 @@ function buildChatPlan(args: string[]): CliPlan { if (readFlag(args, ["--personal"])) { return buildPersonalChatPlan(sub, args); } + if (sub === "settle" || sub === "unsettle") { + throw new CliUsageError( + "'chat settle' / 'chat unsettle' were removed: only the user (or a merged PR) settles a session — report your outcome with 'chat note'.", + ); + } if (sub === "actions") return { kind: "execute", diff --git a/apps/desktop/resources/ade-cli-help.txt b/apps/desktop/resources/ade-cli-help.txt index a0a255179..1795ebb80 100644 --- a/apps/desktop/resources/ade-cli-help.txt +++ b/apps/desktop/resources/ade-cli-help.txt @@ -50,7 +50,7 @@ _ ____ _____ $ ade history list | show | commits | export Inspect ADE operation timeline and lane commits $ ade chat list | create | send | ask | note | interrupt Work with ADE agent chats - $ ade session show | snooze | wake | clear-woke File a session's lifecycle (snooze until a deadline) + $ ade session show | snooze | wake | clear-woke Manage a session's lifecycle (snooze until a deadline) $ ade linear attach | comment | set-state | issue | graphql Read and write attached Linear issues $ ade github app-auth login | status | clear Authorize the machine ADE GitHub App (device flow) @@ -187,7 +187,7 @@ _ ____ _____ $ ade history list | show | commits | export Inspect ADE operation timeline and lane commits $ ade chat list | create | send | ask | note | interrupt Work with ADE agent chats - $ ade session show | snooze | wake | clear-woke File a session's lifecycle (snooze until a deadline) + $ ade session show | snooze | wake | clear-woke Manage a session's lifecycle (snooze until a deadline) $ ade linear attach | comment | set-state | issue | graphql Read and write attached Linear issues $ ade github app-auth login | status | clear Authorize the machine ADE GitHub App (device flow) @@ -332,6 +332,11 @@ _ ____ _____ $ ade lanes list --text Show lane stack graph and branch names $ ade lanes show --text Inspect one lane status + $ ade lane drift --lane --text Check whether the worktree HEAD drifted off the lane's branch + $ ade lane drift resolve --lane --switch-back + Restore the worktree to the lane's recorded branch (refuses if dirty) + $ ade lane drift resolve --lane --keep-head + Re-point the lane (and its name) at the live HEAD branch $ ade lanes create --name Create a lane from the current project context $ ade lanes create --linear-issue-json '{...}' Create a lane linked to a Linear issue $ ade lanes link-linear-issue --linear-issue-json '{...}' @@ -350,7 +355,10 @@ _ ____ _____ Child lanes carry the parent's unmerged work $ ade lanes import --branch Register an existing branch/worktree $ ade lanes archive Archive a lane in ADE - $ ade lanes unarchive Restore an archived lane + $ ade lanes reclaim-preview Show reclaimable space and safety warnings + $ ade lanes archive-and-reclaim --confirm RECLAIM + Archive the lane, then remove its ADE-managed local files + $ ade lanes unarchive Restore an archived lane and recreate its worktree if needed $ ade lanes delete --force Delete a lane and clean up its worktree $ ade lanes attach --path --name Attach an external worktree $ ade lanes reparent --parent Move lane onto a new parent (runs git rebase) @@ -573,6 +581,12 @@ _ ____ _____ Chat commands use ADE agent chat sessions. Live provider-backed chat normally requires an attached runtime because it owns provider/session state. + Session scope: when you are an ADE-launched agent (ADE_CHAT_SESSION_ID is set), + reads are limited to your OWN chat — "ade chat read " is denied + by design, because transcripts are not shared across workers. Sending is not + restricted: "ade chat send " reaches any session, which is how a + subagent reports back to its spawner. + $ ade chat list --lane --text List chat sessions $ ade chat list --personal --text List machine personal chats (no project required) $ ade chat actions --personal --text List machine personal-chat actions @@ -633,7 +647,9 @@ _ ____ _____ $ ade chat detach-linear-issue [--issue-id ENG-431] Detach one issue (or all) from a session $ ade chat linear-issues --text List issues attached to a session - $ ade chat interrupt Stop an active turn + $ ade chat interrupt Stop an active turn and clear its queued messages + $ ade chat interrupt --keep-queue Stop the turn but preserve queued messages + $ ade chat restore-queue Restore a recently cleared queue during its undo window $ ade chat slash --text List slash commands for a session $ ade new chat --mode cli --lane --prompt "fix" Start a tracked provider CLI session @@ -746,7 +762,7 @@ _ ____ _____ $ ade history list | show | commits | export Inspect ADE operation timeline and lane commits $ ade chat list | create | send | ask | note | interrupt Work with ADE agent chats - $ ade session show | snooze | wake | clear-woke File a session's lifecycle (snooze until a deadline) + $ ade session show | snooze | wake | clear-woke Manage a session's lifecycle (snooze until a deadline) $ ade linear attach | comment | set-state | issue | graphql Read and write attached Linear issues $ ade github app-auth login | status | clear Authorize the machine ADE GitHub App (device flow) @@ -946,7 +962,7 @@ _ ____ _____ $ ade history list | show | commits | export Inspect ADE operation timeline and lane commits $ ade chat list | create | send | ask | note | interrupt Work with ADE agent chats - $ ade session show | snooze | wake | clear-woke File a session's lifecycle (snooze until a deadline) + $ ade session show | snooze | wake | clear-woke Manage a session's lifecycle (snooze until a deadline) $ ade linear attach | comment | set-state | issue | graphql Read and write attached Linear issues $ ade github app-auth login | status | clear Authorize the machine ADE GitHub App (device flow) @@ -1062,9 +1078,14 @@ _ ____ _____ $ ade proof list --text List captured artifacts $ ade proof capture --caption "Done" Capture a screenshot artifact $ ade proof attach /tmp/proof.png --caption "Done" Attach an existing image/video + $ ade proof rm artifact-id Delete stored proof and its record + $ ade proof broken --text List proof whose stored file is unavailable + $ ade proof recover artifact-id Re-import a broken proof from its surviving source + $ ade proof prune Preview broken proof records (does not delete) + $ ade proof prune --broken Delete every broken proof record $ ade proof record --seconds 20 Capture a short video proof $ ade proof launch --app "ADE" Launch an app for proof capture - $ ade proof ingest --input-json '{"artifacts":[]}' Ingest external visual proof artifacts + $ ade proof ingest --input-json '{"backendStyle":"external_cli","backendName":"agent-browser","inputs":[{"kind":"screenshot","path":"/tmp/proof.png"}]}' Ingest external visual proof artifacts ## ade ios-sim --help _ ____ _____ @@ -1361,7 +1382,7 @@ _ ____ _____ $ ade history list | show | commits | export Inspect ADE operation timeline and lane commits $ ade chat list | create | send | ask | note | interrupt Work with ADE agent chats - $ ade session show | snooze | wake | clear-woke File a session's lifecycle (snooze until a deadline) + $ ade session show | snooze | wake | clear-woke Manage a session's lifecycle (snooze until a deadline) $ ade linear attach | comment | set-state | issue | graphql Read and write attached Linear issues $ ade github app-auth login | status | clear Authorize the machine ADE GitHub App (device flow) @@ -1441,16 +1462,27 @@ _ ____ _____ the post-install notice. quitAndInstall relaunches the desktop app and only succeeds when status is "ready". - $ ade --socket update status --text Read AutoUpdateSnapshot (status, version, progress) + $ ade --socket update status --text Read AutoUpdateSnapshot (status, version, progress, last failed install) $ ade --socket update check --text Trigger a background update check $ ade --socket update install --text Refresh latest, then quit and install when ready $ ade --socket update dismiss --text Clear the recently-installed banner $ ade --socket update actions --text List callable update actions Snapshot status values: idle, checking, downloading, ready, installing, error. - "installing" appears between quitAndInstall and the desktop relaunch; if the - install fails, status falls back to error and the pending-install record is - cleared automatically. + "installing" appears between quitAndInstall and the desktop relaunch. That + window is deliberately long — the OS installer stages the new bundle in + process on macOS, so the app is only force-quit after a hard bound of several + minutes (about a minute on Windows/Linux, where staging is external). Do NOT + read a few slow minutes in "installing" as a hang, and do not kill the desktop + app to "unstick" it: that is exactly what makes an install fail to land. + + If quitAndInstall fails before the native handoff, status falls back to error + and the pending-install record is cleared. If the app quits but relaunches on + the OLD version, the install did not land: the next snapshot carries + "lastInstallFailed": { targetVersion, attempt }, which survives the restart. + Check that field before re-offering the same update — the first failure keeps + the downloaded archive so a retry is just another install, and only a second + failure discards the download and forces a fresh one. ## ade actions --help _ ____ _____ @@ -1474,6 +1506,10 @@ _ ____ _____ $ ade actions run pr.submitReview --args-list-json '["pr-1",{"event":"APPROVE"}]' $ ade actions list --text Domain-grouped action catalog $ ade actions list --domain git --text Narrow the catalog + $ ade --role cto actions list --domain attention --text + Discover account-wide Attention actions + $ ade --role cto actions run attention.getSnapshot --input-json '{"since":0}' --json + Read work across connected machines and projects $ ade actions run --input-json '{"key":"value"}' $ ade actions run --input-json '{"key":"value"}' $ ade actions status --text Runtime action availability diff --git a/apps/desktop/src/main/services/lanes/laneService.test.ts b/apps/desktop/src/main/services/lanes/laneService.test.ts index dfffb6996..578d11200 100644 --- a/apps/desktop/src/main/services/lanes/laneService.test.ts +++ b/apps/desktop/src/main/services/lanes/laneService.test.ts @@ -3087,6 +3087,22 @@ describe("laneService updateAppearance color uniqueness", () => { expect(child?.color).toBe("#60a5fa"); }); + it("keeps the primary lane on its reserved color during appearance updates", async () => { + const { db, service } = await setup(); + db.run( + "update lanes set lane_type = 'primary', color = ? where id = ?", + ["#a78bfa", "lane-parent"], + ); + + service.updateAppearance({ laneId: "lane-parent", color: "#60a5fa", icon: "star" }); + + const primary = db.get<{ color: string | null; icon: string | null }>( + "select color, icon from lanes where id = ?", + ["lane-parent"], + ); + expect(primary).toEqual({ color: "#a78bfa", icon: "star" }); + }); + it("ignores archived lanes when checking for color conflicts", async () => { const { db, service } = await setup(); db.run( diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index d2eef0fdc..57e74e871 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -5449,10 +5449,15 @@ export function createLaneService({ .map((entry) => entry.trim()) .filter(Boolean) .slice(0, 24); - const normalizedColor = color === undefined ? lane.color : color; + // Primary purple is an identity invariant, not a mutable preference. + // Normalize here at the write boundary so every caller and every surface + // sees the same reserved color. + const normalizedColor = lane.lane_type === "primary" + ? PRIMARY_LANE_COLOR + : color === undefined ? lane.color : color; const normalizedIcon = icon === undefined ? parseLaneIcon(lane.icon) : icon; - if (normalizedColor && normalizedColor !== lane.color) { + if (lane.lane_type !== "primary" && normalizedColor && normalizedColor !== lane.color) { const conflict = db.get<{ name: string }>( `select name from lanes where project_id = ? diff --git a/apps/desktop/src/renderer/components/app/CommandPalette.test.tsx b/apps/desktop/src/renderer/components/app/CommandPalette.test.tsx index e60cf06f9..6019b2ef3 100644 --- a/apps/desktop/src/renderer/components/app/CommandPalette.test.tsx +++ b/apps/desktop/src/renderer/components/app/CommandPalette.test.tsx @@ -838,6 +838,7 @@ describe("CommandPalette", () => { expect( screen.getByText(/Showing 8 of 12 threads/), ).toBeTruthy(); + expect(screen.getByText(/Showing 8 of 12 threads/).getAttribute("aria-hidden")).toBeNull(); // Newest first, oldest past the cap dropped. expect(screen.getByText("Thread 0")).toBeTruthy(); expect(screen.queryByText("Thread 8")).toBeNull(); @@ -894,6 +895,24 @@ describe("CommandPalette", () => { expect(screen.getByText("ADE · #t3code/release-notes")).toBeTruthy(); }); + it("labels a foreign thread with its bound project rather than the current project", async () => { + seedThreads([makeSession()], { + machines: { + "target-studio": makeForeignMachine({ + binding: { ...REMOTE_BINDING, displayName: "Remote Tools" }, + }), + }, + }); + + render( + + + , + ); + + expect(await screen.findByText("Remote Tools · #t3code/release-notes")).toBeTruthy(); + }); + it("matches a thread on its machine name", async () => { seedThreads([makeSession()], { machines: { "target-studio": makeForeignMachine() }, diff --git a/apps/desktop/src/renderer/components/app/commandPaletteThreads.tsx b/apps/desktop/src/renderer/components/app/commandPaletteThreads.tsx index afc7a92ee..8268207de 100644 --- a/apps/desktop/src/renderer/components/app/commandPaletteThreads.tsx +++ b/apps/desktop/src/renderer/components/app/commandPaletteThreads.tsx @@ -342,11 +342,12 @@ export const ThreadResultRow = React.memo(function ThreadResultRow({ // Secondary line: where the thread lives. Project first (it is the coarser // scope and the one the user names out loud), then branch, then the // current-thread marker. + const rowProjectName = entry.binding?.displayName ?? projectName; const contextParts: string[] = []; - if (projectName) contextParts.push(projectName); + if (rowProjectName) contextParts.push(rowProjectName); else if (entry.laneName) contextParts.push(entry.laneName); if (entry.branch) contextParts.push(`#${entry.branch}`); - else if (projectName && entry.laneName) contextParts.push(entry.laneName); + else if (rowProjectName && entry.laneName) contextParts.push(entry.laneName); return (
  • @@ -450,10 +451,7 @@ export function ThreadOverflowNote({ }) { if (total <= shown) return null; return ( -
  • +
  • Showing {shown} of {total} threads — keep typing to narrow.
  • ); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 0ae039271..d8ba994e7 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -11650,7 +11650,7 @@ export function AgentChatPane({ sessionsPaneCount={sessionsPaneCount} onToggleToolsPane={onToggleToolsPane} toolsPaneOpen={toolsPaneOpen} - className="space-y-0 p-0" + className="h-8 space-y-0 p-0" testId="work-chat-session-header" /> @@ -11786,8 +11786,8 @@ export function AgentChatPane({ // Settled/snoozed notice pinned above the composer. The header chips say WHAT // the state is; this says what sending will do about it, where the eye already // is while typing. Renders null for a live chat, so nothing below it moves. - const lifecycleBanner = selectedSessionId ? ( - + const lifecycleBanner = composerSessionId ? ( + ) : null; const composerMachineBinding = activeComposerRuntimeBinding; diff --git a/apps/desktop/src/renderer/components/chat/ChatSurfaceShell.tsx b/apps/desktop/src/renderer/components/chat/ChatSurfaceShell.tsx index 86f88fb1c..171ea09a8 100644 --- a/apps/desktop/src/renderer/components/chat/ChatSurfaceShell.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatSurfaceShell.tsx @@ -7,8 +7,8 @@ import { chatSurfaceVars } from "./chatSurfaceTheme"; export type ChatSurfaceShellLayoutVariant = "standard" | "mobile"; -/** Canonical 32px chat title rail, matched by CLI and the sessions toolbar. */ -export const CHAT_SHELL_HEADER_CLASS = "h-8 px-2"; +/** Canonical chat-header gutter; each title rail inside it owns its 32px height. */ +export const CHAT_SHELL_HEADER_CLASS = "px-2"; export function ChatSurfaceShell({ mode, diff --git a/apps/desktop/src/renderer/components/settings/ChatAppearancePreview.tsx b/apps/desktop/src/renderer/components/settings/ChatAppearancePreview.tsx index 1ff63d726..56687416e 100644 --- a/apps/desktop/src/renderer/components/settings/ChatAppearancePreview.tsx +++ b/apps/desktop/src/renderer/components/settings/ChatAppearancePreview.tsx @@ -147,7 +147,7 @@ function PreviewShellHeader({ provider }: { provider: PreviewProviderKey }) { const { name, Logo } = PREVIEW_PROVIDER_META[provider]; return (
    -
    +
    diff --git a/apps/desktop/src/renderer/components/terminals/LaneActionsSubmenu.tsx b/apps/desktop/src/renderer/components/terminals/LaneActionsSubmenu.tsx index 20511f450..cda8c9b60 100644 --- a/apps/desktop/src/renderer/components/terminals/LaneActionsSubmenu.tsx +++ b/apps/desktop/src/renderer/components/terminals/LaneActionsSubmenu.tsx @@ -68,6 +68,7 @@ export function LaneActionsSubmenu({ return ( { expect(screen.getByTestId("session-spawn-lineage").textContent).toContain("Spawned"); }); - it("never says 'another chat', and never becomes a click target", () => { + it("never says 'another chat' and exposes parent navigation to the keyboard", () => { const onSelect = vi.fn(); const dispatched: CustomEvent[] = []; const handler = (event: Event) => dispatched.push(event as CustomEvent); @@ -296,15 +296,15 @@ describe("SessionCard lineage", () => { const lineage = screen.getByTestId("session-spawn-lineage"); expect(container.textContent).not.toContain("another chat"); - expect(lineage.tagName).toBe("SPAN"); - expect(lineage.getAttribute("role")).toBeNull(); - expect(lineage.getAttribute("title")).toBeNull(); + expect(lineage.tagName).toBe("BUTTON"); + expect(lineage.getAttribute("aria-label")).toBe("Open parent thread"); act(() => { lineage.click(); }); - // Navigation lives on the hover card's parent-thread row now. - expect(dispatched).toHaveLength(0); + expect(dispatched).toHaveLength(1); + expect(dispatched[0]?.detail).toMatchObject({ sessionId: "parent-9" }); + expect(onSelect).not.toHaveBeenCalled(); } finally { window.removeEventListener("ade:work:select-session", handler); } @@ -1706,6 +1706,19 @@ describe("SessionCard hover detail card", () => { expect(screen.queryByTestId("session-hover-card")).toBeNull(); }); + it("cancels a pending card when the list scrolls during the open delay", () => { + vi.useFakeTimers(); + const { container } = renderCard(); + + fireEvent.mouseEnter(rowWrapper(container)); + act(() => { + window.dispatchEvent(new Event("scroll")); + vi.advanceTimersByTime(SESSION_HOVER_CARD_DELAY_MS + 50); + }); + + expect(screen.queryByTestId("session-hover-card")).toBeNull(); + }); + it("keeps the lane accented and the branch muted, never crossed", () => { vi.useFakeTimers(); const { container } = renderCard(); diff --git a/apps/desktop/src/renderer/components/terminals/SessionCard.tsx b/apps/desktop/src/renderer/components/terminals/SessionCard.tsx index 12980e855..062c299d1 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionCard.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionCard.tsx @@ -533,10 +533,9 @@ export const SessionCard = React.memo(function SessionCard({ `orchestrationRole` are projected onto every summary, so they are always known locally and always specific. - Not a click target: the hover detail card carries a proper, labelled - parent-thread row (`session-hover-parent-thread`) and that is where the - navigation lives. A second affordance on the same lineage, inside a row - that is itself a click target, only competed with it. */ + The chip is also the keyboard path to the parent thread. The hover card + repeats that action for pointer users, but a hover-only action would make + lineage navigation undiscoverable to keyboard and assistive-tech users. */ if (session.orchestrationParentSessionId) { const lineageLabel = session.spawnKind === "subagent" @@ -547,13 +546,21 @@ export const SessionCard = React.memo(function SessionCard({ // specific truth, and "Spawned" is the honest floor. : orchestrationLabel ?? "Spawned"; whereParts.push( - { + event.preventDefault(); + event.stopPropagation(); + navigateToSpawnedChat(session.orchestrationParentSessionId, null); + }} > {lineageLabel} - , + , ); } /* A singleton row shows its LANE and nothing else: with no divider above it, diff --git a/apps/desktop/src/renderer/components/terminals/SessionHoverCard.tsx b/apps/desktop/src/renderer/components/terminals/SessionHoverCard.tsx index be095a179..6926ecff9 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionHoverCard.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionHoverCard.tsx @@ -83,6 +83,7 @@ export function useSessionHoverCard(options?: { disabled?: boolean; rowId?: stri const disabled = Boolean(options?.disabled); const rowId = options?.rowId ?? null; const [anchor, setAnchor] = React.useState(null); + const [pendingOpen, setPendingOpen] = React.useState(false); const openTimerRef = React.useRef(null); const closeTimerRef = React.useRef(null); // The element measured when the timer FIRES, not when the pointer arrived: @@ -117,6 +118,7 @@ export function useSessionHoverCard(options?: { disabled?: boolean; rowId?: stri }, [rowId]); const clearTimers = React.useCallback(() => { + setPendingOpen(false); if (openTimerRef.current != null) { window.clearTimeout(openTimerRef.current); openTimerRef.current = null; @@ -155,6 +157,7 @@ export function useSessionHoverCard(options?: { disabled?: boolean; rowId?: stri clearTimers(); const open = () => { + setPendingOpen(false); const target = resolveTriggerElement(); if (!target) return; const rect = target.getBoundingClientRect(); @@ -170,6 +173,7 @@ export function useSessionHoverCard(options?: { disabled?: boolean; rowId?: stri open(); return; } + setPendingOpen(true); openTimerRef.current = window.setTimeout(() => { openTimerRef.current = null; open(); @@ -202,7 +206,7 @@ export function useSessionHoverCard(options?: { disabled?: boolean; rowId?: stri // mid-scroll would make it chase the row. Cancel instead — the pointer has // not "rested" on anything while the list is moving. React.useEffect(() => { - if (!anchor && openTimerRef.current == null) return undefined; + if (!anchor && !pendingOpen) return undefined; const cancel = () => close(); window.addEventListener("scroll", cancel, true); window.addEventListener("resize", cancel); @@ -210,7 +214,7 @@ export function useSessionHoverCard(options?: { disabled?: boolean; rowId?: stri window.removeEventListener("scroll", cancel, true); window.removeEventListener("resize", cancel); }; - }, [anchor, close]); + }, [anchor, close, pendingOpen]); React.useEffect(() => clearTimers, [clearTimers]); diff --git a/apps/desktop/src/renderer/components/terminals/useWorkLaneContextMenu.tsx b/apps/desktop/src/renderer/components/terminals/useWorkLaneContextMenu.tsx index a9fa7ed60..178565031 100644 --- a/apps/desktop/src/renderer/components/terminals/useWorkLaneContextMenu.tsx +++ b/apps/desktop/src/renderer/components/terminals/useWorkLaneContextMenu.tsx @@ -56,6 +56,7 @@ export type LaneMenuActions = { onSelectAll: () => void; onBatchManage: (laneIds: string[]) => void; onStartChatInLane: (laneId: string) => void; + onAppearanceChanged: () => Promise; }; /** @@ -77,6 +78,7 @@ export function useLaneMenuActions(args: { const selectLane = useAppStore((s) => s.selectLane); const projectStateKey = useAppStore(selectActiveProjectStateKey); const setWorkViewState = useAppStore((s) => s.setWorkViewState); + const refreshLanes = useAppStore((s) => s.refreshLanes); const goToLanesAction = useCallback( (laneId: string | null, action: string, extras?: Record) => { @@ -114,7 +116,8 @@ export function useLaneMenuActions(args: { goToLanesAction(laneIds[0], "batch", { laneIds: laneIds.join(",") }); }, onStartChatInLane: startChatInLane, - }), [close, goToLanesAction, onManageLane, startChatInLane]); + onAppearanceChanged: () => refreshLanes({ includeStatus: false }), + }), [close, goToLanesAction, onManageLane, refreshLanes, startChatInLane]); } export function useWorkLaneContextMenu(options?: { diff --git a/apps/desktop/src/renderer/components/ui/MenuSubmenu.tsx b/apps/desktop/src/renderer/components/ui/MenuSubmenu.tsx index 16d2b8043..cf5b20911 100644 --- a/apps/desktop/src/renderer/components/ui/MenuSubmenu.tsx +++ b/apps/desktop/src/renderer/components/ui/MenuSubmenu.tsx @@ -117,6 +117,11 @@ export function MenuSubmenu({ }, [clearTimers, onOpen]); const closeNow = useCallback(() => { + const restoreTriggerFocus = Boolean( + panelRef.current + && typeof document !== "undefined" + && panelRef.current.contains(document.activeElement), + ); clearTimers(); openRef.current = false; setOpen(false); @@ -124,6 +129,7 @@ export function MenuSubmenu({ if (hoverBackground && triggerRef.current) { triggerRef.current.style.background = "transparent"; } + if (restoreTriggerFocus) triggerRef.current?.focus(); }, [clearTimers, hoverBackground]); const scheduleOpen = useCallback(() => { diff --git a/apps/desktop/src/shared/laneColorPalette.ts b/apps/desktop/src/shared/laneColorPalette.ts index 466ffea5c..bc7e416be 100644 --- a/apps/desktop/src/shared/laneColorPalette.ts +++ b/apps/desktop/src/shared/laneColorPalette.ts @@ -46,13 +46,14 @@ export const LANE_CLASSIC_COUNT = LANE_CLASSIC_COLORS.length; * offer it, and lanes that already hold it are left alone. */ export const PRIMARY_LANE_COLOR = "#a78bfa"; +const PRIMARY_LANE_COLOR_KEY = PRIMARY_LANE_COLOR.toLowerCase(); /** * The pool auto-allocation draws from: every palette entry except the reserved * Primary purple. */ export const ALLOCATABLE_LANE_COLORS: readonly LaneColor[] = LANE_COLOR_PALETTE - .filter((entry) => entry.hex.toLowerCase() !== PRIMARY_LANE_COLOR); + .filter((entry) => entry.hex.toLowerCase() !== PRIMARY_LANE_COLOR_KEY); // Fallback accents are auto-assigned too (a lane with no stored colour picks one // by index), so they come off the allocatable pool rather than the full palette. diff --git a/apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerModel.swift b/apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerModel.swift index c146fa8f5..11348d2b4 100644 --- a/apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerModel.swift +++ b/apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerModel.swift @@ -270,10 +270,13 @@ public final class AttentionDrawerModel: ObservableObject { ) ) } else if Self.isAgentLive(agent) { + let isBlocked = Self.nonEmpty(agent.phase)?.lowercased() == "blocked" live.append( AttentionItem( id: "live:\(agent.sessionId)", - kind: machineOnline ? .running : .stale, + kind: machineOnline + ? (isBlocked ? .blocked : .running) + : .stale, title: Self.humanAgentTitle(agent), subtitle: Self.nonEmpty(agent.preview) ?? Self.agentPhaseLabel(agent.phase) diff --git a/apps/ios/ADETests/AttentionDrawerModelTests.swift b/apps/ios/ADETests/AttentionDrawerModelTests.swift index eedb2f966..4415063cd 100644 --- a/apps/ios/ADETests/AttentionDrawerModelTests.swift +++ b/apps/ios/ADETests/AttentionDrawerModelTests.swift @@ -994,6 +994,34 @@ final class AttentionDrawerModelTests: XCTestCase { XCTAssertEqual(model.liveItems.first(where: { $0.sessionId == "done-phase" })?.subtitle, "Done") } + func testWorkspaceBlockedPhaseUsesNeutralBlockedPresentation() { + let model = AttentionDrawerModel(defaults: defaults) + let now = Date() + let blocked = AgentSnapshot( + sessionId: "blocked-local", + provider: "codex", + title: "Waiting on dependency", + status: "running", + awaitingInput: false, + lastActivityAt: now, + elapsedSeconds: 20, + preview: "Blocked", + progress: nil, + phase: "blocked", + toolCalls: 1 + ) + + model.rebuild(from: WorkspaceSnapshot( + generatedAt: now, + agents: [blocked], + prs: [], + connection: "connected" + )) + + XCTAssertEqual(model.liveItems.first?.kind, .blocked) + XCTAssertTrue(model.items.isEmpty) + } + func testBlockedItemIsNotFiledOrColouredAsYourMove() { // `blocked` used to borrow `awaitingInput`'s amber bell while filing // itself under `live` — the row said "act on me" in colour and "just diff --git a/apps/ios/ADEWidgets/ADEAgentActivityWidget.swift b/apps/ios/ADEWidgets/ADEAgentActivityWidget.swift index c3c4b41d6..48083f46e 100644 --- a/apps/ios/ADEWidgets/ADEAgentActivityWidget.swift +++ b/apps/ios/ADEWidgets/ADEAgentActivityWidget.swift @@ -172,7 +172,14 @@ struct AgentRunsPresentation { return lhs.updatedAt > rhs.updatedAt }) self.prs = sortedPrs - let activeCount = max(safeState.activeCount, safeState.runs.count) + let visibleInFlightCount = safeState.runs.filter { + $0.resolvedPhase == .starting || $0.resolvedPhase == .running + }.count + // `activeCount` may include runs omitted from the compact roster. Keep + // that hidden remainder, but do not let completed/failed roster rows + // inflate the live count and headline themselves as still working. + let hiddenActiveCount = max(0, safeState.activeCount - safeState.runs.count) + let activeCount = hiddenActiveCount + visibleInFlightCount self.activeCount = activeCount self.waitingCount = safeState.runs.filter { $0.resolvedPhase.needsAttention }.count + safeState.prs.filter { $0.resolvedPhase.needsAttention }.count