From 95dc703567fb9bd14a3d3c8da241d159ef80fd5e Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 27 Jul 2026 05:02:38 -0400 Subject: [PATCH 1/4] Session lifecycle v2, branch drift detection, and Tier 1 defect fixes Adds snooze/wake and a tri-state settle override to ADE's work-session lifecycle, reachable with native UX on all six surfaces (desktop, iOS, `ade code` TUI, hosted web client, `ade` CLI, CTO tools). Adds lane branch-drift detection, and fixes five verified defects. Session lifecycle v2 - Snooze is a synced VISIBILITY OVERLAY, never a phase: canonicalSessionState does not read the snooze columns. Filing yields to a raised hand, so a snoozed session that needs you still surfaces. - Early wake on pending approval, an error strictly newer than snoozed_at, a completed turn, or a failed session end. Fails closed: an equal-or-older error, or an unparseable snoozed_at, does not wake. - Tri-state settle override (settled | active | null) consulted before the exit-0 rule. Fixes clean-exit rows that settled themselves and had no un-settle affordance at all. - Five nullable columns on terminal_sessions, mirrored in both iOS halves (DatabaseBootstrap.sql for fresh installs, Database.swift migrations for upgrades). No non-PK unique index, per crsql_as_crr. Branch drift + fork PRs - Compare live worktree HEAD to branch_ref; switch-back or keep-head. Detection rides the existing porcelain=v2 --branch status read, so it costs no extra process spawns and no new timer. - Filter `gh pr list --head` by head-repo owner so a fork PR with a colliding branch name no longer attaches to the lane. Two byte-identical copies of the vulnerable lookup collapsed into one. - gh rejects unknown --json fields with a non-zero exit, so an older gh would have failed the lookup entirely and reported "no PR" for every lane. Falls back to the legacy field set; an unverifiable owner accepts. Tier 1 defects - EPIPE from a closed launching terminal no longer kills the app: guard the streams and exempt broken-stream codes from the uncaughtException shutdown. - Isolate the Claude capability probe from user MCP servers; it was booting the whole fleet and writing a session file on every 30s cache miss. - Sort the settled tail by settle time, not start time, in both grouping paths. - Handle session_state_changed as the authoritative turn-over signal, plus a type-level guard so a future SDK subtype fails typecheck instead of being silently dropped. - Paint text selection over composer chips in both composers. One parser for settle-override values replaces four implementations with three different behaviours; unrecognized input is now distinct from an explicit clear, so a typo can no longer silently drop a keep-active pin. Co-Authored-By: Claude --- CLAUDE.md | 30 +- apps/ade-cli/README.md | 15 +- apps/ade-cli/src/cli.test.ts | 263 +++++++++ apps/ade-cli/src/cli.ts | 382 ++++++++++++++ .../src/services/sync/syncHostService.test.ts | 15 +- .../sync/syncRemoteCommandService.test.ts | 171 +++++- .../services/sync/syncRemoteCommandService.ts | 151 +++++- apps/ade-cli/src/sessionSnoozeDuration.ts | 166 ++++++ .../src/tuiClient/__tests__/adeApi.test.ts | 60 ++- .../__tests__/sessionLifecycle.test.tsx | 496 +++++++++++++++++ apps/ade-cli/src/tuiClient/adeApi.ts | 59 +++ apps/ade-cli/src/tuiClient/app.tsx | 210 +++++++- apps/ade-cli/src/tuiClient/commands.ts | 11 + .../src/tuiClient/components/Drawer.tsx | 75 ++- .../src/tuiClient/components/RightPane.tsx | 6 +- .../ade-cli/src/tuiClient/sessionLifecycle.ts | 334 ++++++++++++ apps/ade-cli/src/tuiClient/types.ts | 5 +- .../ade-cli-control-plane/SKILL.md | 52 ++ apps/desktop/src/main/main.ts | 15 +- .../main/services/adeActions/registry.test.ts | 145 +++++ .../src/main/services/adeActions/registry.ts | 136 +++++ .../main/services/ai/claudeRuntimeProbe.ts | 9 + .../ai/tools/ctoOperatorTools.test.ts | 115 ++++ .../services/ai/tools/ctoOperatorTools.ts | 213 +++++++- .../main/services/chat/agentChatService.ts | 57 ++ .../src/main/services/git/ghOpenPrLookup.ts | 119 +++++ .../main/services/git/ghPrHeadRepo.test.ts | 208 ++++++++ .../src/main/services/git/ghPrHeadRepo.ts | 142 +++++ .../main/services/git/gitOperationsService.ts | 40 +- .../src/main/services/ipc/registerIpc.ts | 127 +++-- .../services/lanes/laneBranchDrift.test.ts | 118 +++++ .../main/services/lanes/laneBranchDrift.ts | 86 +++ .../services/lanes/laneListSnapshotService.ts | 10 +- .../main/services/lanes/laneService.test.ts | 309 ++++++++++- .../src/main/services/lanes/laneService.ts | 214 +++++++- .../src/main/services/pty/ptyService.ts | 1 + .../services/sessions/sessionService.test.ts | 384 ++++++++++++++ .../main/services/sessions/sessionService.ts | 358 ++++++++++++- .../src/main/services/state/kvDb.test.ts | 33 ++ apps/desktop/src/main/services/state/kvDb.ts | 18 + .../main/services/usage/usageStatsStore.ts | 8 + apps/desktop/src/preload/global.d.ts | 30 ++ apps/desktop/src/preload/preload.ts | 94 ++++ .../chat/AgentChatComposer.test.tsx | 106 +++- .../components/chat/AgentChatComposer.tsx | 131 ++++- .../components/chat/AgentChatPane.tsx | 9 + .../components/chat/ChatGitToolbar.tsx | 10 +- .../components/lanes/LaneBranchDrift.tsx | 186 +++++++ .../components/terminals/SessionCard.test.tsx | 54 ++ .../components/terminals/SessionCard.tsx | 91 +++- .../terminals/SessionContextMenu.test.tsx | 101 +++- .../terminals/SessionContextMenu.tsx | 101 +++- .../terminals/SessionListPane.test.tsx | 58 ++ .../components/terminals/SessionListPane.tsx | 154 ++++-- .../terminals/SessionSnoozeControl.tsx | 129 +++++ .../components/terminals/TerminalsPage.tsx | 6 + .../terminals/sessionLifecycleActions.ts | 90 ++++ .../terminals/useWorkSessions.test.ts | 171 ++++++ .../components/terminals/useWorkSessions.ts | 122 ++++- .../work/SessionLifecycleChips.test.tsx | 119 +++++ .../components/work/SessionLifecycleChips.tsx | 174 ++++++ .../components/work/WorkSurfaceHeader.tsx | 11 + apps/desktop/src/renderer/index.css | 20 + .../src/renderer/lib/sessionSnooze.test.ts | 128 +++++ .../desktop/src/renderer/lib/sessionSnooze.ts | 208 ++++++++ .../src/renderer/lib/terminalAttention.ts | 5 +- .../adapter/__tests__/adapter.test.ts | 154 +++++- .../__tests__/sessionLifecycleOverlay.test.ts | 120 +++++ .../src/renderer/webclient/adapter/lanes.ts | 3 + .../adapter/sessionLifecycleOverlay.ts | 200 +++++++ .../adapter/sessionLifecycleSupport.ts | 75 +++ .../renderer/webclient/adapter/sessionsPty.ts | 289 +++++++++- .../webclient/shell/WebClientRoot.tsx | 7 + .../shell/__tests__/WebClientRoot.test.tsx | 3 + .../__tests__/sessionLifecycleChrome.test.ts | 182 +++++++ .../webclient/shell/sessionLifecycleChrome.ts | 195 +++++++ apps/desktop/src/shared/ipc.ts | 8 + .../src/shared/sessionCanonicalState.test.ts | 218 ++++++++ .../src/shared/sessionCanonicalState.ts | 170 +++++- .../src/shared/syncMobileCompatibility.ts | 10 + apps/desktop/src/shared/types/lanes.ts | 44 ++ apps/desktop/src/shared/types/sessions.ts | 84 +++ apps/desktop/src/shared/types/sync.ts | 15 + apps/ios/ADE/Models/RemoteModels.swift | 28 + apps/ios/ADE/Models/RemoteRosterModels.swift | 12 + apps/ios/ADE/Resources/DatabaseBootstrap.sql | 5 + apps/ios/ADE/Services/Database.swift | 165 +++++- apps/ios/ADE/Services/SyncService.swift | 166 ++++++ .../ADE/Views/Work/WorkRootComponents.swift | 160 ++++++ .../Views/Work/WorkRootScreen+Actions.swift | 68 +++ apps/ios/ADE/Views/Work/WorkRootScreen.swift | 18 +- .../Work/WorkSessionCanonicalState.swift | 366 ++++++++++++- .../ADE/Views/Work/WorkSessionGrouping.swift | 57 +- .../ADETests/SyncRecoveryPolicyTests.swift | 169 ++++++ .../WorkSessionCanonicalStateTests.swift | 498 ++++++++++++++++++ docs/ARCHITECTURE.md | 33 +- docs/features/ade-code/README.md | 14 +- docs/features/chat/README.md | 32 +- docs/features/chat/agent-routing.md | 11 +- docs/features/chat/composer-and-ui.md | 49 +- docs/features/chat/transcript-and-turns.md | 24 +- docs/features/cto/README.md | 36 +- docs/features/lanes/README.md | 101 +++- docs/features/pull-requests/README.md | 48 ++ docs/features/sync-and-multi-device/README.md | 9 +- .../sync-and-multi-device/ios-companion.md | 79 ++- .../sync-and-multi-device/remote-commands.md | 40 ++ .../features/terminals-and-sessions/README.md | 285 +++++++++- .../terminals-and-sessions/ui-surfaces.md | 66 ++- docs/features/web-client/README.md | 101 +++- 110 files changed, 11763 insertions(+), 328 deletions(-) create mode 100644 apps/ade-cli/src/sessionSnoozeDuration.ts create mode 100644 apps/ade-cli/src/tuiClient/__tests__/sessionLifecycle.test.tsx create mode 100644 apps/ade-cli/src/tuiClient/sessionLifecycle.ts create mode 100644 apps/desktop/src/main/services/git/ghOpenPrLookup.ts create mode 100644 apps/desktop/src/main/services/git/ghPrHeadRepo.test.ts create mode 100644 apps/desktop/src/main/services/git/ghPrHeadRepo.ts create mode 100644 apps/desktop/src/main/services/lanes/laneBranchDrift.test.ts create mode 100644 apps/desktop/src/main/services/lanes/laneBranchDrift.ts create mode 100644 apps/desktop/src/renderer/components/lanes/LaneBranchDrift.tsx create mode 100644 apps/desktop/src/renderer/components/terminals/SessionSnoozeControl.tsx create mode 100644 apps/desktop/src/renderer/components/terminals/sessionLifecycleActions.ts create mode 100644 apps/desktop/src/renderer/components/work/SessionLifecycleChips.test.tsx create mode 100644 apps/desktop/src/renderer/components/work/SessionLifecycleChips.tsx create mode 100644 apps/desktop/src/renderer/lib/sessionSnooze.test.ts create mode 100644 apps/desktop/src/renderer/lib/sessionSnooze.ts create mode 100644 apps/desktop/src/renderer/webclient/adapter/__tests__/sessionLifecycleOverlay.test.ts create mode 100644 apps/desktop/src/renderer/webclient/adapter/sessionLifecycleOverlay.ts create mode 100644 apps/desktop/src/renderer/webclient/adapter/sessionLifecycleSupport.ts create mode 100644 apps/desktop/src/renderer/webclient/shell/__tests__/sessionLifecycleChrome.test.ts create mode 100644 apps/desktop/src/renderer/webclient/shell/sessionLifecycleChrome.ts diff --git a/CLAUDE.md b/CLAUDE.md index 6339dd5f0..a93e9ff07 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,18 +1,34 @@ ## Picking the right models for work delegation -Three tiers. Fable 5 for thinking, gpt-5.6-sol for building, Opus 4.8 for everything in between. With a tight, self-contained prompt Opus also handles implementation well when gpt-5.6-sol is unavailable. Sonnet is acceptable only for genuinely trivial mechanical subtasks; never use Haiku. +**Applicability — read this first.** Everything below describes the *Fable-orchestrated* workflow: +Fable 5 does the thinking and writes specs, gpt-5.6-sol executes them. **It applies only when the +orchestrating agent is Fable 5.** If you are any other model — Opus, Sonnet, or anything else — +this section does not govern your delegation, and you should not route work to Codex/gpt-5.6-sol +on its account. Check what model you are before applying any of it. + +**If you are not Fable:** do the work yourself, and delegate to `opus` subagents (Claude Opus 5) +when you need parallelism, an independent review perspective, or a second opinion. Sonnet is +acceptable only for genuinely trivial mechanical subtasks; never use Haiku. You may still use +Codex deliberately — for an independent review via `codex review`, or when the user asks for it — +but not as your default implementation path. + +### The Fable-orchestrated tiers + +Three tiers. Fable 5 for thinking, gpt-5.6-sol for building, Opus 5 for everything in between. +With a tight, self-contained prompt Opus also handles implementation well when gpt-5.6-sol is +unavailable. | model | use for | |----------|---------| | fable-5 | Deep research, architecture/design decisions, hard debugging, anything requiring sustained reasoning or judgment. Also the orchestrator: it writes the specs and prompts the other models execute. | -| gpt-5.6-sol (high/xhigh) | Pure implementation once a strong, self-contained prompt exists: clear-spec features, migrations, mechanical refactors, test writing, data analysis. Effectively free ‚Äî use liberally. | -| opus-4.8 | Everything else: reviews, moderate-complexity tasks, user-facing polish, second opinions. | +| gpt-5.6-sol (high/xhigh) | Pure implementation once a strong, self-contained prompt exists: clear-spec features, migrations, mechanical refactors, test writing, data analysis. Effectively free — use liberally. | +| opus-5 | Everything else: reviews, moderate-complexity tasks, user-facing polish, second opinions. | How to apply: -- The division of labor is think-then-delegate: Fable (you, or a fable subagent) does the research and produces a detailed spec; gpt-5.6-sol executes it. Never hand gpt-5.6-sol an underspecified task ‚Äî it can't ask clarifying questions mid-run, so the prompt must contain all context, file paths, constraints, and acceptance criteria. +- The division of labor is think-then-delegate: Fable does the research and produces a detailed spec; gpt-5.6-sol executes it. Never hand gpt-5.6-sol an underspecified task — it can't ask clarifying questions mid-run, so the prompt must contain all context, file paths, constraints, and acceptance criteria. - These are defaults, not limits. If a model's output doesn't meet the bar, redo the work with a smarter model without asking. Judge the output, not the price tag. -- Mechanics for gpt-5.6-sol: it's only reachable through the Codex CLI. Run `codex exec -m gpt-5.6-sol ""` via Bash ‚Äî my ~/.codex/config.toml defaults to gpt-5.6-sol at xhigh reasoning; pass `-m gpt-5.6-sol` (and `-c model_reasoning_effort=xhigh`) explicitly whenever the config default differs. Use `codex exec -s read-only` for investigation/analysis; use `codex exec resume --last` to iterate on a prior run. `codex review` for an independent review perspective. -- Invoking codex from an agent shell (IMPORTANT): always close stdin and write output to a log file ‚Äî `codex exec "" "$LOG" 2>&1`, backgrounded. In non-interactive shells stdin is an open pipe and codex blocks forever on "Reading additional input from stdin..." before doing any work; piping stdout through `tail`/`head` buffers everything so you can't see progress. Verify it's actually working by checking the log grows and a new session file appears under `~/.codex/sessions//`; no session file after ~2 min = wedged, kill and relaunch. +- Mechanics for gpt-5.6-sol: it's only reachable through the Codex CLI. Run `codex exec -m gpt-5.6-sol ""` via Bash — my ~/.codex/config.toml defaults to gpt-5.6-sol at xhigh reasoning; pass `-m gpt-5.6-sol` (and `-c model_reasoning_effort=xhigh`) explicitly whenever the config default differs. Use `codex exec -s read-only` for investigation/analysis; use `codex exec resume --last` to iterate on a prior run. `codex review` for an independent review perspective. +- Invoking codex from an agent shell (IMPORTANT): always close stdin and write output to a log file — `codex exec "" "$LOG" 2>&1`, backgrounded. In non-interactive shells stdin is an open pipe and codex blocks forever on "Reading additional input from stdin..." before doing any work; piping stdout through `tail`/`head` buffers everything so you can't see progress. Verify it's actually working by checking the log grows and a new session file appears under `~/.codex/sessions//`; no session file after ~2 min = wedged, kill and relaunch. - Mechanics for Claude models: use the Agent/Workflow `model` parameter (`fable`, `opus`). - Inside Workflows (where the model parameter only takes Claude models), reach gpt-5.6-sol via a thin wrapper: spawn an `opus` agent whose prompt says "run the following via `codex exec` in Bash and return its output verbatim, then verify the result compiles/passes tests before returning." -- Reviews of anything that ships: fable-5 or opus-4.8, optionally `codex review` as an extra independent perspective. \ No newline at end of file +- Reviews of anything that ships: fable-5 or opus-5, optionally `codex review` as an extra independent perspective. diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index 267f644b0..a2b4f86bf 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -349,6 +349,10 @@ ade lanes create "fix-checkout-flow" --parent main ade lanes create "fix-login" --base origin/main # omit --base to branch from the configured new-lane base (remote-first by default) ade lanes child --lane lane-parent --name fix-followup # child lane carries the parent's unmerged work; a base-less `ade lanes create`/`--auto-create-lane` from a lane with commits not yet on main prints a non-blocking stderr nudge to use this instead ade lanes create "lin-123" --linear-issue-json '{"id":"...","identifier":"LIN-123","title":"...","projectId":"...","projectSlug":"...","teamId":"...","teamKey":"...","stateId":"...","stateName":"Todo","stateType":"unstarted","priority":2,"priorityLabel":"high","labels":[],"assigneeId":null,"assigneeName":null,"createdAt":"...","updatedAt":"..."}' +ade lane drift --lane lane-id --text # did someone `git checkout` inside the worktree? compares live HEAD to the lane's recorded branch +ade lane drift resolve --lane lane-id --switch-back # put the worktree back on the lane's branch (refuses on a dirty tree) +ade lane drift resolve --lane lane-id --keep-head # re-point the lane (and its name) at the live HEAD branch +ade lane drift resolve --lane lane-id --keep-head --expected-head hotfix-auth --force # --expected-head guards a stale read; --force acknowledges active work ade lanes reparent lane-child --parent lane-parent --stack-base-branch main ade lanes delete lane-id --force --delete-branch ade lanes create-from-linear --issue-id ENG-431 --start-chat --provider codex --model @@ -408,7 +412,16 @@ 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" # mark the caller settled; add --session to target explicitly -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) ade chat scheduled-work list [session-id] --all # list durable jobs; --all includes recent terminal history ade chat scheduled-work create --in 12m --prompt "Check CI and report" --reason "CI check" --session session-id # safest one-shot form; omit --session inside the bound agent diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 355ca2d23..aa51dfcd9 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -22,7 +22,9 @@ import { isFailedServiceManagerResult, machineRuntimeMismatchReason, parseCliArgs, + parseSnoozeDurationMs, readRuntimeIdleExitMs, + resolveSnoozeUntilIso, renderLaneGraph, resolveAdeCodeModulePath, resolveRoots, @@ -2940,6 +2942,267 @@ describe("ADE CLI", () => { }, ); + describe("session lifecycle commands", () => { + const NOW = Date.parse("2026-07-26T12:00:00.000Z"); + + it.each([ + ["30m", 30 * 60_000], + ["1h", 60 * 60_000], + ["4h", 4 * 60 * 60_000], + ["1d", 24 * 60 * 60_000], + ["1.5h", 90 * 60_000], + ["45s", 45_000], + ["2w", 14 * 24 * 60 * 60_000], + [" 1H ", 60 * 60_000], + ["90", 90 * 60_000], + ["2hours", 2 * 60 * 60_000], + ])("parses --for %s", (input, expectedMs) => { + expect(parseSnoozeDurationMs(input)).toBe(expectedMs); + }); + + it.each([ + ["", /positive duration/], + ["soon", /positive duration/], + ["0h", /positive duration/], + ["-1h", /positive duration/], + ["1y", /positive duration/], + ["0.001s", /at least one second/], + ["31d", /30d or less/], + ])("rejects --for %s", (input, message) => { + expect(() => parseSnoozeDurationMs(input)).toThrow(message); + }); + + it("turns --for into a future ISO deadline and accepts --until directly", () => { + expect(resolveSnoozeUntilIso({ forValue: "1h", untilValue: null }, NOW)) + .toBe("2026-07-26T13:00:00.000Z"); + expect(resolveSnoozeUntilIso({ forValue: null, untilValue: "2026-07-26T18:00:00Z" }, NOW)) + .toBe("2026-07-26T18:00:00.000Z"); + }); + + it("refuses ambiguous, unparseable, or already-elapsed deadlines", () => { + expect(() => resolveSnoozeUntilIso({ forValue: "1h", untilValue: "2026-07-26T18:00:00Z" }, NOW)) + .toThrow("Use either --for or --until, not both."); + expect(() => resolveSnoozeUntilIso({ forValue: null, untilValue: null }, NOW)) + .toThrow(/--for <30m\|1h\|4h\|1d> or --until/); + expect(() => resolveSnoozeUntilIso({ forValue: null, untilValue: "next tuesday" }, NOW)) + .toThrow(/ISO-8601/); + expect(() => resolveSnoozeUntilIso({ forValue: null, untilValue: "2026-07-26T11:00:00Z" }, NOW)) + .toThrow("--until must be in the future."); + }); + + it("expresses the open-ended 'until asked' deadline the desktop preset writes", () => { + const untilIso = resolveSnoozeUntilIso( + { forValue: null, untilValue: null, untilAsked: true }, + NOW, + ); + // Far enough out that only a hand-raise brings the row back, and well past + // the 30d cap that guards mistyped *relative* durations. + expect(Date.parse(untilIso) - NOW).toBeGreaterThan(365 * 24 * 60 * 60_000); + expect(() => + resolveSnoozeUntilIso({ forValue: "1h", untilValue: null, untilAsked: true }, NOW), + ).toThrow(/--until-asked or a --for\/--until deadline, not both/); + }); + + it("plans ade session snooze --until-asked", () => { + const plan = expectExecutePlan(buildCliPlan(["session", "snooze", "session-x", "--until-asked"])); + const params = plan.steps[0]?.params as { + arguments: { action: string; args: Record }; + }; + expect(params.arguments.action).toBe("snoozeSession"); + expect(params.arguments.args.sessionId).toBe("session-x"); + expect(Date.parse(params.arguments.args.untilIso as string) - Date.now()) + .toBeGreaterThan(365 * 24 * 60 * 60_000); + }); + + it("renders an open-ended snooze as 'when asked' instead of a century-out date", () => { + const farFuture = new Date(Date.now() + 100 * 365 * 24 * 60 * 60_000).toISOString(); + const text = formatOutput( + { sessionId: "session-x", snoozedUntil: farFuture }, + { text: true } as never, + "session-lifecycle", + ); + expect(text).toContain("when asked"); + expect(text).not.toContain(farFuture); + + const soon = new Date(Date.now() + 3 * 60 * 60_000).toISOString(); + const soonText = formatOutput( + { sessionId: "session-x", snoozedUntil: soon }, + { text: true } as never, + "session-lifecycle", + ); + expect(soonText).toContain(soon); + expect(soonText).toMatch(/wakes\s+in 3h/); + }); + + it("plans ade session snooze --for through the session action", () => { + const plan = expectExecutePlan(buildCliPlan(["session", "snooze", "session-x", "--for", "1h"])); + const params = plan.steps[0]?.params as { + arguments: { domain: string; action: string; args: Record }; + }; + expect(params.arguments.domain).toBe("session"); + expect(params.arguments.action).toBe("snoozeSession"); + expect(params.arguments.args.sessionId).toBe("session-x"); + const untilIso = params.arguments.args.untilIso as string; + expect(Date.parse(untilIso)).toBeGreaterThan(Date.now()); + expect(inferFormatter(plan)).toBe("session-lifecycle"); + }); + + it.each([ + [["wake", "session-x"], "wakeSession", { sessionId: "session-x" }], + [["wake", "session-x", "--reason", "needs_you"], "wakeSession", { + 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) => { + const plan = expectExecutePlan(buildCliPlan(["session", ...commandArgs])); + expect(plan.steps[0]?.params).toMatchObject({ + arguments: { domain: "session", action, args: expectedArgs }, + }); + }); + + it("accepts --session and falls back to the caller's session id", () => { + const flagPlan = expectExecutePlan(buildCliPlan([ + "session", + "wake", + "--session", + "session-flag", + ])); + expect(flagPlan.steps[0]?.params).toMatchObject({ + arguments: { args: { sessionId: "session-flag" } }, + }); + + const previous = process.env.ADE_CHAT_SESSION_ID; + process.env.ADE_CHAT_SESSION_ID = "session-env"; + try { + const envPlan = expectExecutePlan(buildCliPlan(["session", "unsettle"])); + expect(envPlan.steps[0]?.params).toMatchObject({ + arguments: { action: "unsettleSelfSession", args: { sessionId: "session-env" } }, + }); + } finally { + if (previous === undefined) delete process.env.ADE_CHAT_SESSION_ID; + else process.env.ADE_CHAT_SESSION_ID = previous; + } + }); + + it("requires a snooze deadline and a resolvable session", () => { + expect(() => buildCliPlan(["session", "snooze", "session-x"])) + .toThrow(/--for <30m\|1h\|4h\|1d> or --until/); + const previous = process.env.ADE_CHAT_SESSION_ID; + delete process.env.ADE_CHAT_SESSION_ID; + try { + expect(() => buildCliPlan(["session", "wake"])).toThrow("sessionId is required."); + } finally { + if (previous !== undefined) process.env.ADE_CHAT_SESSION_ID = previous; + } + expect(() => buildCliPlan(["session", "hibernate", "session-x"])) + .toThrow(/Unknown session subcommand 'hibernate'/); + }); + + it("documents the session surface in help", () => { + const help = buildCliPlan(["session", "--help"]); + expect(help.kind).toBe("help"); + if (help.kind === "help") { + 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"); + } + const top = buildCliPlan([]); + if (top.kind === "help") { + expect(top.text).toContain("ade session snooze | wake | settle | unsettle"); + } + }); + }); + + describe("lane branch drift commands", () => { + it("reads drift status for a lane", () => { + const plan = expectExecutePlan(buildCliPlan(["lane", "drift", "--lane", "lane-1"])); + expect(plan.steps[0]?.params).toMatchObject({ + arguments: { domain: "lane", action: "getBranchDrift", args: { laneId: "lane-1" } }, + }); + expect(inferFormatter(plan)).toBe("lane-drift"); + expect(formatOutput(null, { text: true } as any, inferFormatter(plan))) + .toContain("No branch drift"); + }); + + it("accepts a positional lane id for the status read", () => { + const plan = expectExecutePlan(buildCliPlan(["lane", "drift", "lane-2"])); + expect(plan.steps[0]?.params).toMatchObject({ + arguments: { action: "getBranchDrift", args: { laneId: "lane-2" } }, + }); + }); + + it.each([ + ["--switch-back", "switch-back"], + ["--keep-head", "keep-head"], + ])("resolves drift with %s", (flag, resolution) => { + const plan = expectExecutePlan(buildCliPlan([ + "lane", + "drift", + "resolve", + "--lane", + "lane-1", + flag, + ])); + expect(plan.steps[0]?.params).toMatchObject({ + arguments: { + domain: "lane", + action: "resolveBranchDrift", + args: { laneId: "lane-1", resolution }, + }, + }); + }); + + it("requires exactly one resolution flag", () => { + expect(() => buildCliPlan(["lane", "drift", "resolve", "--lane", "lane-1"])) + .toThrow(/exactly one of --switch-back or --keep-head/); + expect(() => buildCliPlan([ + "lane", + "drift", + "resolve", + "--lane", + "lane-1", + "--switch-back", + "--keep-head", + ])).toThrow(/exactly one of --switch-back or --keep-head/); + }); + + it("passes the stale-read guard and active-work acknowledgement through", () => { + const plan = expectExecutePlan(buildCliPlan([ + "lane", + "drift", + "resolve", + "lane-3", + "--keep-head", + "--expected-head", + "hotfix-auth", + "--force", + ])); + expect(plan.steps[0]?.params).toMatchObject({ + arguments: { + action: "resolveBranchDrift", + args: { + laneId: "lane-3", + resolution: "keep-head", + expectedHeadBranchRef: "hotfix-auth", + acknowledgeActiveWork: true, + }, + }, + }); + }); + }); + it("routes chat send through the normalized message primitive", () => { const executePlan = expectExecutePlan(buildCliPlan([ "chat", diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 61a739ad9..377f7a143 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -114,6 +114,12 @@ import { type AdeServiceCommand, } from "./serviceManager/common"; import { normalizeAdeRuntimeRole, resolveAdeDefaultRole } from "./runtimeRoles"; +import { + isIndefiniteSnooze, + parseSnoozeDuration, + resolveSnoozeUntil, +} from "./sessionSnoozeDuration"; +import { snoozeWakeLabel } from "../../desktop/src/renderer/lib/sessionSnooze"; import type { AdeRuntime } from "./bootstrap"; import { reseedBundledAdeSkillsForCli } from "./bootstrap"; import { EncryptedFileCredentialStore } from "./services/credentials/credentialStore"; @@ -217,6 +223,8 @@ type FormatterId = | "pr-comments" | "chat-list" | "chat-read" + | "session-lifecycle" + | "lane-drift" | "scheduled-work-create" | "tests-runs" | "proof-list" @@ -608,6 +616,7 @@ const TOP_LEVEL_HELP = `${ADE_BANNER} $ ade chat list | create | send | ask | note settle | unsettle | interrupt Work with ADE agent chats + $ ade session snooze | wake | settle | unsettle 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) @@ -1451,6 +1460,11 @@ const HELP_BY_COMMAND: Record = { $ 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 '{...}' @@ -1633,6 +1647,31 @@ const HELP_BY_COMMAND: Record = { $ ade files mkdir --workspace src/new $ ade files search --workspace -q Search text in a workspace $ ade files quick-open --workspace -q app +`, + session: `${ADE_BANNER} + Session lifecycle + + These commands file a session rather than talk to it. Every subcommand takes + 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. + + $ 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 + Snooze until an explicit ISO-8601 deadline + $ 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 + + Snooze is a visibility overlay, not a lifecycle phase: the session keeps + running while snoozed, and a hand-raise wakes it early — an approval request + ('needs_you'), a failed turn ('error'), or a completed turn ('turn_complete'). + 'ade session show' reports which one brought it back. `, chat: `${ADE_BANNER} Work chats @@ -2444,6 +2483,33 @@ function parseScheduledWorkDelaySeconds(value: string): number { return seconds; } +/** + * Parse `ade session snooze --for `. The grammar and the 30-day cap + * live in `sessionSnoozeDuration.ts` so `ade code`'s `/session snooze` resolves + * a duration identically; this wrapper only turns the shared result union into + * the `CliUsageError` the argv layer already knows how to report. + */ +function parseSnoozeDurationMs(value: string): number { + const parsed = parseSnoozeDuration(value); + if (!parsed.ok) throw new CliUsageError(parsed.message); + return parsed.ms; +} + +/** + * Resolve the snooze deadline from `--for `, `--until `, or + * `--until-asked`. Exactly one is required; `--until` must parse and must be in + * the future, because a past deadline would make the row instantly visible + * again and read as a silently ignored command. + */ +function resolveSnoozeUntilIso( + args: { forValue: string | null; untilValue: string | null; untilAsked?: boolean }, + now: number = Date.now(), +): string { + const resolved = resolveSnoozeUntil(args, now); + if (!resolved.ok) throw new CliUsageError(resolved.message); + return resolved.untilIso; +} + function readCommandTextValue(args: string[], names: string[]): string | null { for (let index = 0; index < args.length; index += 1) { const token = args[index]; @@ -3695,6 +3761,57 @@ function buildLanePlan(args: string[]): CliPlan { steps: [actionCallStep("result", "get_lane_status", { laneId })], }; } + // Branch drift = someone ran `git checkout` inside the lane worktree, so the + // live HEAD no longer matches the branch ADE still advertises for the lane. + if (sub === "drift") { + // firstStandalonePositional (not firstPositional) so `--lane ` is not + // mistaken for the mode token or the lane positional. + const modeToken = firstStandalonePositional(args); + const isResolve = modeToken === "resolve" || modeToken === "fix"; + const laneId = requireValue( + readLaneId(args) ?? (isResolve ? firstStandalonePositional(args) : modeToken), + "laneId", + ); + if (!isResolve) { + return { + kind: "execute", + label: "lane drift", + formatter: "lane-drift", + steps: [actionStep("result", "lane", "getBranchDrift", { laneId })], + }; + } + const switchBack = readFlag(args, ["--switch-back", "--restore"]); + const keepHead = readFlag(args, ["--keep-head", "--adopt-head"]); + if (switchBack === keepHead) { + throw new CliUsageError( + "lane drift resolve requires exactly one of --switch-back or --keep-head.", + ); + } + const expectedHeadBranchRef = readValue(args, [ + "--expected-head", + "--expected-head-branch", + ]); + return { + kind: "execute", + label: "lane drift resolve", + formatter: "lane-drift", + steps: [ + actionStep( + "result", + "lane", + "resolveBranchDrift", + collectGenericObjectArgs(args, { + laneId, + resolution: switchBack ? "switch-back" : "keep-head", + ...(expectedHeadBranchRef ? { expectedHeadBranchRef } : {}), + ...(readFlag(args, ["--force", "--acknowledge-active-work"]) + ? { acknowledgeActiveWork: true } + : {}), + }), + ), + ], + }; + } if (sub === "merge") { const laneId = requireValue( readLaneId(args) ?? firstPositional(args), @@ -6591,6 +6708,187 @@ 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. + * + * 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. + */ +function buildSessionPlan(args: string[]): CliPlan { + const sub = firstPositional(args) ?? "show"; + if (sub === "actions") { + return { + kind: "execute", + label: "session actions", + steps: [listActionsStep("actions", "session")], + }; + } + if (sub === "action") { + return { + kind: "execute", + label: "session action", + steps: [buildActionRunStep(["session", ...args])], + }; + } + // The positional is the documented form and wins, but readSessionId() still + // runs so it consumes --session/--session-id (and supplies the + // $ADE_CHAT_SESSION_ID fallback when neither form is given). + const positionalSessionId = firstStandalonePositional(args); + const fallbackSessionId = readSessionId(args); + const sessionId = requireValue( + positionalSessionId ?? fallbackSessionId, + "sessionId", + ); + + if (sub === "show" || sub === "status" || sub === "get") { + return { + kind: "execute", + label: "session show", + formatter: "session-lifecycle", + steps: [actionStep("result", "session", "get", { sessionId })], + }; + } + + if (sub === "snooze") { + // `--until-asked` mirrors the desktop/iOS "Until I'm asked" preset: no clock + // deadline, so only a hand-raise (needs-you / error / turn complete) brings + // the row back. Read before --for/--until so the conflict is reported. + const untilAsked = readFlag(args, ["--until-asked", "--indefinite"]); + const untilIso = resolveSnoozeUntilIso({ + forValue: readValue(args, ["--for", "--duration"]), + untilValue: readValue(args, ["--until", "--until-iso"]), + untilAsked, + }); + return { + kind: "execute", + label: "session snooze", + formatter: "session-lifecycle", + steps: [ + actionStep( + "result", + "session", + "snoozeSession", + collectGenericObjectArgs(args, { sessionId, untilIso }), + ), + ], + }; + } + + if (sub === "wake" || sub === "unsnooze") { + const reason = readValue(args, ["--reason"]); + return { + kind: "execute", + label: "session wake", + formatter: "session-lifecycle", + steps: [ + actionStep( + "result", + "session", + "wakeSession", + collectGenericObjectArgs(args, { + sessionId, + ...(reason ? { reason } : {}), + }), + ), + ], + }; + } + + // `--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", + label: "session clear-woke", + formatter: "session-lifecycle", + steps: [ + actionStep( + "result", + "session", + "clearWokeMarker", + collectGenericObjectArgs(args, { sessionId }), + ), + ], + }; + } + + throw new CliUsageError( + `Unknown session subcommand '${sub}'. Try: show, snooze, wake, settle, unsettle, clear-woke.`, + ); +} + function buildChatPlan(args: string[]): CliPlan { const sub = firstPositional(args) ?? "list"; if (readFlag(args, ["--personal"])) { @@ -11441,6 +11739,8 @@ const VALUE_CARRIER_FLAGS: ReadonlySet = new Set([ "--event", "--end-x", "--end-y", + "--expected-head", + "--expected-head-branch", "--delivery", "--file", "--for", @@ -11488,6 +11788,7 @@ const VALUE_CARRIER_FLAGS: ReadonlySet = new Set([ "--owner", "--owner-id", "--owner-kind", + "--outcome", "--output", "--oid", "--params-json", @@ -11576,6 +11877,7 @@ const VALUE_CARRIER_FLAGS: ReadonlySet = new Set([ "--udid", "--url", "--until", + "--until-iso", "--value", "--window-title", "--workspace", @@ -11654,6 +11956,7 @@ function buildCliPlan( "auto-update": "update", updates: "update", operation: "operations", + sessions: "session", project: "projects", machine: "machines", quota: "usage", @@ -11892,6 +12195,8 @@ function buildCliPlan( if (primary === "terminal" || primary === "term") return buildTerminalPlan(args); if (primary === "history") return buildHistoryPlan(args); + if (primary === "session" || primary === "sessions") + return buildSessionPlan(args); if (primary === "chat" || primary === "chats" || primary === "work") return buildChatPlan(args); if (primary === "agent" || primary === "agents") return buildAgentPlan(args); @@ -17079,6 +17384,77 @@ function formatChatList(value: unknown): string { ); } +/** + * One renderer for every `ade session …` command. They all return either a + * session row (`show`) or a small mutation ack, so the formatter prints + * whichever lifecycle fields are present rather than switching per command. + * + * The "wakes" line comes from the shared `snoozeWakeLabel`, so an open-ended + * "until asked" deadline reads as `wakes when asked` here exactly as it does on + * desktop, iOS, and in `ade code` — and the raw timestamp (~100 years out, which + * would read as corruption) is suppressed for that case. + */ +function formatSessionLifecycle(value: unknown): string { + const record = + firstRecord(value, ["session", "result", "detail"]) ?? + (isRecord(value) ? value : {}); + const now = Date.now(); + const snoozedUntil = asString(record.snoozedUntil); + const snoozedUntilMs = snoozedUntil ? Date.parse(snoozedUntil) : NaN; + const snoozed = Number.isFinite(snoozedUntilMs) && snoozedUntilMs > now; + const wakeLabel = snoozed ? snoozeWakeLabel(snoozedUntil, now) : null; + const indefinite = isIndefiniteSnooze(snoozedUntil, now); + return renderKeyValues("ADE session lifecycle", [ + ["session", record.sessionId ?? record.id], + ["title", record.title], + ["lane", record.laneId], + ["runtime state", record.runtimeState], + ["settled at", record.settledAt], + ["settle override", record.settleOverride], + ["status note", record.statusNote], + ["attention", record.attentionRequestedAt], + ["last turn failed", record.lastTurnFailedAt], + ["snoozed", snoozedUntil ? (snoozed ? "yes" : "expired") : undefined], + ["wakes", wakeLabel ? wakeLabel.replace(/^wakes\s+/, "") : undefined], + ["snoozed until", snoozedUntil && !indefinite ? snoozedUntil : undefined], + ["snoozed at", record.snoozedAt], + ["woke at", record.wokeAt], + ["woke reason", record.wokeReason ?? record.reason], + ["ok", record.ok], + ]); +} + +/** + * `ade lane drift` renders a null result as the clean state — no drift is the + * expected answer, and an empty table would read like a failure. + */ +function formatLaneDrift(value: unknown): string { + if (value == null) return "No branch drift: this lane is on its recorded branch."; + const record = firstRecord(value, ["drift", "result"]) ?? (isRecord(value) ? value : {}); + const lane = firstRecord(record, ["lane"]); + const resolution = asString(record.resolution); + if (resolution) { + return renderKeyValues("ADE lane branch drift resolved", [ + ["lane", lane?.id ?? record.laneId], + ["resolution", resolution], + ["previous branch", record.previousBranchRef], + ["branch", record.branchRef], + ["previous lane name", record.previousLaneName], + ["lane name", record.laneName], + ]); + } + const headBranchRef = asString(record.headBranchRef); + if (!headBranchRef) return "No branch drift: this lane is on its recorded branch."; + return renderKeyValues("ADE lane branch drift", [ + ["expected branch", record.expectedBranchRef], + ["head branch", headBranchRef], + [ + "resolve with", + "ade lane drift resolve --lane --switch-back | --keep-head", + ], + ]); +} + function formatChatRead(value: unknown): string { const entries = Array.isArray(value) ? value.filter(isRecord) @@ -18155,6 +18531,10 @@ function formatTextOutput( return formatChatList(value); case "chat-read": return formatChatRead(value); + case "session-lifecycle": + return formatSessionLifecycle(value); + case "lane-drift": + return formatLaneDrift(value); case "scheduled-work-create": { const result = isRecord(value) && isRecord(value.result) ? value.result : value; const item = isRecord(result) && isRecord(result.item) ? result.item : {}; @@ -19679,6 +20059,8 @@ if (/(^|[/\\])cli\.(?:ts|js|cjs)$/.test(process.argv[1] ?? "")) { export { buildCliPlan, buildAdeCodeArgs, + parseSnoozeDurationMs, + resolveSnoozeUntilIso, checkLinearReadiness, detectUnmergedLaneCreateNudge, findProjectRoots, diff --git a/apps/ade-cli/src/services/sync/syncHostService.test.ts b/apps/ade-cli/src/services/sync/syncHostService.test.ts index f17f68295..f4d3f5269 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.test.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.test.ts @@ -4289,16 +4289,25 @@ describe("CTO-gated Linear sync commands", () => { "cto.completeLinearMobileOAuth", "cto.setLinearToken", "cto.clearLinearToken", + "session.settleSessions", + "session.unsettleSessions", + "session.setSettleOverride", + "session.snoozeSession", + "session.wakeSession", + "session.clearWokeMarker", ]); expect(MOBILE_SYNC_REQUIRED_REMOTE_COMMAND_ACTIONS).not.toEqual( expect.arrayContaining([...MOBILE_SYNC_OPTIONAL_REMOTE_COMMAND_ACTIONS]), ); for (const action of MOBILE_SYNC_OPTIONAL_REMOTE_COMMAND_ACTIONS) { - expect(actions).toContainEqual({ + // Policy shape varies (lifecycle mutations are additionally queueable); + // what matters for feature detection is that the action is advertised + // and viewer-allowed. + expect(actions).toContainEqual(expect.objectContaining({ action, scope: "project", - policy: { viewerAllowed: true }, - }); + policy: expect.objectContaining({ viewerAllowed: true }), + })); const requestId = `viewer-${action}`; peer.ws.send(encodeSyncEnvelope({ diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts index 7891e8b04..9d07a8e65 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it, vi } from "vitest"; import type { SyncCommandPayload, SyncPairingConnectInfo, SyncWebPairingInfo } from "../../../../desktop/src/shared/types"; import { parsePairingQrText } from "../../../../desktop/src/shared/pairingQr"; import { deriveDeterministicLaneNameFromPrompt } from "../../../../desktop/src/shared/laneNameFallback"; +import { MOBILE_SYNC_OPTIONAL_REMOTE_COMMAND_ACTIONS } from "../../../../desktop/src/shared/syncMobileCompatibility"; import { createSyncRemoteCommandService } from "./syncRemoteCommandService"; function makePayload( @@ -22,6 +23,7 @@ function createService(options?: { externalSessionsService?: Record; gitService?: Record; githubService?: Record; + laneService?: Record; operationService?: Record; prService?: Record; prSummaryService?: Record; @@ -69,7 +71,7 @@ function createService(options?: { const service = createSyncRemoteCommandService({ ...(options?.db ? { db: options.db } : {}), ...(options?.projectRoot ? { projectRoot: options.projectRoot } : {}), - laneService: {}, + laneService: options?.laneService ?? {}, prService: options?.prService ?? {}, ...(options?.prSummaryService ? { prSummaryService: options.prSummaryService } : {}), ...(options?.queueLandingService ? { queueLandingService: options.queueLandingService } : {}), @@ -2040,6 +2042,173 @@ describe("createSyncRemoteCommandService", () => { }); }); +describe("session lifecycle remote commands", () => { + function createLifecycleService() { + const sessionService = { + settleSession: vi.fn(() => true), + unsettleSession: vi.fn(() => true), + settleSessions: vi.fn(() => ["session-1"]), + unsettleSessions: vi.fn(), + snoozeSession: vi.fn(() => true), + snoozeSessions: vi.fn(() => ["session-1"]), + wakeSession: vi.fn(() => true), + wakeSessions: vi.fn(() => ["session-1"]), + setSettleOverride: vi.fn(() => true), + clearWokeMarker: vi.fn(() => true), + get: vi.fn(() => ({ id: "session-1", toolType: "codex-chat" })), + }; + const { service } = createService({ sessionService }); + return { service, sessionService }; + } + + it("settles and unsettles a session for clients with no local DB", async () => { + const { service, sessionService } = createLifecycleService(); + await expect(service.execute(makePayload("session.settleSession", { + sessionId: "session-1", + outcome: "PR #841 merged", + }))).resolves.toEqual({ ok: true, sessionId: "session-1" }); + expect(sessionService.settleSession).toHaveBeenCalledWith("session-1", { outcome: "PR #841 merged" }); + + await expect(service.execute(makePayload("session.unsettleSession", { sessionId: "session-1" }))) + .resolves.toEqual({ ok: true, sessionId: "session-1" }); + expect(sessionService.unsettleSession).toHaveBeenCalledWith("session-1"); + }); + + it("normalizes the snooze deadline and rejects unparseable ones", async () => { + const { service, sessionService } = createLifecycleService(); + await expect(service.execute(makePayload("session.snoozeSession", { + sessionId: "session-1", + untilIso: "2026-07-26T18:00:00Z", + }))).resolves.toEqual({ + ok: true, + sessionId: "session-1", + snoozedUntil: "2026-07-26T18:00:00.000Z", + }); + expect(sessionService.snoozeSession).toHaveBeenCalledWith("session-1", "2026-07-26T18:00:00.000Z"); + + await expect(service.execute(makePayload("session.snoozeSession", { sessionId: "session-1" }))) + .rejects.toThrow(/untilIso/); + await expect(service.execute(makePayload("session.snoozeSession", { + sessionId: "session-1", + untilIso: "tomorrow", + }))).rejects.toThrow(/ISO-8601/); + await expect(service.execute(makePayload("session.snoozeSessions", { + sessionIds: [], + untilIso: "2026-07-26T18:00:00Z", + }))).rejects.toThrow(/at least one session id/); + }); + + it("defaults the wake reason to manual and validates the union", async () => { + const { service, sessionService } = createLifecycleService(); + await expect(service.execute(makePayload("session.wakeSession", { sessionId: "session-1" }))) + .resolves.toEqual({ ok: true, sessionId: "session-1", reason: "manual" }); + expect(sessionService.wakeSession).toHaveBeenCalledWith("session-1", "manual"); + + await expect(service.execute(makePayload("session.wakeSessions", { + sessionIds: ["session-1"], + reason: "turn_complete", + }))).resolves.toEqual(["session-1"]); + expect(sessionService.wakeSessions).toHaveBeenCalledWith(["session-1"], "turn_complete"); + + await expect(service.execute(makePayload("session.wakeSession", { + sessionId: "session-1", + reason: "vibes", + }))).rejects.toThrow(/reason must be one of/); + }); + + it("guards the settle override union and clears the woke marker", async () => { + const { service, sessionService } = createLifecycleService(); + await expect(service.execute(makePayload("session.setSettleOverride", { + sessionId: "session-1", + override: "active", + }))).resolves.toEqual({ ok: true, sessionId: "session-1", settleOverride: "active" }); + expect(sessionService.setSettleOverride).toHaveBeenCalledWith("session-1", "active"); + + await expect(service.execute(makePayload("session.setSettleOverride", { + sessionId: "session-1", + override: null, + }))).resolves.toEqual({ ok: true, sessionId: "session-1", settleOverride: null }); + + await expect(service.execute(makePayload("session.setSettleOverride", { + sessionId: "session-1", + override: "snoozed", + }))).rejects.toThrow(/'settled', 'active', or null/); + + await expect(service.execute(makePayload("session.clearWokeMarker", { sessionId: "session-1" }))) + .resolves.toEqual({ ok: true, sessionId: "session-1" }); + expect(sessionService.clearWokeMarker).toHaveBeenCalledWith("session-1"); + }); +}); + +describe("lanes branch drift remote commands", () => { + it("reads and resolves branch drift over sync", async () => { + const getBranchDrift = vi.fn(async () => ({ + expectedBranchRef: "ade/feature", + headBranchRef: "hotfix-auth", + })); + const resolveBranchDrift = vi.fn(async () => ({ resolution: "keep-head" })); + const { service: driftService } = createService({ + laneService: { getBranchDrift, resolveBranchDrift }, + }); + + await expect(driftService.execute(makePayload("lanes.getBranchDrift", { laneId: "lane-1" }))) + .resolves.toEqual({ expectedBranchRef: "ade/feature", headBranchRef: "hotfix-auth" }); + await expect(driftService.execute(makePayload("lanes.getBranchDrift", {}))) + .rejects.toThrow(/laneId/); + + await expect(driftService.execute(makePayload("lanes.resolveBranchDrift", { + laneId: "lane-1", + resolution: "keep-head", + expectedHeadBranchRef: "hotfix-auth", + }))).resolves.toEqual({ resolution: "keep-head" }); + expect(resolveBranchDrift).toHaveBeenCalledWith({ + laneId: "lane-1", + resolution: "keep-head", + expectedHeadBranchRef: "hotfix-auth", + }); + + await expect(driftService.execute(makePayload("lanes.resolveBranchDrift", { + laneId: "lane-1", + resolution: "rebase", + }))).rejects.toThrow(/'switch-back' or 'keep-head'/); + }); +}); + +describe("mobile lifecycle command contract", () => { + it("advertises the phone's lifecycle actions and accepts its null-free clear sentinel", async () => { + const setSettleOverride = vi.fn(() => true); + const { service } = createService({ + sessionService: { + settleSessions: vi.fn(() => ["session-1"]), + unsettleSessions: vi.fn(), + setSettleOverride, + snoozeSession: vi.fn(() => true), + wakeSession: vi.fn(() => true), + clearWokeMarker: vi.fn(() => true), + }, + }); + + // iOS gates its lifecycle UI on these appearing in hello_ok's descriptor + // list, so a missing registration silently hides the whole feature. + for (const action of MOBILE_SYNC_OPTIONAL_REMOTE_COMMAND_ACTIONS) { + if (!action.startsWith("session.")) continue; + expect(service.getDescriptor(action)).toEqual({ + action, + scope: "project", + policy: { viewerAllowed: true, queueable: true }, + }); + } + + // The phone cannot put a JSON null in its [String: Any] arg dict, so it + // sends the "clear" sentinel instead. + await expect(service.execute(makePayload("session.setSettleOverride", { + sessionId: "session-1", + override: "clear", + }))).resolves.toEqual({ ok: true, sessionId: "session-1", settleOverride: null }); + expect(setSettleOverride).toHaveBeenCalledWith("session-1", null); + }); +}); + describe("prs.land", () => { it("forwards bypass + editable commit message to prService.land", async () => { const land = vi.fn().mockResolvedValue({ prId: "pr-1", success: true }); diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index 422ae95ea..9117edf4b 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -178,6 +178,9 @@ import type { SyncStartCliSessionResult, SyncWebPairingInfo, SyncRunQuickCommandArgs, + LaneBranchDriftResolution, + SessionSettleOverride, + SessionWakeReason, UpdateSessionMetaArgs, UpdateIntegrationProposalArgs, TerminalToolType, @@ -188,6 +191,10 @@ import type { WriteTextAtomicArgs, } from "../../../../desktop/src/shared/types"; import { isAdeUsageRangePreset, isAdeUsageScope } from "../../../../desktop/src/shared/types"; +import { + parseSessionSettleOverride, + SESSION_WAKE_REASONS, +} from "../../../../desktop/src/shared/types"; import type { OrchestrationRunCreateRequest } from "../../../../desktop/src/shared/types/orchestration"; import { PERSONAL_CHAT_ACTIONS, @@ -266,6 +273,7 @@ import type { createUsageTrackingService } from "../../../../desktop/src/main/se import type { ProductAnalyticsService } from "../../../../desktop/src/main/services/analytics/productAnalyticsService"; import { parseProductAnalyticsCapture } from "../../../../desktop/src/shared/types/productAnalytics"; import { deleteTerminalSessionWithRuntimeCleanup } from "../../../../desktop/src/main/services/sessions/deleteTerminalSession"; +import { settleTerminalSession } from "../../../../desktop/src/main/services/sessions/settleTerminalSession"; import type { createSessionDeltaService } from "../../../../desktop/src/main/services/sessions/sessionDeltaService"; import type { createSessionService } from "../../../../desktop/src/main/services/sessions/sessionService"; import { getSharedModelPickerStore, type ModelPickerStore } from "../modelPickerStore"; @@ -684,6 +692,56 @@ function parseSessionIdArgs(value: Record, action: string): { s }; } +const REMOTE_WAKE_REASONS: readonly SessionWakeReason[] = SESSION_WAKE_REASONS; + +function parseRemoteSessionIds(value: Record, action: string): string[] { + if (!Array.isArray(value.sessionIds)) throw new Error(`${action} requires a sessionIds array.`); + const ids = value.sessionIds.filter( + (id): id is string => typeof id === "string" && id.trim().length > 0, + ); + if (!ids.length) throw new Error(`${action} requires at least one session id.`); + return ids; +} + +/** + * Snooze deadlines arrive from clients that have no local clock authority, so + * they must be a parseable ISO timestamp; `sessionService` would otherwise + * silently return false and the client would show a no-op. + */ +function parseRemoteSnoozeDeadline(value: Record, action: string): string { + const raw = asTrimmedString(value.untilIso) ?? asTrimmedString(value.snoozedUntil); + if (!raw) throw new Error(`${action} requires an ISO-8601 untilIso.`); + const parsed = new Date(raw); + if (Number.isNaN(parsed.getTime())) { + throw new Error(`${action} requires an ISO-8601 untilIso; received '${raw}'.`); + } + return parsed.toISOString(); +} + +function parseRemoteWakeReason(value: unknown, action: string): SessionWakeReason { + if (value == null || value === "") return "manual"; + if (typeof value === "string" && (REMOTE_WAKE_REASONS as readonly string[]).includes(value)) { + return value as SessionWakeReason; + } + throw new Error(`${action} reason must be one of: ${REMOTE_WAKE_REASONS.join(", ")}.`); +} + +function parseRemoteSettleOverride(value: unknown, action: string): SessionSettleOverride | null { + const parsed = parseSessionSettleOverride(value); + if (parsed === undefined) { + throw new Error(`${action} override must be 'settled', 'active', or null.`); + } + return parsed; +} + +function parseRemoteBranchDriftResolution( + value: unknown, + action: string, +): LaneBranchDriftResolution { + if (value === "switch-back" || value === "keep-head") return value; + throw new Error(`${action} resolution must be 'switch-back' or 'keep-head'.`); +} + function parseAgentChatContextUsageArgs(value: Record): AgentChatContextUsageArgs { return parseSessionIdArgs(value, "chat.getContextUsage"); } @@ -3362,15 +3420,21 @@ function sessionStatusBucket(argsIn: { lastOutputPreview: string | null | undefined; runtimeState?: string | null; settledAt?: string | null; + settleOverride?: "settled" | "active" | null; attentionRequestedAt?: string | null; lastTurnFailedAt?: string | null; }): "running" | "awaiting-input" | "ended" { // Mirrors the settled-tier precedence in shared/sessionCanonicalState.ts: // an escalated ask outranks everything; a declared settle is the quiet // bucket but only AT REST (background wakes count as running); a dead chat - // turn is not running. + // turn is not running. The tri-state override is consulted at that same + // declared-settle tier: "active" is an explicit keep-active pin that + // suppresses settle, "settled" behaves like a declared settle. if (argsIn.attentionRequestedAt) return "awaiting-input"; - if (argsIn.settledAt && (argsIn.status !== "running" || argsIn.runtimeState === "idle")) return "ended"; + const effectiveSettled = argsIn.settleOverride === "active" + ? false + : argsIn.settleOverride === "settled" || Boolean(argsIn.settledAt); + if (effectiveSettled && (argsIn.status !== "running" || argsIn.runtimeState === "idle")) return "ended"; if (argsIn.lastTurnFailedAt) return "ended"; if (argsIn.status === "running") { if (argsIn.runtimeState === "waiting-input") return "awaiting-input"; @@ -3388,11 +3452,18 @@ function sessionStatusBucket(argsIn: { function summarizeLaneRuntime( laneId: string, + // Declares every field sessionStatusBucket actually reads. These arrive on the + // rows from sessionService.list() at runtime; leaving them off the type meant the + // settled tier looked dead here even though it was being evaluated. sessions: Array<{ laneId: string; status: string; lastOutputPreview: string | null; runtimeState?: string | null; + settledAt?: string | null; + settleOverride?: "settled" | "active" | null; + attentionRequestedAt?: string | null; + lastTurnFailedAt?: string | null; }>, ): LaneListSnapshot["runtime"] { let runningCount = 0; @@ -3555,6 +3626,19 @@ type RemoteCommandRegistrationDeps = { function registerLaneRemoteCommands({ args, register }: RemoteCommandRegistrationDeps): void { register("lanes.list", { viewerAllowed: true }, async (payload) => args.laneService.list(parseListLanesArgs(payload))); register("lanes.listDeleteProgress", { viewerAllowed: true }, async () => args.laneService.listDeleteProgress()); + register("lanes.getBranchDrift", { viewerAllowed: true }, async (payload) => + args.laneService.getBranchDrift({ + laneId: requireString(payload.laneId, "lanes.getBranchDrift requires laneId."), + })); + register("lanes.resolveBranchDrift", { viewerAllowed: true, queueable: true }, async (payload) => { + const expectedHeadBranchRef = asTrimmedString(payload.expectedHeadBranchRef); + return args.laneService.resolveBranchDrift({ + laneId: requireString(payload.laneId, "lanes.resolveBranchDrift requires laneId."), + resolution: parseRemoteBranchDriftResolution(payload.resolution, "lanes.resolveBranchDrift"), + ...(expectedHeadBranchRef ? { expectedHeadBranchRef } : {}), + ...(payload.acknowledgeActiveWork === true ? { acknowledgeActiveWork: true } : {}), + }); + }); register("lanes.refreshSnapshots", { viewerAllowed: true }, async (payload) => { const listArgs = parseListLanesArgs(payload); const refreshed = await args.laneService.refreshSnapshots(listArgs); @@ -3770,6 +3854,69 @@ function registerWorkRemoteCommands({ args, register }: RemoteCommandRegistratio args.sessionService.updateMeta(parseUpdateSessionMetaArgs(payload)); return { ok: true }; }); + // --------------------------------------------------------------------- + // Session lifecycle over sync. Mobile and the hosted web client have no + // local DB, so settle/snooze/wake are only reachable through these. + // --------------------------------------------------------------------- + register("session.settleSession", { viewerAllowed: true, queueable: true }, async (payload) => { + const sessionId = requireString(payload.sessionId, "session.settleSession requires sessionId."); + const outcome = asTrimmedString(payload.outcome); + const settled = await settleTerminalSession({ + sessionId, + opts: { + ...(outcome ? { outcome } : {}), + ...(payload.dismissPendingInput === true ? { dismissPendingInput: true } : {}), + }, + sessionService: args.sessionService, + agentChatService: args.agentChatService ?? null, + ptyService: args.ptyService, + }); + if (!settled) throw new Error(`Session '${sessionId}' was not found.`); + return { ok: true, sessionId }; + }); + register("session.unsettleSession", { viewerAllowed: true, queueable: true }, async (payload) => { + const sessionId = requireString(payload.sessionId, "session.unsettleSession requires sessionId."); + return { ok: args.sessionService.unsettleSession(sessionId), sessionId }; + }); + register("session.settleSessions", { viewerAllowed: true, queueable: true }, async (payload) => + args.sessionService.settleSessions(parseRemoteSessionIds(payload, "session.settleSessions"))); + register("session.unsettleSessions", { viewerAllowed: true, queueable: true }, async (payload) => { + args.sessionService.unsettleSessions(parseRemoteSessionIds(payload, "session.unsettleSessions")); + return { ok: true }; + }); + register("session.snoozeSession", { viewerAllowed: true, queueable: true }, async (payload) => { + const sessionId = requireString(payload.sessionId, "session.snoozeSession requires sessionId."); + const untilIso = parseRemoteSnoozeDeadline(payload, "session.snoozeSession"); + const ok = args.sessionService.snoozeSession(sessionId, untilIso); + if (!ok) throw new Error(`Session '${sessionId}' was not found.`); + return { ok, sessionId, snoozedUntil: untilIso }; + }); + register("session.snoozeSessions", { viewerAllowed: true, queueable: true }, async (payload) => + args.sessionService.snoozeSessions( + parseRemoteSessionIds(payload, "session.snoozeSessions"), + parseRemoteSnoozeDeadline(payload, "session.snoozeSessions"), + )); + register("session.wakeSession", { viewerAllowed: true, queueable: true }, async (payload) => { + const sessionId = requireString(payload.sessionId, "session.wakeSession requires sessionId."); + const reason = parseRemoteWakeReason(payload.reason, "session.wakeSession"); + return { ok: args.sessionService.wakeSession(sessionId, reason), sessionId, reason }; + }); + register("session.wakeSessions", { viewerAllowed: true, queueable: true }, async (payload) => + args.sessionService.wakeSessions( + parseRemoteSessionIds(payload, "session.wakeSessions"), + parseRemoteWakeReason(payload.reason, "session.wakeSessions"), + )); + register("session.setSettleOverride", { viewerAllowed: true, queueable: true }, async (payload) => { + const sessionId = requireString(payload.sessionId, "session.setSettleOverride requires sessionId."); + const override = parseRemoteSettleOverride(payload.override, "session.setSettleOverride"); + const ok = args.sessionService.setSettleOverride(sessionId, override); + if (!ok) throw new Error(`Session '${sessionId}' was not found.`); + return { ok, sessionId, settleOverride: override }; + }); + register("session.clearWokeMarker", { viewerAllowed: true, queueable: true }, async (payload) => { + const sessionId = requireString(payload.sessionId, "session.clearWokeMarker requires sessionId."); + return { ok: args.sessionService.clearWokeMarker(sessionId), sessionId }; + }); register("work.runQuickCommand", { viewerAllowed: true, queueable: true }, async (payload) => { const parsed = parseQuickCommandArgs(payload); return await args.ptyService.create({ diff --git a/apps/ade-cli/src/sessionSnoozeDuration.ts b/apps/ade-cli/src/sessionSnoozeDuration.ts new file mode 100644 index 000000000..6b160be85 --- /dev/null +++ b/apps/ade-cli/src/sessionSnoozeDuration.ts @@ -0,0 +1,166 @@ +/** + * Snooze duration parsing, shared by the `ade session snooze` planner in + * `cli.ts` and the `ade code` TUI's `/session snooze` command. + * + * Extracted rather than duplicated: there must be exactly one answer to "what + * does `1.5h` mean" across the CLI and the terminal client, and exactly one cap + * on how far out a snooze may be parked. Snooze expiry is DERIVED everywhere + * (compare `snoozedUntil` to now) — nothing in here schedules anything. + * + * The functions return a result union instead of throwing so each surface can + * dress the failure in its own voice: `cli.ts` re-throws `CliUsageError` with + * the flag-worded `message`, the TUI switches on `code` to write terminal copy + * that never mentions a flag the user did not type. + */ + +import { snoozeDeadlineIso } from "../../desktop/src/renderer/lib/sessionSnooze"; + +const SNOOZE_UNIT_MS: Record = { + s: 1000, + m: 60 * 1000, + h: 60 * 60 * 1000, + d: 24 * 60 * 60 * 1000, + w: 7 * 24 * 60 * 60 * 1000, +}; + +/** Longest *relative* snooze we accept. `--for 400d` is almost certainly a typo, + * and there is no scheduler anywhere that could walk the deadline back. The cap + * is deliberately NOT applied to the open-ended "until asked" form below, which + * is a named intent rather than a mistyped number. */ +export const MAX_SNOOZE_MS = 30 * SNOOZE_UNIT_MS.d!; + +/** + * A deadline this far out is open-ended in every surface: desktop's + * `snoozeWakeLabel` prints "wakes when asked" instead of a countdown, so the CLI + * must not print the raw far-future timestamp either. Mirrors + * `INDEFINITE_LABEL_THRESHOLD_MS` in + * `desktop/src/renderer/lib/sessionSnooze.ts`, which is module-private there. + */ +export const INDEFINITE_SNOOZE_THRESHOLD_MS = 365 * SNOOZE_UNIT_MS.d!; + +/** + * Whether a stored deadline is the open-ended "until I'm asked" kind — the one + * the desktop/iOS preset writes (~100 years out) and that `--until-asked` + * reproduces. Callers use this to render "wakes when asked" instead of a date. + */ +export function isIndefiniteSnooze( + untilIso: string | null | undefined, + nowMs: number = Date.now(), +): boolean { + if (typeof untilIso !== "string" || !untilIso.trim()) return false; + const ms = Date.parse(untilIso); + if (!Number.isFinite(ms)) return false; + return ms - nowMs >= INDEFINITE_SNOOZE_THRESHOLD_MS; +} + +/** + * The open-ended deadline, delegated to the desktop preset so the CLI, the TUI, + * the desktop menu, and iOS all park an "until asked" row on the exact same + * instant. Nothing here schedules anything — expiry is still derived by + * comparing the deadline to now. + */ +export function untilAskedSnoozeIso(nowMs: number = Date.now()): string { + return snoozeDeadlineIso("asked", nowMs); +} + +export type SnoozeDurationErrorCode = "invalid" | "too-short" | "too-long"; + +export type SnoozeDurationResult = + | { ok: true; ms: number } + | { ok: false; code: SnoozeDurationErrorCode; message: string }; + +/** + * Parse a snooze duration: an integer or one-decimal amount plus a unit suffix + * (`30m`, `1h`, `1.5h`, `4h`, `1d`). A bare number is read as minutes so `90` + * does the obvious thing. + */ +export function parseSnoozeDuration(value: string): SnoozeDurationResult { + const invalid = { + ok: false, + code: "invalid", + message: "--for must be a positive duration such as 30m, 1h, 4h, or 1d.", + } as const; + const raw = value.trim().toLowerCase(); + const match = /^(\d+(?:\.\d+)?)\s*(s|m|h|d|w|sec|secs|min|mins|hour|hours|day|days|week|weeks)?$/.exec(raw); + if (!match) return invalid; + const amount = Number(match[1]); + if (!Number.isFinite(amount) || amount <= 0) return invalid; + const unit = (match[2] ?? "m").slice(0, 1); + const ms = Math.round(amount * SNOOZE_UNIT_MS[unit]!); + if (!Number.isSafeInteger(ms) || ms < 1000) { + return { ok: false, code: "too-short", message: "--for must resolve to at least one second." }; + } + if (ms > MAX_SNOOZE_MS) { + return { ok: false, code: "too-long", message: "--for must be 30d or less." }; + } + return { ok: true, ms }; +} + +export type SnoozeDeadlineErrorCode = + | SnoozeDurationErrorCode + | "both" + | "neither" + | "not-iso" + | "past"; + +export type SnoozeDeadlineResult = + | { ok: true; untilIso: string } + | { ok: false; code: SnoozeDeadlineErrorCode; message: string }; + +/** + * Resolve the snooze deadline from a relative duration, an absolute ISO + * instant, or the open-ended "until asked" form. Exactly one is required; an + * absolute deadline must parse and must be in the future, because a past + * deadline makes the row instantly visible again and reads as a silently + * ignored command. + * + * `untilAsked` exists so the CLI can express the same thing the desktop/iOS + * "Until I'm asked" preset writes. It bypasses `MAX_SNOOZE_MS` on purpose: the + * cap guards mistyped relative durations, and refusing the named form here + * would leave a deadline ADE's own UI creates unreachable from the CLI. + */ +export function resolveSnoozeUntil( + args: { forValue: string | null; untilValue: string | null; untilAsked?: boolean }, + now: number = Date.now(), +): SnoozeDeadlineResult { + const { forValue, untilValue } = args; + const untilAsked = args.untilAsked === true; + if (forValue != null && untilValue != null) { + return { ok: false, code: "both", message: "Use either --for or --until, not both." }; + } + if (untilAsked && (forValue != null || untilValue != null)) { + return { + ok: false, + code: "both", + message: "Use either --until-asked or a --for/--until deadline, not both.", + }; + } + if (untilAsked) { + return { ok: true, untilIso: untilAskedSnoozeIso(now) }; + } + if (forValue != null) { + const parsed = parseSnoozeDuration(forValue); + if (!parsed.ok) return parsed; + return { ok: true, untilIso: new Date(now + parsed.ms).toISOString() }; + } + if (untilValue != null) { + const parsed = new Date(untilValue.trim()); + if (Number.isNaN(parsed.getTime())) { + return { + ok: false, + code: "not-iso", + message: `--until must be an ISO-8601 timestamp such as 2026-07-26T18:00:00Z; received '${untilValue}'.`, + }; + } + if (parsed.getTime() <= now) { + return { ok: false, code: "past", message: "--until must be in the future." }; + } + return { ok: true, untilIso: parsed.toISOString() }; + } + return { + ok: false, + code: "neither", + message: + "Pass --for <30m|1h|4h|1d> or --until , or --until-asked to snooze until a hand-raise.", + }; +} diff --git a/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts b/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts index 2ddc1c0d3..b9ffcca54 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts @@ -3,7 +3,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { AgentChatEventEnvelope } from "../../../../desktop/src/shared/types/chat"; -import { archiveChatSession, buildPtyContinuationLaunchFields, cancelSteerMessage, createChatSession, DEFAULT_CODEX_REASONING_EFFORT, deleteChatSession, deriveClaudeGoalFromEvents, dispatchSteerMessage, discoverProjectSlashCommands, editSteerMessage, enrichChatSessionsWithLifecycle, enrichTerminalSessionsWithLifecycle, getAvailableModels, getChatHistoryPage, getMainTranscript, latestGoal, latestTokenStats, listChatSessions, listLaneDiffStats, listPrsByLane, listSessionSummaries, listTerminalSessions, messageChatSession, recoverCodexTurn, recoverTurn, requestSessionAttention, resolveUnprocessedMessage, resumeTerminalSession, runDefaultLaneSetup, sendChatMessage, setSessionStatusNote, settleSession, signalTerminal, startCliTerminalSession, steerChatMessage, trackedCliTerminalProvider, unarchiveChatSession, unsettleSession } from "../adeApi"; +import { archiveChatSession, buildPtyContinuationLaunchFields, cancelSteerMessage, clearSessionWokeMarker, createChatSession, DEFAULT_CODEX_REASONING_EFFORT, deleteChatSession, deriveClaudeGoalFromEvents, dispatchSteerMessage, discoverProjectSlashCommands, editSteerMessage, enrichChatSessionsWithLifecycle, enrichTerminalSessionsWithLifecycle, getAvailableModels, getChatHistoryPage, getMainTranscript, latestGoal, latestTokenStats, listChatSessions, listLaneDiffStats, listPrsByLane, listSessionSummaries, listTerminalSessions, messageChatSession, recoverCodexTurn, recoverTurn, requestSessionAttention, resolveUnprocessedMessage, resumeTerminalSession, runDefaultLaneSetup, sendChatMessage, setSessionSettleOverride, setSessionStatusNote, settleSession, signalTerminal, snoozeSession, startCliTerminalSession, steerChatMessage, trackedCliTerminalProvider, unarchiveChatSession, unsettleSession, wakeSession } from "../adeApi"; import type { ChatTerminalSession, TerminalSessionSummary } from "../../../../desktop/src/shared/types/sessions"; import type { AdeCodeConnection } from "../types"; @@ -140,6 +140,64 @@ describe("session lifecycle parity", () => { ["session", "unsettleSelfSession", { sessionId: "session-1" }], ]); }); + + it("maps snooze, wake, settle-override, and clear-woke onto the session action domain", async () => { + const action = vi.fn().mockResolvedValue({ ok: true }); + const connection = { action } as unknown as AdeCodeConnection; + + await snoozeSession(connection, "session-1", "2026-07-26T18:00:00.000Z"); + await wakeSession(connection, "session-1", "manual"); + await setSessionSettleOverride(connection, "session-1", "active"); + await setSessionSettleOverride(connection, "session-1", null); + await clearSessionWokeMarker(connection, "session-1"); + + expect(action.mock.calls).toEqual([ + ["session", "snoozeSession", { sessionId: "session-1", untilIso: "2026-07-26T18:00:00.000Z" }], + ["session", "wakeSession", { sessionId: "session-1", reason: "manual" }], + ["session", "setSettleOverride", { sessionId: "session-1", override: "active" }], + // null is a real value here — it CLEARS the pin, so it must be sent, not dropped. + ["session", "setSettleOverride", { sessionId: "session-1", override: null }], + ["session", "clearWokeMarker", { sessionId: "session-1" }], + ]); + }); + + it("carries the snooze, woke, and settle-override columns onto enriched rows", () => { + const lifecycle = { + id: "session-1", + settledAt: null, + statusNote: null, + attentionRequestedAt: null, + attentionMessage: null, + lastTurnFailedAt: null, + settleOverride: "active", + snoozedUntil: "2026-07-26T18:00:00.000Z", + snoozedAt: "2026-07-26T12:00:00.000Z", + wokeAt: "2026-07-26T13:00:00.000Z", + wokeReason: "needs_you", + } as TerminalSessionSummary; + + const [chat] = enrichChatSessionsWithLifecycle([{ + sessionId: "session-1", + laneId: "lane-1", + provider: "codex", + model: "gpt-5.5", + status: "idle", + startedAt: "2026-07-26T11:00:00.000Z", + endedAt: null, + lastActivityAt: "2026-07-26T11:30:00.000Z", + lastOutputPreview: null, + summary: null, + nextWakeAt: null, + }], [lifecycle]); + + expect(chat).toMatchObject({ + settleOverride: "active", + snoozedUntil: "2026-07-26T18:00:00.000Z", + snoozedAt: "2026-07-26T12:00:00.000Z", + wokeAt: "2026-07-26T13:00:00.000Z", + wokeReason: "needs_you", + }); + }); }); describe("getMainTranscript", () => { diff --git a/apps/ade-cli/src/tuiClient/__tests__/sessionLifecycle.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/sessionLifecycle.test.tsx new file mode 100644 index 000000000..778d8ab5f --- /dev/null +++ b/apps/ade-cli/src/tuiClient/__tests__/sessionLifecycle.test.tsx @@ -0,0 +1,496 @@ +import React from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render } from "ink-testing-library"; +import type { AgentChatSessionSummary } from "../../../../desktop/src/shared/types/chat"; +import type { LaneSummary } from "../../../../desktop/src/shared/types/lanes"; +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, + resolveSnoozeFreeText, + sessionLifecycleCommandFor, + sessionLifecycleMarker, + shouldClearWokeMarkerOnVisit, +} from "../sessionLifecycle"; + +const NOW = Date.parse("2026-07-26T12:00:00.000Z"); + +/** + * Strips BOTH SGR color and every other CSI sequence, then asserts the result + * still carries the state. This is the no-color legibility check: whatever the + * drawer says about snooze/settle/wake has to survive a terminal that renders + * no styling at all. + */ +function stripAnsi(text: string): string { + return text.replace(/\[[0-?]*[ -/]*[@-~]/g, ""); +} + +/** The stripped row a session's title appears on. */ +function rowFor(frame: string, title: string): string { + return frame.split("\n").find((line) => line.includes(title)) ?? ""; +} + +function lane(id: string, name: string): LaneSummary { + return { + id, + name, + laneType: "worktree", + baseRef: "main", + branchRef: `feat/${id}`, + worktreePath: `/tmp/${id}`, + 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-26T10:00:00.000Z", + }; +} + +function session(overrides: Partial & { sessionId: string }): TuiChatSessionSummary { + return { + laneId: "lane-1", + provider: "codex", + model: "gpt-5.5", + title: overrides.sessionId, + status: "idle", + startedAt: "2026-07-26T11:00:00.000Z", + endedAt: null, + lastActivityAt: "2026-07-26T11:30:00.000Z", + lastOutputPreview: null, + summary: null, + nextWakeAt: null, + ...overrides, + } as TuiChatSessionSummary; +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("/session slash commands", () => { + it("registers every lifecycle verb with a description and an optional-id hint", () => { + for (const name of [ + "/session snooze", + "/session wake", + "/session settle", + "/session unsettle", + "/session keep-active", + ]) { + const spec = BUILTIN_COMMANDS.find((command) => command.name === name); + expect(spec, name).toBeDefined(); + expect(spec!.description.length).toBeGreaterThan(0); + expect(spec!.placement).toBe("right"); + expect(spec!.argumentHint).toContain("session-id"); + expect(sessionLifecycleCommandFor(name)).not.toBeNull(); + } + }); + + it("parses the multi-word forms without shadowing each other, and surfaces them in the palette", () => { + expect(parseCommand("/session snooze abc 1h")?.name).toBe("/session snooze"); + expect(parseCommand("/session snooze abc 1h")?.args).toBe("abc 1h"); + expect(parseCommand("/session keep-active")?.name).toBe("/session keep-active"); + expect(parseCommand("/session unsettle sess-9")?.args).toBe("sess-9"); + + expect(paletteCommands("/session sn")).toContainEqual(expect.objectContaining({ + name: "/session snooze", + source: "ade", + })); + }); + + it("routes a bare /session to the right pane rather than into the chat", () => { + const parsed = parseCommand("/session"); + expect(parsed?.name).toBe("/session"); + expect(parsed?.spec?.placement).toBe("right"); + expect(sessionLifecycleCommandFor("/session")).toBeNull(); + }); + + it("keeps the pre-existing active-only /chat settle and /chat unsettle working", () => { + expect(parseCommand("/chat settle PR merged")?.name).toBe("/chat settle"); + expect(parseCommand("/chat settle PR merged")?.args).toBe("PR merged"); + expect(parseCommand("/chat unsettle")?.name).toBe("/chat unsettle"); + // They are NOT routed through the /session dispatcher. + expect(sessionLifecycleCommandFor("/chat settle")).toBeNull(); + expect(sessionLifecycleCommandFor("/chat unsettle")).toBeNull(); + }); +}); + +describe("per-session targeting", () => { + const known = ["sess-alpha-1111", "sess-beta-2222", "sess-beta-3333"]; + + it("falls back to the active session when no id is given", () => { + const resolved = resolveSessionTarget({ + input: "", + activeSessionId: "sess-alpha-1111", + knownSessionIds: known, + }); + expect(resolved).toEqual({ ok: true, sessionId: "sess-alpha-1111", explicit: false, rest: "" }); + }); + + it("targets an explicit id and hands the remaining text back as the verb's argument", () => { + const resolved = resolveSessionTarget({ + input: "sess-beta-2222 1h", + activeSessionId: "sess-alpha-1111", + knownSessionIds: known, + }); + expect(resolved).toEqual({ ok: true, sessionId: "sess-beta-2222", explicit: true, rest: "1h" }); + }); + + it("resolves an unambiguous id prefix but refuses an ambiguous one", () => { + expect(resolveSessionTarget({ + input: "sess-beta-22", + activeSessionId: null, + knownSessionIds: known, + })).toMatchObject({ ok: true, sessionId: "sess-beta-2222", explicit: true }); + + expect(resolveSessionTarget({ + input: "sess-beta", + activeSessionId: "sess-alpha-1111", + knownSessionIds: known, + })).toMatchObject({ ok: false, code: "ambiguous-session" }); + }); + + it("reads a lone duration as a duration, not as a session id", () => { + // The whole point of matching against known ids first: `/session snooze 1h` + // must snooze the ACTIVE session for an hour. + const resolved = resolveSessionTarget({ + input: "1h", + activeSessionId: "sess-alpha-1111", + knownSessionIds: known, + }); + expect(resolved).toEqual({ ok: true, sessionId: "sess-alpha-1111", explicit: false, rest: "1h" }); + }); + + it("rejects a stray leading token for the verbs that take no argument", () => { + expect(resolveSessionTarget({ + input: "sess-nope", + activeSessionId: "sess-alpha-1111", + knownSessionIds: known, + strictLeadingToken: true, + })).toMatchObject({ ok: false, code: "unknown-session" }); + + // …but the same token is passed through as an outcome for settle. + expect(resolveSessionTarget({ + input: "shipped it", + activeSessionId: "sess-alpha-1111", + knownSessionIds: known, + })).toMatchObject({ ok: true, sessionId: "sess-alpha-1111", explicit: false, rest: "shipped it" }); + }); + + it("reports a missing target instead of silently doing nothing", () => { + expect(resolveSessionTarget({ + input: "", + activeSessionId: null, + knownSessionIds: known, + })).toMatchObject({ ok: false, code: "no-active" }); + }); +}); + +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", + "Until I'm asked", + ]); + }); + + it("resolves a menu choice to a concrete future deadline", () => { + const resolved = resolveSnoozeChoice("hour", NOW); + expect(resolved.ok).toBe(true); + if (!resolved.ok) return; + expect(Date.parse(resolved.untilIso)).toBe(NOW + 60 * 60_000); + expect(resolved.confirmation).toBe("Snoozed for 1 hour."); + }); + + 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; + expect(Date.parse(resolved.untilIso)).toBe(NOW + 3 * 60 * 60_000); + expect(resolved.confirmation).toBe("Snoozed · wakes in 3h."); + }); + + it("rewrites the CLI's flag-worded failures into terminal copy", () => { + expect(resolveSnoozeFreeText("soon", NOW)).toEqual({ + ok: false, + message: "'soon' is not a duration. Try 30m, 1h, 4h, or 1d.", + }); + // The cap on relative durations points at the open-ended choice instead of + // dead-ending — that choice is how a >30d intent is actually expressed. + expect(resolveSnoozeFreeText("31d", NOW)).toEqual({ + ok: false, + message: "Snooze for 30d or less, or pick \"Until I'm asked\" for an open-ended snooze.", + }); + }); +}); + +describe("lifecycle markers", () => { + it("labels a snoozed row with the shared wake copy and never from a phase check", () => { + const marker = sessionLifecycleMarker( + { snoozedUntil: new Date(NOW + 3 * 60 * 60_000).toISOString(), snoozedAt: new Date(NOW).toISOString() }, + { nowMs: NOW }, + ); + expect(marker).toEqual({ kind: "snoozed", glyph: "z", text: "z wakes in 3h" }); + }); + + it("prefers a live snooze over a settled state — snooze is an overlay, not a phase", () => { + const marker = sessionLifecycleMarker( + { + settledAt: "2026-07-26T11:00:00.000Z", + snoozedUntil: new Date(NOW + 30 * 60_000).toISOString(), + snoozedAt: new Date(NOW).toISOString(), + }, + { nowMs: NOW }, + ); + expect(marker?.kind).toBe("snoozed"); + expect(marker?.text).toBe("z wakes in 30m"); + }); + + it("explains why a woken row came back", () => { + expect(sessionLifecycleMarker( + { wokeAt: "2026-07-26T11:59:00.000Z", wokeReason: "needs_you" }, + { nowMs: NOW }, + )).toEqual({ kind: "woke", glyph: "*", text: "* needs approval" }); + expect(sessionLifecycleMarker( + { wokeAt: "2026-07-26T11:59:00.000Z", wokeReason: "error" }, + { nowMs: NOW }, + )?.text).toBe("* errored"); + expect(sessionLifecycleMarker( + { wokeAt: "2026-07-26T11:59:00.000Z", wokeReason: "turn_complete" }, + { nowMs: NOW }, + )?.text).toBe("* turn finished"); + }); + + it("files a settled row in the quiet tier, with its outcome when it left one", () => { + expect(sessionLifecycleMarker({ settledAt: "2026-07-26T11:00:00.000Z" }, { nowMs: NOW })) + .toEqual({ kind: "settled", glyph: "", text: "done" }); + expect(sessionLifecycleMarker( + { settledAt: "2026-07-26T11:00:00.000Z" }, + { note: "PR merged", nowMs: NOW }, + )?.text).toBe("done: PR merged"); + }); + + it("honours the tri-state override: a keep-active pin suppresses the quiet tier", () => { + expect(sessionLifecycleMarker( + { settledAt: "2026-07-26T11:00:00.000Z", settleOverride: "active" }, + { nowMs: NOW }, + )).toBeNull(); + // …and a "settled" override settles a row that never declared one. + expect(sessionLifecycleMarker({ settleOverride: "settled" }, { nowMs: NOW })?.kind).toBe("settled"); + }); + + it("leaves an ordinary row unmarked", () => { + expect(sessionLifecycleMarker({}, { nowMs: NOW })).toBeNull(); + }); + + // Regression: an "Until I'm asked" snooze (~100 years) marked a blocked row + // `z wakes when asked` forever. Every early-wake trigger was chat-only, and a + // tracked CLI row's needs-input state is derived with no event to hook — so a + // needs-you row must never READ as snoozed either. + it("does NOT mark a snoozed row as snoozed while it is asking for you", () => { + const snooze = { + snoozedUntil: new Date(NOW + 100 * 365 * 86_400_000).toISOString(), + snoozedAt: new Date(NOW).toISOString(), + }; + // Every deterministic hand-raise a text row can see. + expect(sessionLifecycleMarker({ ...snooze, runtimeState: "waiting-input" }, { nowMs: NOW })).toBeNull(); + expect(sessionLifecycleMarker({ ...snooze, awaitingInput: true }, { nowMs: NOW })).toBeNull(); + expect(sessionLifecycleMarker({ ...snooze, pendingInputItemId: "item-1" }, { nowMs: NOW })).toBeNull(); + expect(sessionLifecycleMarker( + { ...snooze, attentionRequestedAt: "2026-07-26T11:59:00.000Z" }, + { nowMs: NOW }, + )).toBeNull(); + + // …and the same row with no raised hand is still marked snoozed. + expect(sessionLifecycleMarker(snooze, { nowMs: NOW })).toEqual({ + kind: "snoozed", + glyph: "z", + text: "z wakes when asked", + }); + // The RAW read is unchanged — chips and wake copy still see the snooze. + expect(isSessionSnoozed(snooze, NOW)).toBe(true); + expect(isSessionFiledAsSnoozed(snooze, "needs_you", NOW)).toBe(false); + }); +}); + +describe("clearing the woke marker on visit", () => { + const clearable = { sessionId: "sess-persisted", wokeAt: "2026-07-26T11:58:00.000Z" }; + // A snooze that lapsed on its own. `sessionWokeMarker` still DERIVES a marker + // for this row, but the host never wrote one, so there is nothing to clear. + const derivedOnly = { + sessionId: "sess-derived", + wokeAt: null, + snoozedUntil: "2026-07-26T11:00:00.000Z", + snoozedAt: "2026-07-26T10:00:00.000Z", + }; + + it("fires the action when the opened row carries a persisted wokeAt", () => { + const clear = vi.fn().mockResolvedValue(undefined); + expect(clearWokeMarkerOnVisit({ + sessionId: "sess-persisted", + sessions: [derivedOnly, clearable], + clear, + })).toBe(true); + expect(clear).toHaveBeenCalledTimes(1); + expect(clear).toHaveBeenCalledWith("sess-persisted"); + }); + + it("does NOT fire for a purely derived marker, even though the row still shows one", () => { + // The row genuinely renders a marker… + expect(sessionLifecycleMarker(derivedOnly, { nowMs: NOW })).toMatchObject({ kind: "woke" }); + // …but nothing is persisted, so visiting it must not round-trip. + const clear = vi.fn().mockResolvedValue(undefined); + expect(clearWokeMarkerOnVisit({ + sessionId: "sess-derived", + sessions: [derivedOnly, clearable], + clear, + })).toBe(false); + expect(clear).not.toHaveBeenCalled(); + expect(shouldClearWokeMarkerOnVisit(derivedOnly)).toBe(false); + }); + + it("stays quiet for rows with no marker at all, unknown ids, and deselection", () => { + const clear = vi.fn().mockResolvedValue(undefined); + expect(clearWokeMarkerOnVisit({ sessionId: null, sessions: [clearable], clear })).toBe(false); + expect(clearWokeMarkerOnVisit({ sessionId: "sess-missing", sessions: [clearable], clear })).toBe(false); + expect(clearWokeMarkerOnVisit({ + sessionId: "sess-plain", + sessions: [{ sessionId: "sess-plain", wokeAt: null }], + clear, + })).toBe(false); + // Whitespace is not a marker. + expect(shouldClearWokeMarkerOnVisit({ wokeAt: " " })).toBe(false); + expect(clear).not.toHaveBeenCalled(); + }); + + it("never lets a failed clear block or delay opening the session", async () => { + const clear = vi.fn().mockRejectedValue(new Error("runtime went away")); + // Synchronous return, no throw: the caller proceeds to open the row. + expect(() => clearWokeMarkerOnVisit({ + sessionId: "sess-persisted", + sessions: [clearable], + clear, + })).not.toThrow(); + // And the rejection is swallowed rather than surfacing as an unhandled one. + await new Promise((resolve) => setImmediate(resolve)); + expect(clear).toHaveBeenCalledTimes(1); + }); +}); + +describe("session list legibility with no color at all", () => { + it("renders snoozed, woken, and settled rows as plain text in the lane drawer", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(NOW)); + + const frame = stripAnsi(render( + , + ).lastFrame() ?? ""); + + // Every state is carried by text on its OWN row: strip all styling and the + // three rows are still told apart without a single color. + expect(rowFor(frame, "Snoozed chat")).toContain("z wakes in 3h"); + expect(rowFor(frame, "Woken chat")).toContain("* needs approval"); + expect(rowFor(frame, "Settled chat")).toContain("done"); + expect(rowFor(frame, "Snoozed chat")).not.toContain("done"); + expect(rowFor(frame, "Settled chat")).not.toContain("wakes"); + // Nothing escaped the strip: no residual escape byte is doing the work. + expect(frame).not.toContain(""); + }); + + it("keeps the same markers in chats mode, where rows have no status suffix column", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(NOW)); + + const frame = stripAnsi(render( + , + ).lastFrame() ?? ""); + + expect(rowFor(frame, "Snoozed")).toContain("z wakes tomorrow"); + expect(rowFor(frame, "Settled")).toContain("done"); + }); + + it("does not mark a running session as done just because it once settled", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(NOW)); + + const running: AgentChatSessionSummary = session({ + sessionId: "chat-running", + title: "Running chat", + status: "active", + settledAt: "2026-07-26T11:00:00.000Z", + lastOutputPreview: "compiling", + }); + + const frame = stripAnsi(render( + , + ).lastFrame() ?? ""); + + expect(frame).not.toContain("done"); + expect(frame).toContain("compiling"); + }); +}); diff --git a/apps/ade-cli/src/tuiClient/adeApi.ts b/apps/ade-cli/src/tuiClient/adeApi.ts index a9bf8cc54..1b7585382 100644 --- a/apps/ade-cli/src/tuiClient/adeApi.ts +++ b/apps/ade-cli/src/tuiClient/adeApi.ts @@ -157,6 +157,15 @@ export type TuiSessionLifecycleFields = Pick< | "attentionRequestedAt" | "attentionMessage" | "lastTurnFailedAt" + // Lifecycle parity columns. `settleOverride` is the tri-state pin consulted + // at the declared-settle tier; the snooze pair is a VISIBILITY OVERLAY whose + // expiry is derived by comparing `snoozedUntil` to now (no timers anywhere); + // the woke pair explains why a snoozed row came back. + | "settleOverride" + | "snoozedUntil" + | "snoozedAt" + | "wokeAt" + | "wokeReason" >; export type TuiChatSessionSummary = AgentChatSessionSummary & TuiSessionLifecycleFields; @@ -179,6 +188,11 @@ function lifecycleFields( attentionRequestedAt: summary?.attentionRequestedAt ?? null, attentionMessage: summary?.attentionMessage ?? null, lastTurnFailedAt: summary?.lastTurnFailedAt ?? null, + settleOverride: summary?.settleOverride ?? null, + snoozedUntil: summary?.snoozedUntil ?? null, + snoozedAt: summary?.snoozedAt ?? null, + wokeAt: summary?.wokeAt ?? null, + wokeReason: summary?.wokeReason ?? null, }; } @@ -241,6 +255,51 @@ export async function unsettleSession( await connection.action("session", "unsettleSelfSession", { sessionId }); } +/** + * Park a session out of the attention surfaces until `untilIso`. Purely a + * visibility overlay — the canonical phase is untouched, and expiry is derived + * by every surface comparing the deadline to now rather than by a scheduler. + */ +export async function snoozeSession( + connection: AdeCodeConnection, + sessionId: string, + untilIso: string, +): Promise { + await connection.action("session", "snoozeSession", { sessionId, untilIso }); +} + +export async function wakeSession( + connection: AdeCodeConnection, + sessionId: string, + reason?: string, +): Promise { + await connection.action("session", "wakeSession", { + sessionId, + ...(reason ? { reason } : {}), + }); +} + +/** + * Set the tri-state settle override, consulted at the declared-settle tier + * BEFORE the derived exit-0 rule. "active" is the keep-active pin (the only way + * to hold a clean-exit row out of the quiet tier); null clears the pin. + */ +export async function setSessionSettleOverride( + connection: AdeCodeConnection, + sessionId: string, + override: "settled" | "active" | null, +): Promise { + await connection.action("session", "setSettleOverride", { sessionId, override }); +} + +/** Drop the "woke" marker a row carries until it is opened. */ +export async function clearSessionWokeMarker( + connection: AdeCodeConnection, + sessionId: string, +): Promise { + await connection.action("session", "clearWokeMarker", { sessionId }); +} + export async function getScheduledWorkState( connection: AdeCodeConnection, sessionId: string, diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index fe3a79689..64223633a 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -75,6 +75,7 @@ import { getScheduledWorkState, getStoredApiKeyProviders, getSubagentTranscript, + clearSessionWokeMarker, interruptChat, killDroidWorker, latestGoal, @@ -108,7 +109,10 @@ import { signalTerminal, setClaudeOutputStyle, setSessionStatusNote, + setSessionSettleOverride, settleSession, + snoozeSession, + wakeSession, startCliTerminalSession, type CliTerminalProvider, steerChatMessage, @@ -123,6 +127,17 @@ import { import { aggregateChatBlocks, derivePendingSteers, type AggregatedBlock } from "./aggregate"; import { deriveChatInfoSnapshot } from "./chatInfo"; import { BUILTIN_COMMANDS, paletteCommands, parseCommand } from "./commands"; +import { + SNOOZE_CHOICES, + resolveSessionTarget, + resolveSnoozeChoice, + resolveSnoozeFreeText, + clearWokeMarkerOnVisit as clearSessionWokeMarkerOnVisit, + sessionLifecycleCommandFor, + sessionLifecycleMarker, + type SessionLifecycleCommand, +} from "./sessionLifecycle"; +import type { SnoozeDurationKey } from "../../../desktop/src/renderer/lib/sessionSnooze"; import { buildHelpIndex, buildHelpRows, flattenHelpRows, pushRecent } from "./helpIndex"; import { hasFirstUserMessage, isPlanMode } from "./planMode"; import { connectToAde, INTERACTIVE_PROJECT_REGISTRATION } from "./connection"; @@ -841,7 +856,16 @@ export function shouldToggleLatestFailedLineOnBlankEnter(args: { } function openChatRightPaneRow(session: AgentChatSessionSummary, activeSessionId: string | null): string { - return `${session.sessionId === activeSessionId ? "●" : "○"} ${session.title ?? session.sessionId} · ${session.provider}`; + // Lifecycle rides as trailing TEXT ("z wakes in 3h", "* needs approval", + // "done"), never as color: the /chats list has to read the same in a + // monochrome terminal. + const lifecycle = session as TuiChatSessionSummary; + const marker = sessionLifecycleMarker( + { ...lifecycle, isActive: session.status === "active" }, + { note: lifecycle.statusNote ?? null }, + ); + const suffix = marker ? ` · ${marker.text}` : ""; + return `${session.sessionId === activeSessionId ? "●" : "○"} ${session.title ?? session.sessionId} · ${session.provider}${suffix}`; } export function chatSessionToOptimisticSummary( @@ -3123,6 +3147,14 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, const helpRecentsRef = useRef([]); helpRecentsRef.current = helpRecents; const rightChatsQueryRef = useRef(""); + // Session the open "Snooze session" duration palette will act on. Held in a + // ref rather than on the pane so the existing list-activation signature + // (selectedId + action kind) stays unchanged — the selected row is a duration + // key, not a session id. + const pendingSnoozeSessionIdRef = useRef(null); + // Latest session rows, readable from callbacks defined above the memo that + // builds them. Only used for lifecycle lookups on selection. + const displaySessionsRef = useRef([]); // Indexed (grouped, keybind-enriched) command reference. Rebuilt only when the // user's Claude keybinding registry changes, so keybind chips reflect config. const helpIndexGroups = useMemo(() => buildHelpIndex(BUILTIN_COMMANDS, keybindings), [keybindings]); @@ -3559,6 +3591,26 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, } }, [persistAdeCodeState, setChatScrollOffset]); + /** + * Drop a row's "woke" marker once the user has actually looked at it. + * + * Fire-and-forget by design: a failed clear must never block or delay opening + * the session, and it must never print — stray stdout would corrupt the Ink + * frame, so unlike desktop's console.error this swallows (iOS does the same + * with `try?`). The next refresh simply leaves the marker up. + */ + const clearWokeMarkerOnVisit = useCallback((sessionId: string | null): void => { + const conn = connectionRef.current; + if (!conn) return; + // The persisted-vs-derived guard and the fire-and-forget live in + // sessionLifecycle.ts so they are testable without rendering the app. + clearSessionWokeMarkerOnVisit({ + sessionId, + sessions: displaySessionsRef.current as TuiChatSessionSummary[], + clear: (id) => clearSessionWokeMarker(conn, id), + }); + }, []); + const selectActiveSessionId = useCallback((sessionId: string | null) => { if (activeSessionIdRef.current !== sessionId) { setChatScrollOffset(0); @@ -3567,6 +3619,12 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, chatSelectionAnchorRef.current = null; chatMouseSelectionRef.current = null; setChatMouseSelection(null); + // Opening the row IS the acknowledgement — the "woke" marker only exists + // to explain an unexpected return, so it goes as soon as it is seen + // (desktop TerminalsPage.handleSelectSession / iOS openSession parity). + // Every path that puts a session on screen funnels through here, and the + // persisted-wokeAt guard keeps it to rows that have something to clear. + clearWokeMarkerOnVisit(sessionId); } if (!sessionId) { activeTerminalSessionRef.current = null; @@ -3586,7 +3644,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, } activeSessionIdRef.current = sessionId; setActiveSessionId(sessionId); - }, [clearTranscriptPreview, persistAdeCodeState, setChatScrollOffset]); + }, [clearTranscriptPreview, clearWokeMarkerOnVisit, persistAdeCodeState, setChatScrollOffset]); const setDraftChatMode = useCallback((active: boolean) => { setChatScrollOffset(0); @@ -3932,6 +3990,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, ]), [sessions, terminalScheduledWorkById, terminalSessions], ); + displaySessionsRef.current = displaySessions; const closedCliSessions = useMemo( () => deriveClosedCliSessions(terminalSessions, terminalScheduledWorkById), [terminalScheduledWorkById, terminalSessions], @@ -9020,6 +9079,52 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, }); }, [closedCliSessions, openDrawerSessions, rightChatsClosedExpanded]); + /** + * Send the snooze. The deadline is already resolved by the caller — this only + * writes it and refreshes. No timer is armed: expiry is derived everywhere by + * comparing `snoozedUntil` to now. + */ + const applySessionSnooze = useCallback(async ( + sessionId: string, + untilIso: string, + confirmation: string, + ): Promise => { + const conn = connectionRef.current; + if (!conn) { + addNotice("ADE runtime is still connecting. Try again when the connection is ready.", "error"); + return; + } + try { + await snoozeSession(conn, sessionId, untilIso); + addNotice(confirmation, "success"); + await refreshState(); + } catch (err) { + addNotice(err instanceof Error ? err.message : String(err), "error"); + } + }, [addNotice, refreshState]); + + /** + * 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. + */ + const openSnoozeDurationPalette = useCallback((sessionId: string, sessionLabel: string): void => { + pendingSnoozeSessionIdRef.current = sessionId; + setRightSelectionIndex(0); + setRightPane({ + kind: "list", + title: `Snooze · ${sessionLabel}`, + rows: [ + ...SNOOZE_CHOICES.map((choice) => choice.label), + "", + "Free text: /session snooze [id] 45m · 1.5h · 2d", + ], + action: { kind: "snooze-duration", ids: [...SNOOZE_CHOICES.map((choice) => choice.key), "", ""] }, + }); + }, []); + const toggleRightChatsClosedGroup = useCallback(() => { const next = !rightChatsClosedExpanded; setRightChatsClosedExpanded(next); @@ -9031,6 +9136,21 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, void copyProjectSecret(selectedId).catch((err) => addNotice(err instanceof Error ? err.message : String(err), "error")); return; } + if (actionKind === "snooze-duration") { + // The trailing free-text hint row carries an empty id; ignore it. + if (!selectedId) return; + const sessionId = pendingSnoozeSessionIdRef.current; + if (!sessionId) return; + const resolved = resolveSnoozeChoice(selectedId as SnoozeDurationKey); + if (!resolved.ok) { + addNotice(resolved.message, "error"); + return; + } + pendingSnoozeSessionIdRef.current = null; + setRightPane({ kind: "empty" }); + void applySessionSnooze(sessionId, resolved.untilIso, resolved.confirmation); + return; + } if (actionKind === "switch-lane") { const lane = lanes.find((entry) => entry.id === selectedId); if (!lane) return; @@ -9068,6 +9188,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, }, [ activateLaneWithLastChat, addNotice, + applySessionSnooze, copyProjectSecret, displaySessions, lanes, @@ -10266,6 +10387,89 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, } return; } + if (name === "/session") { + setRightPane({ + kind: "details", + title: "Session lifecycle", + body: [ + "Usage: /session [session-id] … (omit the id to target the active session)", + "", + " /session snooze [id] [30m|1h|4h|1d] hide the row until a deadline", + " /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", + "", + "Run /session snooze with no duration to pick one from the list.", + ].join("\n"), + }); + return; + } + // `/session [] …` — the same lifecycle verbs as + // `ade session`, with per-session targeting. Omitting the id targets the + // active session, matching `/chat settle`. + const lifecycleVerb: SessionLifecycleCommand | null = sessionLifecycleCommandFor(name); + if (lifecycleVerb) { + const target = resolveSessionTarget({ + input: args, + activeSessionId: activeSessionIdRef.current, + knownSessionIds: displaySessions.map((session) => session.sessionId), + // wake/unsettle/keep-active take no other argument, so a leading token + // that names no session is a typo rather than a passthrough. + strictLeadingToken: lifecycleVerb !== "snooze" && lifecycleVerb !== "settle", + }); + if (!target.ok) { + setRightPane({ kind: "details", title: "Session lifecycle", body: target.message }); + return; + } + const targetSession = displaySessions.find((session) => session.sessionId === target.sessionId); + const targetLabel = targetSession?.title?.trim() || target.sessionId; + const scope = target.explicit ? ` · ${targetLabel}` : ""; + try { + if (lifecycleVerb === "snooze") { + if (!target.rest) { + openSnoozeDurationPalette(target.sessionId, targetLabel); + return; + } + const resolved = resolveSnoozeFreeText(target.rest); + if (!resolved.ok) { + setRightPane({ kind: "details", title: "Session snooze", body: resolved.message }); + return; + } + await applySessionSnooze(target.sessionId, resolved.untilIso, `${resolved.confirmation}${scope}`); + return; + } + if (lifecycleVerb === "wake") { + await wakeSession(conn, target.sessionId, "manual"); + addNotice(`Woke the session.${scope}`, "success"); + } else if (lifecycleVerb === "settle") { + const dismissPendingInput = Boolean( + targetSession?.awaitingInput + || (targetSession as TuiChatSessionSummary | undefined)?.attentionRequestedAt, + ); + await settleSession(conn, target.sessionId, target.rest || undefined, { dismissPendingInput }); + addNotice( + `${dismissPendingInput + ? "Dismissed the pending input and settled the session." + : "Marked the session settled."}${scope}`, + "success", + ); + } else if (lifecycleVerb === "unsettle") { + 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. + await setSessionSettleOverride(conn, target.sessionId, "active"); + addNotice(`Pinned the session active.${scope}`, "success"); + } + await refreshState(); + } catch (err) { + addNotice(err instanceof Error ? err.message : String(err), "error"); + } + return; + } if (name === "/system") { setRightPane({ kind: "details", @@ -10587,7 +10791,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, addNotice(result.message ?? "Desktop route unavailable from this runtime.", "error"); } } - }, [activeSession?.provider, addNotice, applyLocalModelArg, clearOlderHistoryCursor, displaySessions, loadProviderModels, modelState.provider, pendingSteers, preferServiceRepair, project, refreshAiSetupStatus, refreshState, remoteLaunch, requestAppExit, scheduleModelStateCommit, sendClaudeModelCommandToTerminal, setChatScrollOffset, socketPath]); + }, [activeSession?.provider, addNotice, applyLocalModelArg, applySessionSnooze, clearOlderHistoryCursor, displaySessions, loadProviderModels, modelState.provider, openSnoozeDurationPalette, pendingSteers, preferServiceRepair, project, refreshAiSetupStatus, refreshState, remoteLaunch, requestAppExit, scheduleModelStateCommit, sendClaudeModelCommandToTerminal, setChatScrollOffset, socketPath]); const submitRightForm = useCallback(async ( form: Extract, diff --git a/apps/ade-cli/src/tuiClient/commands.ts b/apps/ade-cli/src/tuiClient/commands.ts index 4d0332f00..07f303030 100644 --- a/apps/ade-cli/src/tuiClient/commands.ts +++ b/apps/ade-cli/src/tuiClient/commands.ts @@ -62,6 +62,17 @@ export const BUILTIN_COMMANDS: BuiltinCommand[] = [ { name: "/chat note", description: "Update the active session status line", placement: "right", argumentHint: "[note]", category: "Chats" }, { name: "/chat settle", description: "Mark the active session settled", placement: "right", argumentHint: "[outcome]", category: "Chats" }, { name: "/chat unsettle", description: "Remove the active session's settled state", placement: "right", category: "Chats" }, + // Session lifecycle. Every verb takes an optional leading session id; omit it + // to target the active session, exactly like /chat settle. Snooze is a + // visibility overlay, not a phase — see tuiClient/sessionLifecycle.ts. + // The bare group name is registered so submitting it prints usage instead of + // leaking "/session" into the chat as a message. + { name: "/session", description: "Run a session lifecycle command", placement: "right", argumentHint: "", category: "Chats" }, + { name: "/session snooze", description: "Snooze a session out of the attention list until a deadline", placement: "right", argumentHint: "[session-id] [30m|1h|4h|1d]", category: "Chats" }, + { 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: "/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/components/Drawer.tsx b/apps/ade-cli/src/tuiClient/components/Drawer.tsx index c0afa3c02..e0fce4d8f 100644 --- a/apps/ade-cli/src/tuiClient/components/Drawer.tsx +++ b/apps/ade-cli/src/tuiClient/components/Drawer.tsx @@ -21,6 +21,7 @@ import { type DrawerLaneInput, } from "../drawerLayout"; import { Rail, statusGlyph, type StatusKind } from "./designKit"; +import { sessionLifecycleMarker, type SessionLifecycleMarker } from "../sessionLifecycle"; export { visibleDrawerChatCount, visibleDrawerLaneCount }; @@ -112,16 +113,46 @@ function sanitizeChatStatusLine(raw: string | null | undefined, maxChars = 120): return `${normalized.slice(0, Math.max(0, maxChars - 1)).trimEnd()}…`; } +/** + * The one lifecycle marker a drawer chat row may carry. Thin wrapper so both + * drawer modes read snooze/woke/settled from the same shared derivation. + */ +function chatLifecycleMarker( + session: AgentChatSessionSummary, + note: string | null, + nowMs: number = Date.now(), +): SessionLifecycleMarker | null { + const lifecycle = session as TuiChatSessionSummary; + return sessionLifecycleMarker( + { ...lifecycle, isActive: session.status === "active" }, + { note, nowMs }, + ); +} + +/** + * The trailing "· …" fragment on a chat row. Priority, highest first: + * 1. an escalated ask / pending input ("needs you"), + * 2. the lifecycle marker — snoozed ("z wakes in 3h"), woken ("* needs + * approval"), or settled ("done" / "done: PR merged"), + * 3. the most useful free text left (status note, preview, summary, goal). + * + * Every lifecycle state above is carried by TEXT, never by color alone: the + * drawer has to stay readable in a monochrome terminal. + */ function chatStatusLine( session: AgentChatSessionSummary, - settled: boolean, + nowMs: number = Date.now(), ): string | null { const lifecycle = session as TuiChatSessionSummary; if (lifecycle.attentionRequestedAt || session.awaitingInput) { return sanitizeChatStatusLine(lifecycle.attentionMessage) || "needs you"; } const note = sanitizeChatStatusLine(lifecycle.statusNote); - if (note) return settled ? `done: ${note}` : note; + // The marker owns all settled/snoozed/woke copy, so a keep-active pin never + // leaks a stale "done:" here. + const marker = chatLifecycleMarker(session, note, nowMs); + if (marker) return marker.text; + if (note) return note; const primary = formatSessionLabel(session); for (const candidate of [session.lastOutputPreview, session.summary, session.goal]) { const fallback = sanitizeChatStatusLine(candidate); @@ -564,8 +595,14 @@ function LaneCard({ const dot = statusGlyph(laneStatusDot(status)); const LEAD_WIDTH = 2; // status dot + space - // Right cluster, in priority order: a missing/rebasing worktree wins over the - // live diff. The diff is the common case and refreshes in place. + // Right cluster, in priority order: a missing/rebasing worktree, then branch + // drift, then the live diff. The diff is the common case and refreshes in + // place. Drift outranks it because the diff is measured against a branch the + // lane is no longer on — showing `+12 −3` there would be actively misleading. + // The marker is plain words, never color alone, so it survives a terminal + // with no color at all. Resolving it stays in `ade lane drift resolve`: the + // fix can move the worktree or rename the lane, which is not something to + // trigger from a row that is one arrow-key away. const diff = worktreeAvailable && diffStats ? { add: diffStats.additions, del: diffStats.deletions } : null; @@ -576,9 +613,11 @@ function LaneCard({ ? { kind: "text", text: "no worktree", color: theme.color.error } : lane.status?.rebaseInProgress ? { kind: "text", text: "rebasing", color: theme.color.attention } - : diff - ? { kind: "diff", add: diff.add, del: diff.del } - : null; + : lane.branchDrift + ? { kind: "text", text: "off-branch", color: theme.color.attention } + : diff + ? { kind: "diff", add: diff.add, del: diff.del } + : null; const rightWidth = rightCluster == null ? 0 : rightCluster.kind === "text" @@ -674,7 +713,11 @@ function ChatRow({ }) { const lifecycle = session as TuiChatSessionSummary; const attention = Boolean(session.awaitingInput || lifecycle.attentionRequestedAt); - const settled = Boolean(lifecycle.settledAt && session.status !== "active"); + // A snoozed or settled row shares the same quiet tier; a "z wakes in 3h" / + // "done" marker in the suffix is what actually distinguishes them without + // relying on the dimmer color, which a monochrome terminal drops. + const marker = chatLifecycleMarker(session, sanitizeChatStatusLine(lifecycle.statusNote)); + const quiet = marker?.kind === "settled" || marker?.kind === "snoozed"; const failed = Boolean(lifecycle.lastTurnFailedAt); const running = session.status === "active" && !attention && !failed; const provider = (session.provider as AdeCodeProvider) ?? null; @@ -698,7 +741,7 @@ function ChatRow({ : null; const spawnLabel = spawnKind === "subagent" ? "sub" : spawnKind; const spawnReserve = spawnLabel ? spawnLabel.length + 1 : 0; - const lifecycleText = chatStatusLine(session, settled); + const lifecycleText = chatStatusLine(session); const lifecycleSuffix = lifecycleText ? truncate(lifecycleText, Math.max(8, Math.floor(max / 2))) : null; @@ -716,7 +759,7 @@ function ChatRow({ ? theme.color.violet : attention ? theme.color.attention - : settled ? theme.color.t4 + : quiet ? theme.color.t4 : failed ? theme.color.error : dimTitle ? theme.color.t2 : theme.color.t1; return ( @@ -1035,13 +1078,18 @@ function MiniDrawer({ const lifecycle = session as TuiChatSessionSummary; const attention = Boolean(session.awaitingInput || lifecycle.attentionRequestedAt); const failed = Boolean(lifecycle.lastTurnFailedAt); - const settled = Boolean(lifecycle.settledAt && session.status !== "active"); + const marker = chatLifecycleMarker(session, sanitizeChatStatusLine(lifecycle.statusNote)); + const quiet = marker?.kind === "settled" || marker?.kind === "snoozed"; const running = session.status === "active" && !attention && !failed; const hovered = hoveredId?.startsWith(`drawer:chat:${session.sessionId}:`) ?? false; const provider = (session.provider as AdeCodeProvider) ?? null; const exec = theme.provider(provider); const dot = statusGlyph(chatStatusDot(session)); - const nameMax = Math.max(4, inner - 4); + // Reserve the marker's columns so the title truncates ahead of it: + // "z wakes in 3h" is the only thing distinguishing a snoozed row in + // a terminal with no color. + const markerText = marker ? truncate(marker.text, Math.max(6, Math.floor(inner / 2))) : null; + const nameMax = Math.max(4, inner - 4 - (markerText ? markerText.length + 1 : 0)); return ( {running ? : {dot.glyph} } @@ -1055,7 +1103,7 @@ function MiniDrawer({ ? theme.color.attention : failed ? theme.color.error - : settled + : quiet ? theme.color.t4 : session.sessionId === activeSessionId || running ? theme.color.violet @@ -1065,6 +1113,7 @@ function MiniDrawer({ > {pad(truncate(formatSessionLabel(session), nameMax), nameMax)} + {markerText ? {` ${markerText}`} : null} ); })} diff --git a/apps/ade-cli/src/tuiClient/components/RightPane.tsx b/apps/ade-cli/src/tuiClient/components/RightPane.tsx index 8d2d4dce3..fc15ae590 100644 --- a/apps/ade-cli/src/tuiClient/components/RightPane.tsx +++ b/apps/ade-cli/src/tuiClient/components/RightPane.tsx @@ -2344,7 +2344,11 @@ function RightPaneComponent({ ) : null} {content.action && content.rows.length ? ( - {content.action.kind === "copy-secret" ? "arrows move · enter/c copies" : "arrows move · enter opens"} + {content.action.kind === "copy-secret" + ? "arrows move · enter/c copies" + : content.action.kind === "snooze-duration" + ? "arrows move · enter snoozes" + : "arrows move · enter opens"} ) : null} diff --git a/apps/ade-cli/src/tuiClient/sessionLifecycle.ts b/apps/ade-cli/src/tuiClient/sessionLifecycle.ts new file mode 100644 index 000000000..2ed959f29 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/sessionLifecycle.ts @@ -0,0 +1,334 @@ +import { + SNOOZE_DURATION_OPTIONS, + sessionWokeMarker, + snoozeConfirmationLabel, + snoozeDeadlineIso, + snoozeWakeLabel, + type SnoozeDurationKey, +} from "../../../desktop/src/renderer/lib/sessionSnooze"; +import { + isSessionFiledAsSnoozed, + isSessionSnoozed, +} from "../../../desktop/src/shared/sessionCanonicalState"; +import { parseSnoozeDuration } from "../sessionSnoozeDuration"; +import type { TuiSessionLifecycleFields } from "./adeApi"; + +/** + * `ade code`'s half of the session-lifecycle surface: argument parsing for the + * `/session …` slash commands and the text-only row markers the drawer and the + * right-pane chat list render. + * + * Everything semantic is imported, never re-derived: + * - "is this row snoozed" comes from the shared `isSessionSnoozed` + * (expiry is DERIVED by comparing `snoozedUntil` to now — no timers here), + * - wake-label copy ("wakes in 3h" / "wakes tomorrow" / "wakes when asked" / + * "wakes now") comes from `snoozeWakeLabel`, + * - woke-reason copy ("needs approval" / "errored" / "turn finished") comes + * from `sessionWokeMarker`, + * - duration grammar comes from `parseSnoozeDuration`. + * + * Snooze stays a VISIBILITY OVERLAY: nothing below reads or writes a canonical + * phase, and no marker here is derived from a phase check. + */ + +export type SessionLifecycleCommand = + | "snooze" + | "wake" + | "settle" + | "unsettle" + | "keep-active"; + +/** Slash names this module owns, mapped to their verb. `/chat settle` and + * `/chat unsettle` keep their own (active-only) dispatch in app.tsx. */ +export const SESSION_LIFECYCLE_COMMAND_BY_NAME: Readonly> = { + "/session snooze": "snooze", + "/session wake": "wake", + "/session settle": "settle", + "/session unsettle": "unsettle", + "/session keep-active": "keep-active", +}; + +export function sessionLifecycleCommandFor(name: string): SessionLifecycleCommand | null { + return SESSION_LIFECYCLE_COMMAND_BY_NAME[name] ?? null; +} + +// --------------------------------------------------------------------------- +// Per-session targeting +// --------------------------------------------------------------------------- + +/** Shortest prefix we will resolve to a session. Below this a typo is more + * likely than an abbreviation, and a wrong target is a destructive surprise. */ +const MIN_ID_PREFIX = 4; + +export type SessionTargetResolution = + | { ok: true; sessionId: string; explicit: boolean; rest: string } + | { ok: false; code: "no-active" | "unknown-session" | "ambiguous-session"; message: string }; + +function matchSessionId(token: string, knownSessionIds: readonly string[]): "none" | "ambiguous" | string { + const needle = token.trim().toLowerCase(); + if (!needle) return "none"; + const exact = knownSessionIds.find((id) => id.toLowerCase() === needle); + if (exact) return exact; + if (needle.length < MIN_ID_PREFIX) return "none"; + const prefixed = knownSessionIds.filter((id) => id.toLowerCase().startsWith(needle)); + if (prefixed.length === 1) return prefixed[0]!; + if (prefixed.length > 1) return "ambiguous"; + return "none"; +} + +/** + * Resolve `[] `: an id is only consumed when the leading token + * actually names a session the client knows about, so `/session snooze 1h` + * still means "snooze the active session for an hour" and never means "snooze + * the session called 1h". Omitting the id targets the active session, matching + * how `/chat settle` behaves. + * + * `strictLeadingToken` is for the verbs that take no other argument (wake / + * unsettle / keep-active): there, a leading token that does not name a session + * is a typo, not a passthrough. + */ +export function resolveSessionTarget(args: { + input: string; + activeSessionId: string | null; + knownSessionIds: readonly string[]; + strictLeadingToken?: boolean; +}): SessionTargetResolution { + const trimmed = args.input.trim(); + const [leading = ""] = trimmed.split(/\s+/, 1); + const matched = leading ? matchSessionId(leading, args.knownSessionIds) : "none"; + + if (matched === "ambiguous") { + return { + ok: false, + code: "ambiguous-session", + message: `'${leading}' matches more than one session. Pass more of the id.`, + }; + } + if (matched !== "none") { + return { + ok: true, + sessionId: matched, + explicit: true, + rest: trimmed.slice(leading.length).trim(), + }; + } + if (leading && args.strictLeadingToken) { + return { + ok: false, + code: "unknown-session", + message: `No session matches '${leading}'. Run /chats to see session ids, or omit the id to target the active session.`, + }; + } + if (!args.activeSessionId) { + return { + ok: false, + code: "no-active", + message: "No active chat or CLI session is selected. Pass a session id.", + }; + } + return { ok: true, sessionId: args.activeSessionId, explicit: false, rest: trimmed }; +} + +// --------------------------------------------------------------------------- +// Duration choices +// --------------------------------------------------------------------------- + +export type SnoozeChoice = { key: SnoozeDurationKey; label: 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, +})); + +export type SnoozeResolution = + | { ok: true; untilIso: string; confirmation: string } + | { ok: false; message: string }; + +/** Resolve a menu choice to a concrete deadline (computed client-side; there is + * no scheduler — every surface derives expiry by comparing to now). */ +export function resolveSnoozeChoice(key: SnoozeDurationKey, nowMs: number = Date.now()): SnoozeResolution { + return { + ok: true, + untilIso: snoozeDeadlineIso(key, nowMs), + confirmation: `Snoozed ${snoozeConfirmationLabel(key)}.`, + }; +} + +/** + * Resolve free-text duration entry ("45m", "1.5h", "2d") through the shared + * grammar. The TUI never typed a flag, so flag-worded failures are rewritten. + */ +export function resolveSnoozeFreeText(value: string, nowMs: number = Date.now()): SnoozeResolution { + const parsed = parseSnoozeDuration(value); + if (!parsed.ok) { + switch (parsed.code) { + case "too-short": + return { ok: false, message: "Snooze for at least one second." }; + case "too-long": + // Point at the open-ended choice rather than dead-ending: a user asking + // for 90d wants "until I'm asked", not a shorter countdown. + return { + ok: false, + message: "Snooze for 30d or less, or pick \"Until I'm asked\" for an open-ended snooze.", + }; + default: + return { + ok: false, + message: `'${value.trim()}' is not a duration. Try 30m, 1h, 4h, or 1d.`, + }; + } + } + const untilIso = new Date(nowMs + parsed.ms).toISOString(); + const label = snoozeWakeLabel(untilIso, nowMs); + return { ok: true, untilIso, confirmation: label ? `Snoozed · ${label}.` : "Snoozed." }; +} + +// --------------------------------------------------------------------------- +// Row markers — text only, legible with no color at all +// --------------------------------------------------------------------------- + +export type SessionLifecycleSnapshot = Partial & { + /** Passed separately because chat rows and CLI rows spell "still going" + * differently; only used to keep a running row out of the quiet tier. */ + isActive?: boolean; + pendingInputItemId?: string | null; + /** Deterministic needs-you signals, read straight off the row the caller + * already spreads in — a snoozed row that is asking for you must not be + * MARKED snoozed (see `sessionLifecycleMarker`). */ + awaitingInput?: boolean | null; + runtimeState?: string | null; +}; + +/** + * The deterministic hand-raise a text row can see, mirroring rule 1 of + * `canonicalSessionState`: a pending input item, a "waiting-input" runtime, an + * `ade chat ask` escalation, or the runtime's own awaiting-input flag. This is + * all the TUI needs to decide filing — the preview heuristic only ever upgrades + * running → needs_you, and a running row is not the case at risk here. + */ +function snapshotRaisesHand(session: SessionLifecycleSnapshot): boolean { + if (session.awaitingInput === true) return true; + if (typeof session.runtimeState === "string" && session.runtimeState.trim().toLowerCase() === "waiting-input") { + return true; + } + if (typeof session.pendingInputItemId === "string" && session.pendingInputItemId.trim().length > 0) return true; + return typeof session.attentionRequestedAt === "string" && session.attentionRequestedAt.trim().length > 0; +} + +/** + * Leading marker glyph for a snoozed row. Deliberately the ASCII letter `z`: + * every state below must survive a terminal with no color, no bold, and no + * emoji font, so state is NEVER encoded in color alone. + */ +export const SNOOZED_GLYPH = "z"; +/** Leading marker for a row that came back on its own. */ +export const WOKE_GLYPH = "*"; +/** Quiet-tier marker; pairs with an outcome note when the session left one. */ +export const SETTLED_LABEL = "done"; + +export type SessionLifecycleMarker = { + /** Machine-readable state for callers that want to tier or sort rows. */ + kind: "snoozed" | "woke" | "settled"; + /** One-cell text glyph, or "" for settled (the word carries it). */ + glyph: string; + /** Full marker text, glyph included — safe to render with no styling. */ + text: string; +}; + +/** + * The single row marker a session earns, highest priority first: + * 0. a raised hand — no marker at all: a row blocked on the user must never + * read as `z wakes in 3h`, or an "until I'm asked" snooze hides the very + * thing it promised to bring back (tracked CLI rows have no early-wake + * event, so this derivation is the only thing that surfaces them), + * 1. snoozed — `z wakes in 3h` (a live visibility overlay outranks history), + * 2. woke — `* needs approval` (why the row came back, until it's opened), + * 3. settled — `done` / `done: ` (the quiet tier). + * Returns null for an ordinary row. + * + * Snooze filing comes from the shared `isSessionFiledAsSnoozed`, never from a + * local phase check, and a settled marker is suppressed while the session is + * active so a woken-and-working row is not filed as done. + */ +export function sessionLifecycleMarker( + session: SessionLifecycleSnapshot, + options: { note?: string | null; nowMs?: number } = {}, +): SessionLifecycleMarker | null { + const nowMs = options.nowMs ?? Date.now(); + if (isSessionFiledAsSnoozed( + { snoozedUntil: session.snoozedUntil, snoozedAt: session.snoozedAt }, + snapshotRaisesHand(session) ? "needs_you" : null, + nowMs, + )) { + const label = snoozeWakeLabel(session.snoozedUntil, nowMs) ?? "wakes when asked"; + return { kind: "snoozed", glyph: SNOOZED_GLYPH, text: `${SNOOZED_GLYPH} ${label}` }; + } + const woke = sessionWokeMarker( + { + snoozedUntil: session.snoozedUntil ?? null, + snoozedAt: session.snoozedAt ?? null, + wokeAt: session.wokeAt ?? null, + wokeReason: session.wokeReason ?? null, + pendingInputItemId: session.pendingInputItemId ?? null, + lastTurnFailedAt: session.lastTurnFailedAt ?? null, + }, + nowMs, + ); + if (woke) { + return { kind: "woke", glyph: WOKE_GLYPH, text: `${WOKE_GLYPH} ${woke.label}` }; + } + // An "active" keep-active pin suppresses the quiet tier outright — that is the + // whole point of the pin — and so does a session that is actually running. + if (session.settleOverride === "active" || session.isActive) return null; + if (session.settleOverride === "settled" || session.settledAt) { + const note = options.note?.trim(); + return { + kind: "settled", + glyph: "", + text: note ? `${SETTLED_LABEL}: ${note}` : SETTLED_LABEL, + }; + } + return null; +} + +/** + * Whether opening this row should clear its "woke" marker. + * + * Guards on the PERSISTED `wokeAt`, never on `sessionLifecycleMarker()`. A row + * whose snooze merely lapsed still shows a marker — `sessionWokeMarker` derives + * one from the expired deadline — but the host never wrote anything, so calling + * `session.clearWokeMarker` for it is a pointless round-trip. + */ +export function shouldClearWokeMarkerOnVisit( + session: Pick | null | undefined, +): boolean { + return typeof session?.wokeAt === "string" && session.wokeAt.trim().length > 0; +} + +/** + * Clear the "woke" marker for a session the user just opened, matching desktop + * (`TerminalsPage.handleSelectSession`) and iOS (`clearWokeMarkerOnVisit`). + * + * Strictly fire-and-forget: the promise is never awaited and a rejection is + * swallowed, so a failed clear can neither block nor delay opening the session. + * Returns whether the action was dispatched, which is also what makes the + * persisted-vs-derived guard testable. + */ +export function clearWokeMarkerOnVisit(args: { + sessionId: string | null; + sessions: readonly (Pick & { sessionId: string })[]; + clear: (sessionId: string) => Promise; +}): boolean { + if (!args.sessionId) return false; + const session = args.sessions.find((entry) => entry.sessionId === args.sessionId); + if (!shouldClearWokeMarkerOnVisit(session)) return false; + void Promise.resolve(args.clear(args.sessionId)).catch(() => {}); + return true; +} + +/** Re-exported so TUI call sites never hand-roll a snooze check. `isSessionSnoozed` + * is the raw column read; `isSessionFiledAsSnoozed` is the filing rule that + * yields to a raised hand. */ +export { isSessionFiledAsSnoozed, isSessionSnoozed, snoozeWakeLabel }; diff --git a/apps/ade-cli/src/tuiClient/types.ts b/apps/ade-cli/src/tuiClient/types.ts index 426322609..b3e7c5c6b 100644 --- a/apps/ade-cli/src/tuiClient/types.ts +++ b/apps/ade-cli/src/tuiClient/types.ts @@ -275,7 +275,10 @@ export type RightPaneContent = rows: string[]; emptyText?: string; action?: { - kind: "switch-lane" | "switch-chat" | "chat-list" | "copy-secret"; + // "snooze-duration" rows are duration choices, not sessions: the ids are + // SnoozeDurationKey values and the target session is held alongside the + // pane state in app.tsx. + kind: "switch-lane" | "switch-chat" | "chat-list" | "copy-secret" | "snooze-duration"; ids: string[]; }; } 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 c2a3d52e2..40ace2caa 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,6 +172,58 @@ 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 + +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. + +```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 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 +``` + +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. +- **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`. + +### Lane branch drift + +A lane's worktree HEAD can drift from the branch ADE recorded (someone runs +`git checkout` inside it). While drifted, PR matching is paused, because a PR +created from that lane would target the wrong branch. + +```bash +ade lane drift [--lane ] --text +ade lane drift resolve --switch-back # restore the recorded branch +ade lane drift resolve --keep-head # adopt the live branch instead +``` + +`--switch-back` refuses on a dirty worktree rather than risking work. `--keep-head` +re-points the lane's branch and renames the lane only when its name was literally +advertising the old branch. Actions: `lane.getBranchDrift`, `lane.resolveBranchDrift`. + ### Scheduled work Persistent ADE chats and tracked provider CLI sessions can schedule their own diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index ec41133a6..0f3412891 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -4,6 +4,15 @@ if (app.isPackaged && process.env.ADE_RUNTIME_PACKAGED === undefined) { process.env.ADE_RUNTIME_PACKAGED = "1"; } +// When the terminal that launched ADE goes away, the next write to stdout/stderr raises +// EPIPE. Without a listener that surfaces as an uncaughtException and tears down the app. +for (const stream of [process.stdout, process.stderr]) { + stream.on("error", (err: NodeJS.ErrnoException) => { + if (err.code === "EPIPE" || err.code === "ERR_STREAM_DESTROYED") return; + throw err; + }); +} + import { AsyncLocalStorage } from "node:async_hooks"; import os from "node:os"; import path from "node:path"; @@ -6057,9 +6066,13 @@ app.whenReady().then(async () => { }; const FILE_LIMIT_CODES = new Set(["EMFILE", "ENFILE"]); + // A dead stdout/stderr pipe (launching terminal closed) must never take the app down. + const BROKEN_STREAM_CODES = new Set(["EPIPE", "ERR_STREAM_DESTROYED"]); let emfileWarned = false; process.on("uncaughtException", (err) => { - if (FILE_LIMIT_CODES.has((err as NodeJS.ErrnoException).code ?? "")) return; + const code = (err as NodeJS.ErrnoException).code ?? ""; + if (FILE_LIMIT_CODES.has(code)) return; + if (BROKEN_STREAM_CODES.has(code)) return; const logger = getActiveContext().logger; logger.error("process.uncaught_exception", { err: String(err), diff --git a/apps/desktop/src/main/services/adeActions/registry.test.ts b/apps/desktop/src/main/services/adeActions/registry.test.ts index 97175aae1..e028020a3 100644 --- a/apps/desktop/src/main/services/adeActions/registry.test.ts +++ b/apps/desktop/src/main/services/adeActions/registry.test.ts @@ -77,6 +77,19 @@ describe("isAllowedAdeAction", () => { expect(isAllowedAdeAction("session", "unsettleSelfSession")).toBe(true); }); + 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); + expect(isAllowedAdeAction("session", "wakeSession")).toBe(true); + expect(isAllowedAdeAction("session", "wakeSessions")).toBe(true); + expect(isAllowedAdeAction("session", "setSettleOverride")).toBe(true); + expect(isAllowedAdeAction("session", "clearWokeMarker")).toBe(true); + expect(isAllowedAdeAction("lane", "getBranchDrift")).toBe(true); + expect(isAllowedAdeAction("lane", "resolveBranchDrift")).toBe(true); + expect(isCtoOnlyAdeAction("session", "snoozeSession")).toBe(false); + expect(isCtoOnlyAdeAction("lane", "resolveBranchDrift")).toBe(false); + }); + it("exposes iOS Preview Lab matching and workspace readiness to generic actions", () => { expect(isAllowedAdeAction("ios_simulator", "resolvePreviewMatch")).toBe(true); expect(isAllowedAdeAction("ios_simulator", "ensurePreviewWorkspace")).toBe(true); @@ -1308,6 +1321,138 @@ describe("runtime session actions", () => { expect(unsettleSession).toHaveBeenCalledWith("session-1"); }); + it("validates snooze/wake/settle-override args before touching the session service", () => { + const snoozeSession = vi.fn(() => true); + const snoozeSessions = vi.fn(() => ["session-1"]); + const wakeSession = vi.fn(() => true); + const wakeSessions = vi.fn(() => ["session-1"]); + const setSettleOverride = vi.fn(() => true); + const clearWokeMarker = vi.fn(() => true); + const runtime = { + sessionService: { + get: vi.fn(), + list: vi.fn(), + snoozeSession, + snoozeSessions, + wakeSession, + wakeSessions, + setSettleOverride, + clearWokeMarker, + }, + } as unknown as Parameters[0]; + const sessionActions = getAdeActionDomainServices(runtime).session as Record< + string, + (args?: unknown) => unknown + >; + + expect(listAllowedAdeActionNames("session", sessionActions)).toEqual( + expect.arrayContaining([ + "snoozeSession", + "snoozeSessions", + "wakeSession", + "wakeSessions", + "setSettleOverride", + "clearWokeMarker", + ]), + ); + + // Deadlines are normalized to ISO so every surface stores the same shape. + expect(sessionActions.snoozeSession({ + sessionId: "session-1", + untilIso: "2026-07-26T18:00:00Z", + })).toEqual({ + ok: true, + sessionId: "session-1", + snoozedUntil: "2026-07-26T18:00:00.000Z", + }); + expect(snoozeSession).toHaveBeenCalledWith("session-1", "2026-07-26T18:00:00.000Z"); + + expect(() => sessionActions.snoozeSession({ sessionId: "session-1" })) + .toThrow(/untilIso/); + expect(() => sessionActions.snoozeSession({ sessionId: "session-1", untilIso: "later" })) + .toThrow(/ISO-8601/); + expect(() => sessionActions.snoozeSession({ untilIso: "2026-07-26T18:00:00Z" })) + .toThrow(/sessionId/); + expect(snoozeSession).toHaveBeenCalledTimes(1); + + expect(() => sessionActions.snoozeSessions({ + sessionIds: [], + untilIso: "2026-07-26T18:00:00Z", + })).toThrow(/at least one session id/); + expect(sessionActions.snoozeSessions({ + sessionIds: ["session-1", 7], + untilIso: "2026-07-26T18:00:00Z", + })).toEqual(["session-1"]); + expect(snoozeSessions).toHaveBeenCalledWith(["session-1"], "2026-07-26T18:00:00.000Z"); + + // Wake defaults to "manual" and rejects anything outside the reason union. + expect(sessionActions.wakeSession({ sessionId: "session-1" })) + .toEqual({ ok: true, sessionId: "session-1", reason: "manual" }); + expect(wakeSession).toHaveBeenCalledWith("session-1", "manual"); + expect(sessionActions.wakeSession({ sessionId: "session-1", reason: "needs_you" })) + .toEqual({ ok: true, sessionId: "session-1", reason: "needs_you" }); + expect(() => sessionActions.wakeSession({ sessionId: "session-1", reason: "because" })) + .toThrow(/reason/); + expect(sessionActions.wakeSessions({ sessionIds: ["session-1"], reason: "error" })) + .toEqual(["session-1"]); + expect(wakeSessions).toHaveBeenCalledWith(["session-1"], "error"); + + expect(sessionActions.setSettleOverride({ sessionId: "session-1", override: "active" })) + .toEqual({ ok: true, sessionId: "session-1", settleOverride: "active" }); + expect(setSettleOverride).toHaveBeenCalledWith("session-1", "active"); + expect(sessionActions.setSettleOverride({ sessionId: "session-1", override: null })) + .toEqual({ ok: true, sessionId: "session-1", settleOverride: null }); + expect(setSettleOverride).toHaveBeenLastCalledWith("session-1", null); + expect(() => sessionActions.setSettleOverride({ sessionId: "session-1", override: "snoozed" })) + .toThrow(/'settled', 'active', or null/); + + expect(sessionActions.clearWokeMarker({ sessionId: "session-1" })) + .toEqual({ ok: true, sessionId: "session-1" }); + expect(clearWokeMarker).toHaveBeenCalledWith("session-1"); + }); + + it("validates lane branch-drift args and forwards the resolution", async () => { + const getBranchDrift = vi.fn(async () => ({ + expectedBranchRef: "ade/feature", + headBranchRef: "hotfix-auth", + })); + const resolveBranchDrift = vi.fn(async () => ({ resolution: "switch-back" })); + const runtime = { + laneService: { getBranchDrift, resolveBranchDrift }, + } as unknown as Parameters[0]; + const laneActions = getAdeActionDomainServices(runtime).lane as Record< + string, + (args?: unknown) => Promise + >; + + // `ade lane actions --text` reads this list, so drift must appear in it. + expect(listAllowedAdeActionNames("lane", laneActions)).toEqual( + expect.arrayContaining(["getBranchDrift", "resolveBranchDrift"]), + ); + + await expect(laneActions.getBranchDrift({ laneId: "lane-1" })).resolves.toEqual({ + expectedBranchRef: "ade/feature", + headBranchRef: "hotfix-auth", + }); + expect(getBranchDrift).toHaveBeenCalledWith({ laneId: "lane-1" }); + await expect(laneActions.getBranchDrift({})).rejects.toThrow(/laneId/); + + await expect(laneActions.resolveBranchDrift({ + laneId: "lane-1", + resolution: "keep-head", + expectedHeadBranchRef: " hotfix-auth ", + })).resolves.toEqual({ resolution: "switch-back" }); + expect(resolveBranchDrift).toHaveBeenCalledWith({ + laneId: "lane-1", + resolution: "keep-head", + expectedHeadBranchRef: "hotfix-auth", + }); + + await expect(laneActions.resolveBranchDrift({ laneId: "lane-1", resolution: "rebase" })) + .rejects.toThrow(/'switch-back' or 'keep-head'/); + expect(resolveBranchDrift).toHaveBeenCalledTimes(1); + }); + it("dismisses pending chat input before settling through the session action", async () => { const dismissPendingInputForSettlement = vi.fn(async () => undefined); const settleSession = vi.fn(() => true); diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index 67ea32f2c..89c669cbc 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -49,6 +49,7 @@ import type { DeleteLaneArgs, FileChangeEvent, FilesWatchArgs, + LaneBranchDriftResolution, LaneEnvInitConfig, LaneEnvInitProgress, LaneListSnapshot, @@ -56,6 +57,8 @@ import type { LanePreviewInfo, ListSessionsArgs, ListLanesArgs, + SessionSettleOverride, + SessionWakeReason, PortLease, PrAgentPermissionMode, PrAiResolutionContext, @@ -75,6 +78,7 @@ import type { CtoLinearQuickView, LinearConnectionStatus, } from "../../../shared/types"; +import { parseSessionSettleOverride, SESSION_WAKE_REASONS } from "../../../shared/types"; import { getModelById } from "../../../shared/modelRegistry"; import { matchLaneOverlayPolicies } from "../config/laneOverlayMatcher"; import { mergeAiConfig } from "../config/projectConfigService"; @@ -265,6 +269,7 @@ export const ADE_ACTION_ALLOWLIST: Partial { + const record = readObjectActionArg(args, "session.snoozeSession"); + const sessionId = requireNonEmptyString(record.sessionId, "sessionId"); + const untilIso = requireSnoozeDeadline(record.untilIso); + if (!sessionService.snoozeSession(sessionId, untilIso)) { + throw new Error(`Session '${sessionId}' was not found.`); + } + return { ok: true, sessionId, snoozedUntil: untilIso }; + }, + snoozeSessions: (args?: unknown) => { + const record = readObjectActionArg(args, "session.snoozeSessions"); + const sessionIds = readSessionIdList(record.sessionIds, "session.snoozeSessions"); + const untilIso = requireSnoozeDeadline(record.untilIso); + return sessionService.snoozeSessions(sessionIds, untilIso); + }, + wakeSession: (args?: unknown) => { + const record = readObjectActionArg(args, "session.wakeSession"); + const sessionId = requireNonEmptyString(record.sessionId, "sessionId"); + const reason = readWakeReason(record.reason, "session.wakeSession"); + return { ok: sessionService.wakeSession(sessionId, reason), sessionId, reason }; + }, + wakeSessions: (args?: unknown) => { + const record = readObjectActionArg(args, "session.wakeSessions"); + const sessionIds = readSessionIdList(record.sessionIds, "session.wakeSessions"); + const reason = readWakeReason(record.reason, "session.wakeSessions"); + return sessionService.wakeSessions(sessionIds, reason); + }, + setSettleOverride: (args?: unknown) => { + const record = readObjectActionArg(args, "session.setSettleOverride"); + const sessionId = requireNonEmptyString(record.sessionId, "sessionId"); + const override = readSettleOverride(record.override, "session.setSettleOverride"); + if (!sessionService.setSettleOverride(sessionId, override)) { + throw new Error(`Session '${sessionId}' was not found.`); + } + return { ok: true, sessionId, settleOverride: override }; + }, + clearWokeMarker: (args?: unknown) => { + const record = readObjectActionArg(args, "session.clearWokeMarker"); + const sessionId = requireNonEmptyString(record.sessionId, "sessionId"); + if (!sessionService.clearWokeMarker(sessionId)) { + throw new Error(`Session '${sessionId}' was not found.`); + } + return { ok: true, sessionId }; + }, deleteSession: (arg?: { sessionId?: string } | string) => { const sessionId = typeof arg === "string" ? requireNonEmptyString(arg, "sessionId") @@ -1986,6 +2048,30 @@ function buildLaneDomainService(runtime: AdeRuntime): OpaqueService { ); }, listRebaseSuggestions: () => runtime.rebaseSuggestionService?.listSuggestions() ?? [], + /** + * Branch-drift status read. Returns `null` when the worktree HEAD still + * matches the lane's recorded branch — the common case — so agents can poll + * it cheaply before a PR or checkout operation. + */ + getBranchDrift: async (args?: unknown) => { + const record = readObjectActionArg(args, "lane.getBranchDrift"); + const laneId = requireNonEmptyString(record.laneId, "laneId"); + return runtime.laneService.getBranchDrift({ laneId }); + }, + resolveBranchDrift: async (args?: unknown) => { + const record = readObjectActionArg(args, "lane.resolveBranchDrift"); + const laneId = requireNonEmptyString(record.laneId, "laneId"); + const resolution = readBranchDriftResolution(record.resolution, "lane.resolveBranchDrift"); + const expectedHeadBranchRef = typeof record.expectedHeadBranchRef === "string" + ? record.expectedHeadBranchRef.trim() + : ""; + return runtime.laneService.resolveBranchDrift({ + laneId, + resolution, + ...(expectedHeadBranchRef ? { expectedHeadBranchRef } : {}), + ...(record.acknowledgeActiveWork === true ? { acknowledgeActiveWork: true } : {}), + }); + }, delete: async (args?: DeleteLaneArgs): Promise => { const laneId = requireNonEmptyString(args?.laneId, "laneId"); const laneEnvironmentService = runtime.laneEnvironmentService; @@ -2490,6 +2576,56 @@ function readObjectActionArg(value: unknown, actionName: string): Record typeof id === "string" && id.trim().length > 0); + if (!ids.length) { + throw new Error(`${actionName} requires at least one session id.`); + } + return ids; +} + +function readWakeReason(value: unknown, actionName: string): SessionWakeReason { + if (value == null || value === "") return "manual"; + if (typeof value === "string" && (SESSION_WAKE_REASONS as readonly string[]).includes(value)) { + return value as SessionWakeReason; + } + throw new Error(`${actionName} 'reason' must be one of: ${SESSION_WAKE_REASONS.join(", ")}.`); +} + +function readSettleOverride(value: unknown, actionName: string): SessionSettleOverride | null { + const parsed = parseSessionSettleOverride(value); + if (parsed === undefined) { + throw new Error(`${actionName} 'override' must be 'settled', 'active', or null.`); + } + return parsed; +} + +function readBranchDriftResolution(value: unknown, actionName: string): LaneBranchDriftResolution { + if (value === "switch-back" || value === "keep-head") return value; + throw new Error(`${actionName} 'resolution' must be 'switch-back' or 'keep-head'.`); +} + function readOptionalIntegerActionField(value: unknown, field: string): number | undefined { if (value == null || value === "") return undefined; const numeric = typeof value === "number" diff --git a/apps/desktop/src/main/services/ai/claudeRuntimeProbe.ts b/apps/desktop/src/main/services/ai/claudeRuntimeProbe.ts index 8c856791d..0cd3b083a 100644 --- a/apps/desktop/src/main/services/ai/claudeRuntimeProbe.ts +++ b/apps/desktop/src/main/services/ai/claudeRuntimeProbe.ts @@ -140,6 +140,15 @@ export async function probeClaudeRuntimeHealth(args: { tools: [], abortController, pathToClaudeCodeExecutable: claudeExecutable.path, + // This probe only answers "can the runtime start and authenticate". Left + // unisolated it boots the user's entire MCP fleet and writes a session file on + // every cache miss, which is slow and makes a broken MCP server look like a + // broken Claude runtime. Slash commands are discovered separately by + // claudeSlashCommandDiscovery, so nothing here needs filesystem settings. + settingSources: [], + mcpServers: {}, + strictMcpConfig: true, + persistSession: false, }, }); diff --git a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts index cfb3cbb25..ac6a42d5e 100644 --- a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts +++ b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts @@ -341,6 +341,121 @@ describe("createCtoOperatorTools", () => { }); }); + // ── Session lifecycle tools ───────────────────────────────────── + + describe("session lifecycle tools", () => { + function lifecycleDeps(row: Record = {}) { + const sessionService = { + updateMeta: vi.fn(), + get: vi.fn(() => ({ + id: "chat-1", + settledAt: null, + settleOverride: null, + snoozedUntil: null, + snoozedAt: null, + wokeAt: null, + wokeReason: null, + ...row, + })), + settleSession: vi.fn(() => true), + unsettleSession: vi.fn(() => true), + setSettleOverride: vi.fn(() => true), + snoozeSession: vi.fn(() => true), + wakeSession: vi.fn(() => true), + clearWokeMarker: vi.fn(() => true), + }; + return { + sessionService, + deps: buildDeps({ + sessionService: sessionService as any, + getChatStatus: vi.fn().mockResolvedValue({ ...baseSession }), + }), + }; + } + + it("surfaces the wake reason so the CTO can explain why a row resurfaced", async () => { + const { deps } = lifecycleDeps({ + wokeAt: "2026-07-26T12:00:00.000Z", + wokeReason: "needs_you", + snoozedUntil: "2026-07-26T18:00:00.000Z", + snoozedAt: "2026-07-26T10:00:00.000Z", + }); + const tools = createCtoOperatorTools(deps); + + await expect((tools.getSessionLifecycle as any).execute({ sessionId: "chat-1" })) + .resolves.toMatchObject({ + success: true, + sessionId: "chat-1", + wokeReason: "needs_you", + wokeAt: "2026-07-26T12:00:00.000Z", + }); + + const status = await (tools.getChatStatus as any).execute({ sessionId: "chat-1" }); + expect(status.lifecycle).toMatchObject({ wokeReason: "needs_you" }); + }); + + it("reports an expired snooze as no longer snoozed", async () => { + const { deps } = lifecycleDeps({ snoozedUntil: "2000-01-01T00:00:00.000Z" }); + const tools = createCtoOperatorTools(deps); + await expect((tools.getSessionLifecycle as any).execute({ sessionId: "chat-1" })) + .resolves.toMatchObject({ snoozed: false }); + }); + + it("settles, unsettles, and pins settle state", async () => { + const { deps, sessionService } = lifecycleDeps(); + const tools = createCtoOperatorTools(deps); + + await expect((tools.settleSession as any).execute({ + sessionId: "chat-1", + outcome: "CI green", + })).resolves.toMatchObject({ success: true }); + expect(sessionService.settleSession).toHaveBeenCalledWith("chat-1", { outcome: "CI green" }); + + await (tools.unsettleSession as any).execute({ sessionId: "chat-1" }); + expect(sessionService.unsettleSession).toHaveBeenCalledWith("chat-1"); + + await (tools.setSessionSettleOverride as any).execute({ sessionId: "chat-1", override: "active" }); + expect(sessionService.setSettleOverride).toHaveBeenCalledWith("chat-1", "active"); + await (tools.setSessionSettleOverride as any).execute({ sessionId: "chat-1", override: "clear" }); + expect(sessionService.setSettleOverride).toHaveBeenLastCalledWith("chat-1", null); + }); + + it("snoozes by duration or explicit deadline and rejects neither", async () => { + const { deps, sessionService } = lifecycleDeps(); + const tools = createCtoOperatorTools(deps); + + await expect((tools.snoozeSession as any).execute({ + sessionId: "chat-1", + untilIso: "2026-07-26T18:00:00Z", + })).resolves.toMatchObject({ success: true }); + expect(sessionService.snoozeSession).toHaveBeenCalledWith("chat-1", "2026-07-26T18:00:00.000Z"); + + await (tools.snoozeSession as any).execute({ sessionId: "chat-1", durationMinutes: 60 }); + const deadline = String( + (sessionService.snoozeSession.mock.calls[1] as unknown as unknown[])[1], + ); + expect(Date.parse(deadline)).toBeGreaterThan(Date.now()); + + await expect((tools.snoozeSession as any).execute({ sessionId: "chat-1" })) + .resolves.toMatchObject({ success: false }); + await expect((tools.snoozeSession as any).execute({ + sessionId: "chat-1", + untilIso: "tomorrow", + })).resolves.toMatchObject({ success: false }); + expect(sessionService.snoozeSession).toHaveBeenCalledTimes(2); + }); + + it("wakes with a recorded reason, defaulting to manual", async () => { + const { deps, sessionService } = lifecycleDeps(); + const tools = createCtoOperatorTools(deps); + + await (tools.wakeSession as any).execute({ sessionId: "chat-1" }); + expect(sessionService.wakeSession).toHaveBeenCalledWith("chat-1", "manual"); + await (tools.wakeSession as any).execute({ sessionId: "chat-1", reason: "turn_complete" }); + expect(sessionService.wakeSession).toHaveBeenLastCalledWith("chat-1", "turn_complete"); + }); + }); + // ── Lane tools ────────────────────────────────────────────────── describe("lane tools", () => { diff --git a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts index abab72863..59d61fc93 100644 --- a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts +++ b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts @@ -13,6 +13,8 @@ import type { AutomationRunListArgs, GitPullArgs, OperatorNavigationSuggestion, + SessionSettleOverride, + SessionWakeReason, TestRunSummary, TestSuiteDefinition, } from "../../../../shared/types"; @@ -40,7 +42,17 @@ export interface CtoOperatorToolDeps { laneService: ReturnType; prService?: ReturnType | null; fileService?: ReturnType | null; - sessionService: Pick, "updateMeta">; + sessionService: Pick< + ReturnType, + | "updateMeta" + | "get" + | "settleSession" + | "unsettleSession" + | "setSettleOverride" + | "snoozeSession" + | "wakeSession" + | "clearWokeMarker" + >; testService?: { listSuites: () => TestSuiteDefinition[]; run: (args: { laneId: string; suiteId: string }) => Promise; @@ -241,6 +253,65 @@ function resolveWorkspaceIdForLane( throw new Error(`Workspace not found for lane ${laneId}.`); } +/** + * The lifecycle slice the CTO needs to triage a row: whether it is settled, + * whether it is deliberately quiet, and — the load-bearing bit — WHY it came + * back if a snooze was broken early. + */ +function readSessionLifecycle( + deps: Pick, + sessionId: string, +): { + settledAt: string | null; + settleOverride: SessionSettleOverride | null; + statusNote: string | null; + attentionRequestedAt: string | null; + attentionMessage: string | null; + lastTurnFailedAt: string | null; + snoozedUntil: string | null; + snoozedAt: string | null; + snoozed: boolean; + wokeAt: string | null; + wokeReason: SessionWakeReason | null; +} | null { + // Not every host wires the full session service (the prompt-manifest preview + // and older harnesses pass only `updateMeta`), and a missing lifecycle read + // must degrade to "unknown" rather than break the tool it decorates. + const session = typeof deps.sessionService?.get === "function" + ? deps.sessionService.get(sessionId) + : null; + if (!session) return null; + const snoozedUntil = session.snoozedUntil ?? null; + const snoozedUntilMs = snoozedUntil ? Date.parse(snoozedUntil) : NaN; + return { + settledAt: session.settledAt ?? null, + settleOverride: session.settleOverride ?? null, + statusNote: session.statusNote ?? null, + attentionRequestedAt: session.attentionRequestedAt ?? null, + attentionMessage: session.attentionMessage ?? null, + lastTurnFailedAt: session.lastTurnFailedAt ?? null, + snoozedUntil, + snoozedAt: session.snoozedAt ?? null, + snoozed: Number.isFinite(snoozedUntilMs) && snoozedUntilMs > Date.now(), + wokeAt: session.wokeAt ?? null, + wokeReason: session.wokeReason ?? null, + }; +} + +function resolveSnoozeDeadline(args: { + untilIso?: string | null; + durationMinutes?: number | null; +}): string | null { + const raw = args.untilIso?.trim(); + if (raw) { + const parsed = new Date(raw); + return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString(); + } + const minutes = args.durationMinutes ?? null; + if (minutes == null || !Number.isFinite(minutes) || minutes <= 0) return null; + return new Date(Date.now() + Math.floor(minutes) * 60_000).toISOString(); +} + export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record { const tools: Record = {}; @@ -314,7 +385,10 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record { + const lifecycle = readSessionLifecycle(deps, chat.sessionId); + return lifecycle ? { ...chat, lifecycle } : chat; + }); + return { success: true, count: withLifecycle.length, chats: withLifecycle }; }, }); @@ -433,14 +511,139 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record { const session = await deps.getChatStatus(sessionId); if (!session) return { success: false, error: `Chat not found: ${sessionId}` }; - return { success: true, session }; + return { success: true, session, lifecycle: readSessionLifecycle(deps, sessionId) }; + }, + }); + + tools.getSessionLifecycle = tool({ + description: + "Read the settle/snooze lifecycle for any ADE session (chat or tracked CLI). Use this to triage " + + "what needs attention: `snoozed` rows are deliberately quiet until `snoozedUntil`, and `wokeReason` " + + "explains why a snoozed row came back early ('needs_you' = blocked on a human, 'error' = the turn " + + "failed, 'turn_complete' = the work finished, 'timer' = the snooze simply expired, 'manual' = someone woke it).", + inputSchema: z.object({ + sessionId: z.string().trim().min(1), + }), + execute: async ({ sessionId }) => { + const lifecycle = readSessionLifecycle(deps, sessionId); + if (!lifecycle) return { success: false, error: `Session not found: ${sessionId}` }; + return { success: true, sessionId, ...lifecycle }; + }, + }); + + tools.settleSession = tool({ + description: + "Mark an ADE session complete so it drops out of the active tier. Pass `outcome` to record a one-line " + + "result on the row. Real activity (a new turn, an approval request, a failed turn) un-settles it again.", + inputSchema: z.object({ + sessionId: z.string().trim().min(1), + outcome: z.string().trim().min(1).optional().describe("Short outcome line, e.g. 'PR #841 merged, CI green'."), + }), + execute: async ({ sessionId, outcome }) => { + try { + const ok = deps.sessionService.settleSession(sessionId, outcome ? { outcome } : {}); + if (!ok) return { success: false, error: `Session not found: ${sessionId}` }; + return { success: true, sessionId, ...readSessionLifecycle(deps, sessionId) }; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + }, + }); + + tools.unsettleSession = tool({ + description: + "Return a settled ADE session to the active lifecycle. An explicit keep-active pin survives; " + + "to force a derived-settle row back to active use setSessionSettleOverride with 'active'.", + inputSchema: z.object({ + sessionId: z.string().trim().min(1), + }), + execute: async ({ sessionId }) => { + try { + const ok = deps.sessionService.unsettleSession(sessionId); + if (!ok) return { success: false, error: `Session not found: ${sessionId}` }; + return { success: true, sessionId, ...readSessionLifecycle(deps, sessionId) }; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + }, + }); + + tools.setSessionSettleOverride = tool({ + description: + "Pin an ADE session's settle state. 'settled' behaves like a declared settle, 'active' is a keep-active " + + "pin that beats the derived clean-exit auto-settle, and null hands the row back to the derived rules.", + inputSchema: z.object({ + sessionId: z.string().trim().min(1), + override: z.enum(["settled", "active", "clear"]).describe("'clear' removes the pin."), + }), + execute: async ({ sessionId, override }) => { + try { + const normalized = override === "clear" ? null : override; + const ok = deps.sessionService.setSettleOverride(sessionId, normalized); + if (!ok) return { success: false, error: `Session not found: ${sessionId}` }; + return { success: true, sessionId, ...readSessionLifecycle(deps, sessionId) }; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + }, + }); + + tools.snoozeSession = tool({ + description: + "Hide an ADE session from the attention surfaces until a deadline. Snooze is a visibility overlay, not a " + + "lifecycle change: the session keeps running, and a hand-raise (approval request, failed turn, completed " + + "turn) wakes it early with a recorded reason. Pass either `untilIso` or `durationMinutes`.", + inputSchema: z.object({ + sessionId: z.string().trim().min(1), + untilIso: z.string().trim().min(1).optional().describe("ISO-8601 deadline. Wins over durationMinutes."), + durationMinutes: z + .number() + .int() + .positive() + .max(60 * 24 * 30) + .optional() + .describe("Minutes from now. Ignored when untilIso is supplied."), + }), + execute: async ({ sessionId, untilIso, durationMinutes }) => { + try { + const deadline = resolveSnoozeDeadline({ untilIso, durationMinutes }); + if (!deadline) { + return { success: false, error: "Pass a valid untilIso timestamp or a positive durationMinutes." }; + } + const ok = deps.sessionService.snoozeSession(sessionId, deadline); + if (!ok) return { success: false, error: `Session not found: ${sessionId}` }; + return { success: true, sessionId, ...readSessionLifecycle(deps, sessionId) }; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + }, + }); + + tools.wakeSession = tool({ + description: "Clear a snooze on an ADE session so it resurfaces now. No-op when the session was not snoozed.", + inputSchema: z.object({ + sessionId: z.string().trim().min(1), + reason: z + .enum(["timer", "needs_you", "error", "turn_complete", "manual"]) + .optional() + .describe("Recorded on the row so the surfaces can explain why it came back. Defaults to 'manual'."), + }), + execute: async ({ sessionId, reason }) => { + try { + const woke = deps.sessionService.wakeSession(sessionId, reason ?? "manual"); + return { success: true, sessionId, woke, ...readSessionLifecycle(deps, sessionId) }; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } }, }); diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 8f52276b3..42c0319fa 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -16975,6 +16975,50 @@ export function createAgentChatService(args: { return true; }; + // If this breaks after an SDK update, triage each new subtype into the handled + // or deliberately ignored union below after checking both stream readers and the renderer. + type ClaudeSystemSubtype = Extract["subtype"]; + + type HandledClaudeSystemSubtype = + | "api_retry" + | "background_tasks_changed" + | "commands_changed" + | "compact_boundary" + | "files_persisted" + | "hook_response" + | "informational" + | "init" + | "local_command_output" + | "memory_recall" + | "mirror_error" + | "model_refusal_fallback" + | "notification" + | "permission_denied" + | "session_state_changed" + | "status" + | "task_notification" + | "task_progress" + | "task_started" + | "task_updated" + | "worker_shutting_down"; + + type IgnoredClaudeSystemSubtype = + | "control_request_progress" + | "elicitation_complete" + | "hook_progress" + | "hook_started" + | "model_refusal_no_fallback" + | "plugin_install" + | "thinking_tokens"; + + type UntriagedClaudeSystemSubtype = Exclude< + ClaudeSystemSubtype, + HandledClaudeSystemSubtype | IgnoredClaudeSystemSubtype + >; + + type AssertNever = T; + type _ClaudeSystemSubtypesAreTriaged = AssertNever; + const handleClaudeIdleMessage = async ( managed: ManagedChatSession, runtime: ClaudeRuntime, @@ -17029,6 +17073,19 @@ export function createAgentChatService(args: { return; } + // The SDK's authoritative turn-over signal: 'idle' fires after heldBackResult flushes + // and the background-agent loop exits. Idle turns are opened by background/subagent + // output that carries no result envelope of its own, so before this they could linger + // as a permanently "running" turn. finishClaudeIdleTurn no-ops when no idle turn is + // open, which makes this safe on every idle transition. Deliberately event-based -- + // do not reintroduce a time-based idle watchdog, which was removed for false positives. + if (msg.type === "system" && record.subtype === "session_state_changed") { + if (record.state === "idle") { + await finishClaudeIdleTurn(managed, runtime, state); + } + return; + } + if (msg.type === "system" && record.subtype === "status" && record.status === "compacting") { const turnId = startClaudeIdleTurn(managed, runtime, state, "Claude is compacting context"); const internalCompactionPending = runtime.contextGuardrail.pendingInternalCompactionTrigger !== null; diff --git a/apps/desktop/src/main/services/git/ghOpenPrLookup.ts b/apps/desktop/src/main/services/git/ghOpenPrLookup.ts new file mode 100644 index 000000000..2e64959ac --- /dev/null +++ b/apps/desktop/src/main/services/git/ghOpenPrLookup.ts @@ -0,0 +1,119 @@ +import { spawn } from "child_process"; + +import { parseGithubRemoteUrl } from "../../../shared/githubRemote"; +import { + EMPTY_GH_OPEN_PR_SUMMARY, + GH_PR_LIST_JSON_FIELDS, + GH_PR_LIST_LEGACY_JSON_FIELDS, + selectOwnRepoOpenPr, + type GhOpenPrSummary, +} from "./ghPrHeadRepo"; +import { runGit } from "./git"; + +const GH_PR_LIST_TIMEOUT_MS = 8_000; +/** + * `--head` matches on branch name across every fork, so the row we want is not + * necessarily first. Fetch a small page and let the head-repo filter choose. + */ +const GH_PR_LIST_LIMIT = "10"; + +async function resolveOriginOwner(worktreePath: string): Promise { + const result = await runGit(["remote", "get-url", "origin"], { + cwd: worktreePath, + timeoutMs: 5_000, + }).catch(() => null); + if (!result || result.exitCode !== 0) return null; + return parseGithubRemoteUrl(result.stdout.trim())?.owner ?? null; +} + +/** + * Runs `gh pr list`. Resolves the raw JSON on success, `""` when gh ran and + * exited non-zero (bad flag, not authenticated, not a repo), and `null` when gh + * could not be run at all or timed out. Callers use that distinction to decide + * whether a retry with different flags could possibly help. + */ +function runGhPrList(args: { + worktreePath: string; + branch: string; + fields: string; +}): Promise { + return new Promise((resolve) => { + let settled = false; + let out = ""; + const child = spawn( + "gh", + [ + "pr", + "list", + "--head", + args.branch, + "--state", + "open", + "--json", + args.fields, + "--limit", + GH_PR_LIST_LIMIT, + ], + { + cwd: args.worktreePath, + env: process.env, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + const finish = (value: string | null) => { + if (settled) return; + settled = true; + clearTimeout(timer); + try { child.kill("SIGKILL"); } catch { /* noop */ } + resolve(value); + }; + const timer = setTimeout(() => finish(null), GH_PR_LIST_TIMEOUT_MS); + child.stdout.on("data", (d: Buffer | string) => { + out += Buffer.isBuffer(d) ? d.toString("utf8") : String(d); + }); + child.stderr.on("data", () => { /* swallow — may contain auth state */ }); + // gh missing / not executable: no retry can fix that. + child.on("error", () => finish(null)); + child.on("close", (code) => finish(code === 0 ? out : "")); + }); +} + +/** + * Find the lane's own open PR for `branch`, ignoring same-named branches that + * live in somebody else's fork. Never throws — every failure degrades to the + * empty summary, exactly like the callers' previous inline implementations. + */ +export async function lookupOpenPrForBranch(args: { + worktreePath: string; + branch: string; +}): Promise { + const worktreePath = args.worktreePath.trim(); + const branch = args.branch.trim(); + if (!worktreePath || !branch) return { ...EMPTY_GH_OPEN_PR_SUMMARY }; + try { + const [expectedOwner, rawJson] = await Promise.all([ + resolveOriginOwner(worktreePath), + runGhPrList({ worktreePath, branch, fields: GH_PR_LIST_JSON_FIELDS }), + ]); + if (rawJson) return selectOwnRepoOpenPr({ rawJson, expectedOwner }); + // null means gh could not run at all — a retry cannot help. + if (rawJson === null) return { ...EMPTY_GH_OPEN_PR_SUMMARY }; + // Empty means gh ran and exited non-zero. One cause is a CLI too old to know + // headRepositoryOwner: gh rejects an unknown --json field outright rather + // than omitting it, which would silently report "no PR" for every lane. + // Retry once with the legacy field set. Without an owner we cannot verify + // the head repo, and parseGhPrListEntry treats an absent owner as + // unverifiable-accept, so this degrades to the pre-filter behavior rather + // than to nothing. Any other non-zero cause simply fails again, costing one + // extra spawn behind the caller's existing cache. + const legacyJson = await runGhPrList({ + worktreePath, + branch, + fields: GH_PR_LIST_LEGACY_JSON_FIELDS, + }); + if (!legacyJson) return { ...EMPTY_GH_OPEN_PR_SUMMARY }; + return selectOwnRepoOpenPr({ rawJson: legacyJson, expectedOwner }); + } catch { + return { ...EMPTY_GH_OPEN_PR_SUMMARY }; + } +} diff --git a/apps/desktop/src/main/services/git/ghPrHeadRepo.test.ts b/apps/desktop/src/main/services/git/ghPrHeadRepo.test.ts new file mode 100644 index 000000000..c84084a07 --- /dev/null +++ b/apps/desktop/src/main/services/git/ghPrHeadRepo.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, it } from "vitest"; + +import { + GH_PR_LIST_JSON_FIELDS, + GH_PR_LIST_LEGACY_JSON_FIELDS, + ghPrHeadRepoMatchesLane, + parseGhPrListEntry, + selectOwnRepoOpenPr, +} from "./ghPrHeadRepo"; + +function ghRow(overrides: Record = {}) { + return { + url: "https://github.com/acme/widgets/pull/12", + number: 12, + title: "Fix auth", + headRefName: "hotfix-auth", + headRepositoryOwner: { id: "MDQ6VXNlcjE=", login: "acme" }, + headRepository: { id: "R_1", name: "widgets" }, + ...overrides, + }; +} + +describe("GH_PR_LIST_JSON_FIELDS", () => { + it("requests the head-repository fields the fork filter needs", () => { + expect(GH_PR_LIST_JSON_FIELDS.split(",")).toEqual([ + "url", + "number", + "title", + "headRefName", + "headRepositoryOwner", + "headRepository", + ]); + }); +}); + +describe("parseGhPrListEntry", () => { + it("decodes the modern gh object shapes", () => { + expect(parseGhPrListEntry(ghRow())).toEqual({ + url: "https://github.com/acme/widgets/pull/12", + number: 12, + title: "Fix auth", + headRefName: "hotfix-auth", + headRepositoryOwner: "acme", + headRepositoryName: "widgets", + }); + }); + + it("gh < 2.47: omits headRepositoryOwner/headRepository without dropping the PR", () => { + const entry = parseGhPrListEntry({ + url: "https://github.com/acme/widgets/pull/12", + number: 12, + title: "Fix auth", + headRefName: "hotfix-auth", + }); + + expect(entry).not.toBeNull(); + expect(entry?.headRepositoryOwner).toBeNull(); + expect(entry?.headRepositoryName).toBeNull(); + expect(entry?.number).toBe(12); + }); + + it("accepts bare strings so a future gh shape change degrades gracefully", () => { + const entry = parseGhPrListEntry( + ghRow({ headRepositoryOwner: "acme", headRepository: "widgets" }), + ); + expect(entry?.headRepositoryOwner).toBe("acme"); + expect(entry?.headRepositoryName).toBe("widgets"); + }); + + it("returns null for non-objects", () => { + expect(parseGhPrListEntry(null)).toBeNull(); + expect(parseGhPrListEntry("nope")).toBeNull(); + expect(parseGhPrListEntry([])).toBeNull(); + }); +}); + +describe("ghPrHeadRepoMatchesLane", () => { + it("accepts a PR whose head repo owner matches the lane remote owner", () => { + expect( + ghPrHeadRepoMatchesLane({ entry: { headRepositoryOwner: "acme" }, expectedOwner: "acme" }), + ).toBe(true); + }); + + it("matches owners case-insensitively", () => { + expect( + ghPrHeadRepoMatchesLane({ entry: { headRepositoryOwner: "AcMe" }, expectedOwner: "acme" }), + ).toBe(true); + }); + + it("rejects a PR opened from a fork with a colliding branch name", () => { + expect( + ghPrHeadRepoMatchesLane({ entry: { headRepositoryOwner: "mallory" }, expectedOwner: "acme" }), + ).toBe(false); + }); + + it("gh < 2.47: an absent owner means 'cannot verify, accept' — never reject", () => { + expect( + ghPrHeadRepoMatchesLane({ entry: { headRepositoryOwner: null }, expectedOwner: "acme" }), + ).toBe(true); + }); + + it("accepts when the lane remote owner is unknown (non-GitHub or unparseable origin)", () => { + expect( + ghPrHeadRepoMatchesLane({ entry: { headRepositoryOwner: "mallory" }, expectedOwner: null }), + ).toBe(true); + expect( + ghPrHeadRepoMatchesLane({ entry: { headRepositoryOwner: "mallory" }, expectedOwner: " " }), + ).toBe(true); + }); +}); + +describe("selectOwnRepoOpenPr", () => { + it("skips the fork PR and returns the lane's own PR further down the page", () => { + const rawJson = JSON.stringify([ + ghRow({ + url: "https://github.com/acme/widgets/pull/99", + number: 99, + title: "Drive-by", + headRepositoryOwner: { login: "mallory" }, + headRepository: { name: "widgets" }, + }), + ghRow(), + ]); + + expect(selectOwnRepoOpenPr({ rawJson, expectedOwner: "acme" })).toEqual({ + prUrl: "https://github.com/acme/widgets/pull/12", + prNumber: 12, + title: "Fix auth", + headRefName: "hotfix-auth", + }); + }); + + it("returns the empty summary when every candidate is a fork PR", () => { + const rawJson = JSON.stringify([ghRow({ headRepositoryOwner: { login: "mallory" } })]); + + expect(selectOwnRepoOpenPr({ rawJson, expectedOwner: "acme" })).toEqual({ + prUrl: null, + prNumber: null, + title: null, + headRefName: null, + }); + }); + + it("gh < 2.47: keeps working when the payload has no head-repository fields at all", () => { + const rawJson = JSON.stringify([ + { + url: "https://github.com/acme/widgets/pull/12", + number: 12, + title: "Fix auth", + headRefName: "hotfix-auth", + }, + ]); + + expect(selectOwnRepoOpenPr({ rawJson, expectedOwner: "acme" })).toEqual({ + prUrl: "https://github.com/acme/widgets/pull/12", + prNumber: 12, + title: "Fix auth", + headRefName: "hotfix-auth", + }); + }); + + it("degrades to the empty summary on empty, malformed, or non-array output", () => { + const empty = { prUrl: null, prNumber: null, title: null, headRefName: null }; + expect(selectOwnRepoOpenPr({ rawJson: "", expectedOwner: "acme" })).toEqual(empty); + expect(selectOwnRepoOpenPr({ rawJson: "not json", expectedOwner: "acme" })).toEqual(empty); + expect(selectOwnRepoOpenPr({ rawJson: "{}", expectedOwner: "acme" })).toEqual(empty); + expect(selectOwnRepoOpenPr({ rawJson: "[]", expectedOwner: "acme" })).toEqual(empty); + }); +}); + +/** + * Regression: `gh` rejects an unknown --json field with a NON-ZERO EXIT rather + * than omitting it, so simply asking for headRepositoryOwner against an older + * CLI failed the entire lookup and reported "no open PR" for every lane. The + * lenient per-entry decode never ran, because the process never succeeded. + */ +describe("gh legacy field fallback", () => { + it("requests only fields that predate the head-repo additions", () => { + const legacy = GH_PR_LIST_LEGACY_JSON_FIELDS.split(","); + expect(legacy).toEqual(["url", "number", "title", "headRefName"]); + // The whole point of the fallback is that it cannot reintroduce the fields + // whose absence forced it. + expect(legacy).not.toContain("headRepositoryOwner"); + expect(legacy).not.toContain("headRepository"); + expect(GH_PR_LIST_JSON_FIELDS.split(",")).toContain("headRepositoryOwner"); + }); + + it("accepts a legacy-shaped row that carries no owner, rather than dropping the PR", () => { + // Exactly what the fallback query returns: no headRepositoryOwner key at all. + const rawJson = JSON.stringify([ + { + url: "https://github.com/acme/widgets/pull/12", + number: 12, + title: "Fix auth", + headRefName: "hotfix-auth", + }, + ]); + + // Unverifiable owner must mean accept — degrading to pre-filter behavior — + // never reject, which would hide the lane's own PR. + expect(selectOwnRepoOpenPr({ rawJson, expectedOwner: "acme" })).toEqual({ + prUrl: "https://github.com/acme/widgets/pull/12", + prNumber: 12, + title: "Fix auth", + headRefName: "hotfix-auth", + }); + }); +}); diff --git a/apps/desktop/src/main/services/git/ghPrHeadRepo.ts b/apps/desktop/src/main/services/git/ghPrHeadRepo.ts new file mode 100644 index 000000000..a06751d70 --- /dev/null +++ b/apps/desktop/src/main/services/git/ghPrHeadRepo.ts @@ -0,0 +1,142 @@ +/** + * Head-repository filtering for `gh pr list --head `. + * + * `gh pr list --head` matches on branch NAME only, so a pull request opened + * from a fork that happens to use the same branch name will be returned and + * attach itself to the lane. Requesting the head-repository fields lets us + * drop those. + * + * Compatibility: `headRepositoryOwner` / `headRepository` are only emitted by + * `gh >= 2.47`. Older CLIs omit them entirely, so parsing MUST be lenient and + * an absent field MUST mean "cannot verify — accept" rather than "reject"; + * a strict decode would silently drop every PR for anyone on an older gh. + */ + +/** JSON field set requested from `gh pr list`. */ +export const GH_PR_LIST_JSON_FIELDS = + "url,number,title,headRefName,headRepositoryOwner,headRepository"; + +/** + * Fallback field set for a `gh` too old to expose the head-repo fields on + * `pr list`. `gh` rejects an unknown --json field with a non-zero exit rather + * than omitting it, so requesting the newer fields against an old CLI fails the + * entire lookup. These four have been present for as long as `pr list --json` + * has existed; a result decoded from them carries no owner, which + * `ghPrHeadRepoMatchesLane` treats as unverifiable-accept. + */ +export const GH_PR_LIST_LEGACY_JSON_FIELDS = "url,number,title,headRefName"; + +export type GhPrListEntry = { + url: string | null; + number: number | null; + title: string | null; + headRefName: string | null; + /** `null` when gh did not report it (older gh, or a deleted fork). */ + headRepositoryOwner: string | null; + /** `null` when gh did not report it (older gh, or a deleted fork). */ + headRepositoryName: string | null; +}; + +function readString(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +/** + * gh renders these as objects (`{"id":"...","login":"acme"}` / + * `{"id":"...","name":"widgets"}`). Accept a bare string too so a future + * shape change degrades to "unverifiable" instead of a hard failure. + */ +function readNamedNode(value: unknown, keys: readonly string[]): string | null { + const direct = readString(value); + if (direct) return direct; + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const record = value as Record; + for (const key of keys) { + const candidate = readString(record[key]); + if (candidate) return candidate; + } + return null; +} + +/** Lenient decode of one `gh pr list --json ...` array element. */ +export function parseGhPrListEntry(raw: unknown): GhPrListEntry | null { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; + const record = raw as Record; + return { + url: readString(record.url), + number: typeof record.number === "number" && Number.isFinite(record.number) + ? record.number + : null, + title: readString(record.title), + headRefName: readString(record.headRefName), + headRepositoryOwner: readNamedNode(record.headRepositoryOwner, ["login", "name"]), + headRepositoryName: readNamedNode(record.headRepository, ["name", "nameWithOwner"]), + }; +} + +/** + * True when the PR's head repository is (or cannot be proven not to be) the + * lane's own remote. + * + * Accepts when either side is unknown — an older gh omits the field, and a + * lane whose origin is not a parseable GitHub remote has nothing to compare + * against. Only a confirmed owner mismatch rejects. + */ +export function ghPrHeadRepoMatchesLane(args: { + entry: Pick; + expectedOwner: string | null | undefined; +}): boolean { + const expected = (args.expectedOwner ?? "").trim(); + const actual = (args.entry.headRepositoryOwner ?? "").trim(); + if (!expected || !actual) return true; + return expected.toLowerCase() === actual.toLowerCase(); +} + +export type GhOpenPrSummary = { + prUrl: string | null; + prNumber: number | null; + title: string | null; + headRefName: string | null; +}; + +export const EMPTY_GH_OPEN_PR_SUMMARY: GhOpenPrSummary = { + prUrl: null, + prNumber: null, + title: null, + headRefName: null, +}; + +/** + * Pick the first PR in a `gh pr list` payload whose head repository belongs to + * the lane's own remote owner. + * + * We deliberately ask gh for more than one row: `--head` matches by branch name + * across forks, so a fork PR can sort ahead of ours and `--limit 1` would leave + * nothing to fall back to once it is filtered out. + */ +export function selectOwnRepoOpenPr(args: { + rawJson: string; + expectedOwner: string | null | undefined; +}): GhOpenPrSummary { + const raw = args.rawJson.trim(); + if (!raw) return { ...EMPTY_GH_OPEN_PR_SUMMARY }; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return { ...EMPTY_GH_OPEN_PR_SUMMARY }; + } + if (!Array.isArray(parsed)) return { ...EMPTY_GH_OPEN_PR_SUMMARY }; + for (const item of parsed) { + const entry = parseGhPrListEntry(item); + if (!entry) continue; + if (!ghPrHeadRepoMatchesLane({ entry, expectedOwner: args.expectedOwner })) continue; + return { + prUrl: entry.url, + prNumber: entry.number, + title: entry.title, + headRefName: entry.headRefName, + }; + } + return { ...EMPTY_GH_OPEN_PR_SUMMARY }; +} diff --git a/apps/desktop/src/main/services/git/gitOperationsService.ts b/apps/desktop/src/main/services/git/gitOperationsService.ts index 2aa607369..56de94248 100644 --- a/apps/desktop/src/main/services/git/gitOperationsService.ts +++ b/apps/desktop/src/main/services/git/gitOperationsService.ts @@ -1,5 +1,5 @@ -import { spawn } from "node:child_process"; import path from "node:path"; +import { lookupOpenPrForBranch } from "./ghOpenPrLookup"; import { getHeadSha, runGit, runGitOrThrow } from "./git"; import { detectConflictKind, parseNameOnly } from "./gitConflictState"; import type { @@ -1745,43 +1745,7 @@ export function createGitOperationsService({ const branch = args.branch?.trim() || lane.branchRef?.trim() || ""; if (!branch) return fallback; - try { - const stdout = await new Promise((resolve) => { - let settled = false; - let out = ""; - const child = spawn("gh", ["pr", "list", "--head", branch, "--state", "open", "--json", "url,number,title,headRefName", "--limit", "1"], { - cwd: lane.worktreePath, - env: process.env, - stdio: ["ignore", "pipe", "pipe"], - }); - const finish = (value: string) => { - if (settled) return; - settled = true; - clearTimeout(timer); - try { child.kill("SIGKILL"); } catch { /* noop */ } - resolve(value); - }; - const timer = setTimeout(() => finish(""), 8_000); - child.stdout.on("data", (d: Buffer | string) => { - out += Buffer.isBuffer(d) ? d.toString("utf8") : String(d); - }); - child.stderr.on("data", () => { /* swallow auth state */ }); - child.on("error", () => finish("")); - child.on("close", (code) => finish(code === 0 ? out : "")); - }); - if (!stdout.trim()) return fallback; - const parsed: unknown = JSON.parse(stdout); - if (!Array.isArray(parsed) || parsed.length === 0) return fallback; - const entry = parsed[0] as Record; - return { - prUrl: typeof entry.url === "string" && entry.url ? entry.url : null, - prNumber: typeof entry.number === "number" ? entry.number : null, - title: typeof entry.title === "string" && entry.title ? entry.title : null, - headRefName: typeof entry.headRefName === "string" && entry.headRefName ? entry.headRefName : null, - }; - } catch { - return fallback; - } + return await lookupOpenPrForBranch({ worktreePath: lane.worktreePath, branch }); }, async checkoutBranch(args: GitCheckoutBranchArgs): Promise { diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index b357ca757..f67062abb 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -54,6 +54,7 @@ import { import type { ProductAnalyticsService } from "../analytics/productAnalyticsService"; import type { createProjectSecretService } from "../secrets/projectSecretService"; import { PROJECT_SECRET_ENV_MAX_BYTES } from "../secrets/projectSecretEnv"; +import { lookupOpenPrForBranch } from "../git/ghOpenPrLookup"; import { runGit } from "../git/git"; import type { AdeCleanupResult, @@ -139,9 +140,12 @@ import type { CreateLaneArgs, CreateChildLaneArgs, CreateLaneFromUnstagedArgs, + LaneBranchDrift, LaneBranchSwitchArgs, LaneBranchSwitchPreview, LaneBranchSwitchResult, + ResolveLaneBranchDriftArgs, + ResolveLaneBranchDriftResult, DeleteLaneArgs, DockLayout, GraphPersistedState, @@ -456,6 +460,8 @@ import type { RunTestSuiteArgs, SessionDeltaSummary, SessionLinearIssueLink, + SessionSettleOverride, + SessionWakeReason, StackChainItem, StopTestRunArgs, TerminalSessionDetail, @@ -5545,6 +5551,16 @@ export function registerIpc({ return await ctx.laneService.switchBranch(arg); }); + ipcMain.handle(IPC.lanesGetBranchDrift, async (_event, arg: { laneId: string }): Promise => { + const ctx = ensureLaneContext(); + return await ctx.laneService.getBranchDrift(arg); + }); + + ipcMain.handle(IPC.lanesResolveBranchDrift, async (_event, arg: ResolveLaneBranchDriftArgs): Promise => { + const ctx = ensureLaneContext(); + return await ctx.laneService.resolveBranchDrift(arg); + }); + ipcMain.handle(IPC.lanesAttach, async (_event, arg: AttachLaneArgs): Promise => { const ctx = ensureLaneContext(); const lane = await ctx.laneService.attach(arg); @@ -6494,6 +6510,80 @@ export function registerIpc({ }, ); + ipcMain.handle( + IPC.sessionsSnooze, + async (_event, arg: { sessionId?: unknown; untilIso?: unknown }): Promise => { + const ctx = ensureSessionContext(); + const sessionId = typeof arg?.sessionId === "string" ? arg.sessionId.trim() : ""; + if (!sessionId) throw new Error("Session id is required."); + const untilIso = typeof arg?.untilIso === "string" ? arg.untilIso.trim() : ""; + if (!untilIso) throw new Error("A snooze deadline (untilIso) is required."); + return ctx.sessionService.snoozeSession(sessionId, untilIso); + }, + ); + + ipcMain.handle( + IPC.sessionsWake, + async (_event, arg: { sessionId?: unknown; reason?: unknown }): Promise => { + const ctx = ensureSessionContext(); + const sessionId = typeof arg?.sessionId === "string" ? arg.sessionId.trim() : ""; + if (!sessionId) throw new Error("Session id is required."); + const reason = typeof arg?.reason === "string" ? (arg.reason as SessionWakeReason) : "manual"; + return ctx.sessionService.wakeSession(sessionId, reason); + }, + ); + + ipcMain.handle( + IPC.sessionsSnoozeMany, + async (_event, arg: { sessionIds?: unknown; untilIso?: unknown }): Promise => { + const ctx = ensureSessionContext(); + if (!Array.isArray(arg?.sessionIds)) throw new Error("Session ids are required."); + const untilIso = typeof arg?.untilIso === "string" ? arg.untilIso.trim() : ""; + if (!untilIso) throw new Error("A snooze deadline (untilIso) is required."); + return ctx.sessionService.snoozeSessions( + arg.sessionIds.filter((sessionId): sessionId is string => typeof sessionId === "string"), + untilIso, + ); + }, + ); + + ipcMain.handle( + IPC.sessionsWakeMany, + async (_event, arg: { sessionIds?: unknown; reason?: unknown }): Promise => { + const ctx = ensureSessionContext(); + if (!Array.isArray(arg?.sessionIds)) throw new Error("Session ids are required."); + const reason = typeof arg?.reason === "string" ? (arg.reason as SessionWakeReason) : "manual"; + return ctx.sessionService.wakeSessions( + arg.sessionIds.filter((sessionId): sessionId is string => typeof sessionId === "string"), + reason, + ); + }, + ); + + ipcMain.handle( + IPC.sessionsSetSettleOverride, + async (_event, arg: { sessionId?: unknown; override?: unknown }): Promise => { + const ctx = ensureSessionContext(); + const sessionId = typeof arg?.sessionId === "string" ? arg.sessionId.trim() : ""; + if (!sessionId) throw new Error("Session id is required."); + const override = arg?.override == null ? null : (arg.override as SessionSettleOverride); + if (override !== null && override !== "settled" && override !== "active") { + throw new Error("override must be 'settled', 'active', or null."); + } + return ctx.sessionService.setSettleOverride(sessionId, override); + }, + ); + + ipcMain.handle( + IPC.sessionsClearWokeMarker, + async (_event, arg: { sessionId?: unknown }): Promise => { + const ctx = ensureSessionContext(); + const sessionId = typeof arg?.sessionId === "string" ? arg.sessionId.trim() : ""; + if (!sessionId) throw new Error("Session id is required."); + return ctx.sessionService.clearWokeMarker(sessionId); + }, + ); + ipcMain.handle(IPC.sessionsReadTranscriptTail, async (_event, arg: { sessionId: string; maxBytes?: number; raw?: boolean }): Promise => { const ctx = ensureSessionContext(); const session = ctx.sessionService.get(arg.sessionId); @@ -8453,42 +8543,7 @@ export function registerIpc({ const branch = requestedBranch || laneBranch; if (!branch) return fallback; - try { - const stdout = await new Promise((resolve) => { - let settled = false; - let out = ""; - const child = spawn("gh", ["pr", "list", "--head", branch, "--state", "open", "--json", "url,number,title,headRefName", "--limit", "1"], { - cwd: worktreePath, - env: process.env, - stdio: ["ignore", "pipe", "pipe"], - }); - const finish = (value: string) => { - if (settled) return; - settled = true; - clearTimeout(timer); - try { child.kill("SIGKILL"); } catch { /* noop */ } - resolve(value); - }; - const timer = setTimeout(() => finish(""), 8_000); - child.stdout.on("data", (d: Buffer | string) => { - out += Buffer.isBuffer(d) ? d.toString("utf8") : String(d); - }); - child.stderr.on("data", () => { /* swallow — may contain auth state */ }); - child.on("error", () => finish("")); - child.on("close", (code) => finish(code === 0 ? out : "")); - }); - if (!stdout.trim()) return fallback; - const parsed: unknown = JSON.parse(stdout); - if (!Array.isArray(parsed) || parsed.length === 0) return fallback; - const entry = parsed[0] as Record; - const prUrl = typeof entry.url === "string" && entry.url ? entry.url : null; - const prNumber = typeof entry.number === "number" ? entry.number : null; - const title = typeof entry.title === "string" && entry.title ? entry.title : null; - const headRefName = typeof entry.headRefName === "string" && entry.headRefName ? entry.headRefName : null; - return { prUrl, prNumber, title, headRefName }; - } catch { - return fallback; - } + return await lookupOpenPrForBranch({ worktreePath, branch }); }); ipcMain.handle(IPC.gitSync, async (_event, arg: GitSyncArgs): Promise => { diff --git a/apps/desktop/src/main/services/lanes/laneBranchDrift.test.ts b/apps/desktop/src/main/services/lanes/laneBranchDrift.test.ts new file mode 100644 index 000000000..d4ac97821 --- /dev/null +++ b/apps/desktop/src/main/services/lanes/laneBranchDrift.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from "vitest"; + +import { + detectLaneBranchDrift, + laneNameAdvertisesBranch, + parseWorktreeStatusPorcelainV2, +} from "./laneBranchDrift"; + +describe("parseWorktreeStatusPorcelainV2", () => { + it("reads the live HEAD branch from the header and reports a clean tree", () => { + const stdout = [ + "# branch.oid 0d1f9a3c", + "# branch.head hotfix-auth", + "# branch.upstream origin/hotfix-auth", + "# branch.ab +0 -0", + "", + ].join("\n"); + + expect(parseWorktreeStatusPorcelainV2(stdout)).toEqual({ + dirty: false, + headBranchRef: "hotfix-auth", + }); + }); + + it("treats any non-header line as dirty", () => { + const stdout = [ + "# branch.oid 0d1f9a3c", + "# branch.head ade/feature", + "1 .M N... 100644 100644 100644 aaa bbb src/app.ts", + "? notes.md", + "", + ].join("\n"); + + expect(parseWorktreeStatusPorcelainV2(stdout)).toEqual({ + dirty: true, + headBranchRef: "ade/feature", + }); + }); + + it("reports a detached HEAD as unknown rather than a branch named '(detached)'", () => { + const stdout = ["# branch.oid 0d1f9a3c", "# branch.head (detached)", ""].join("\n"); + + expect(parseWorktreeStatusPorcelainV2(stdout)).toEqual({ + dirty: false, + headBranchRef: null, + }); + }); + + it("tolerates CRLF output and a missing branch header", () => { + expect(parseWorktreeStatusPorcelainV2("# branch.head main\r\n")).toEqual({ + dirty: false, + headBranchRef: "main", + }); + expect(parseWorktreeStatusPorcelainV2("? untracked.txt\n")).toEqual({ + dirty: true, + headBranchRef: null, + }); + expect(parseWorktreeStatusPorcelainV2("")).toEqual({ dirty: false, headBranchRef: null }); + }); + + it("keeps slashes in branch names and does not treat them as path separators", () => { + const parsed = parseWorktreeStatusPorcelainV2("# branch.head ade/start-skill/read-tweet\n"); + expect(parsed.headBranchRef).toBe("ade/start-skill/read-tweet"); + }); +}); + +describe("detectLaneBranchDrift", () => { + it("returns null when HEAD matches the recorded branch", () => { + expect( + detectLaneBranchDrift({ expectedBranchRef: "ade/feature", headBranchRef: "ade/feature" }), + ).toBeNull(); + }); + + it("reports drift when HEAD is on a different branch", () => { + expect( + detectLaneBranchDrift({ expectedBranchRef: "ade/feature", headBranchRef: "hotfix-auth" }), + ).toEqual({ expectedBranchRef: "ade/feature", headBranchRef: "hotfix-auth" }); + }); + + it("normalizes refs/heads/ and origin/ prefixes on both sides before comparing", () => { + expect( + detectLaneBranchDrift({ + expectedBranchRef: "refs/heads/ade/feature", + headBranchRef: "ade/feature", + }), + ).toBeNull(); + expect( + detectLaneBranchDrift({ expectedBranchRef: "ade/feature", headBranchRef: "origin/ade/feature" }), + ).toBeNull(); + }); + + it("compares case-sensitively — git branch names are case-sensitive refs", () => { + expect( + detectLaneBranchDrift({ expectedBranchRef: "Feature", headBranchRef: "feature" }), + ).toEqual({ expectedBranchRef: "Feature", headBranchRef: "feature" }); + }); + + it("returns null when either side is unknown (detached HEAD, unavailable worktree)", () => { + expect(detectLaneBranchDrift({ expectedBranchRef: "ade/feature", headBranchRef: null })).toBeNull(); + expect(detectLaneBranchDrift({ expectedBranchRef: "ade/feature", headBranchRef: " " })).toBeNull(); + expect(detectLaneBranchDrift({ expectedBranchRef: null, headBranchRef: "hotfix-auth" })).toBeNull(); + expect(detectLaneBranchDrift({ expectedBranchRef: undefined, headBranchRef: undefined })).toBeNull(); + }); +}); + +describe("laneNameAdvertisesBranch", () => { + it("matches the full ref and its last segment", () => { + expect(laneNameAdvertisesBranch("ade/fix-auth", "ade/fix-auth")).toBe(true); + expect(laneNameAdvertisesBranch("fix-auth", "ade/fix-auth")).toBe(true); + expect(laneNameAdvertisesBranch("Fix-Auth", "ade/fix-auth")).toBe(true); + }); + + it("leaves hand-written lane names alone", () => { + expect(laneNameAdvertisesBranch("Auth work", "ade/fix-auth")).toBe(false); + expect(laneNameAdvertisesBranch("", "ade/fix-auth")).toBe(false); + expect(laneNameAdvertisesBranch("ade/fix-auth", "")).toBe(false); + }); +}); diff --git a/apps/desktop/src/main/services/lanes/laneBranchDrift.ts b/apps/desktop/src/main/services/lanes/laneBranchDrift.ts new file mode 100644 index 000000000..835db4c95 --- /dev/null +++ b/apps/desktop/src/main/services/lanes/laneBranchDrift.ts @@ -0,0 +1,86 @@ +import type { LaneBranchDrift } from "../../../shared/types"; +import { normalizeBranchName } from "../shared/utils"; + +/** + * Branch drift = the lane worktree's live HEAD no longer points at the branch + * ADE recorded in `lanes.branch_ref`. It happens whenever an agent or the user + * runs `git checkout` inside the worktree; without detection ADE keeps showing + * — and PR-matching against — a branch the lane no longer tracks. + * + * Detection piggybacks on the `git status` call the lane-status refresh already + * makes (see `computeLaneStatus`), so it costs no extra process spawns and needs + * no timer of its own. + */ + +/** git reports a detached HEAD as this literal in porcelain v2 `branch.head`. */ +const DETACHED_HEAD_SENTINEL = "(detached)"; + +export type WorktreeStatusPorcelainV2 = { + dirty: boolean; + /** `null` for a detached HEAD or when git did not report the header. */ + headBranchRef: string | null; +}; + +/** + * Parse `git status --porcelain=v2 --branch`. + * + * Header lines are prefixed `# `; entry lines always start with `1`, `2`, `u`, + * `?` or `!`, never `#`, so the split is unambiguous. Ignored files are not + * listed unless `--ignored` is passed, matching the previous porcelain v1 + * dirty semantics exactly. + */ +export function parseWorktreeStatusPorcelainV2(stdout: string): WorktreeStatusPorcelainV2 { + let dirty = false; + let headBranchRef: string | null = null; + for (const rawLine of stdout.split("\n")) { + const line = rawLine.replace(/\r$/, ""); + if (!line) continue; + if (line.startsWith("#")) { + const match = /^# branch\.head (.*)$/.exec(line); + if (!match) continue; + const value = (match[1] ?? "").trim(); + if (!value || value === DETACHED_HEAD_SENTINEL) continue; + headBranchRef = normalizeBranchName(value).trim() || null; + continue; + } + dirty = true; + } + return { dirty, headBranchRef }; +} + +/** + * Compare the lane's recorded branch against the worktree's live HEAD. + * + * Returns `null` (no drift) when either side is unknown — an unavailable + * worktree or a detached HEAD is not something the drift affordances can act + * on, and nagging about it would be noise. + */ +/** + * True when the lane's display name is just restating the branch it tracks — + * either the whole ref (`ade/fix-auth`) or its last segment (`fix-auth`). + * + * Only those names are re-pointed when a lane adopts a drifted HEAD; a + * hand-written name like "Auth work" advertises no branch and is left alone. + */ +export function laneNameAdvertisesBranch( + laneName: string | null | undefined, + branchRef: string | null | undefined, +): boolean { + const name = (laneName ?? "").trim().toLowerCase(); + const branch = normalizeBranchName((branchRef ?? "").trim()).trim().toLowerCase(); + if (!name || !branch) return false; + if (name === branch) return true; + const lastSegment = branch.split("/").filter(Boolean).pop() ?? ""; + return Boolean(lastSegment) && name === lastSegment; +} + +export function detectLaneBranchDrift(args: { + expectedBranchRef: string | null | undefined; + headBranchRef: string | null | undefined; +}): LaneBranchDrift | null { + const expectedBranchRef = normalizeBranchName((args.expectedBranchRef ?? "").trim()).trim(); + const headBranchRef = normalizeBranchName((args.headBranchRef ?? "").trim()).trim(); + if (!expectedBranchRef || !headBranchRef) return null; + if (expectedBranchRef === headBranchRef) return null; + return { expectedBranchRef, headBranchRef }; +} diff --git a/apps/desktop/src/main/services/lanes/laneListSnapshotService.ts b/apps/desktop/src/main/services/lanes/laneListSnapshotService.ts index 1aab29378..95b504d1b 100644 --- a/apps/desktop/src/main/services/lanes/laneListSnapshotService.ts +++ b/apps/desktop/src/main/services/lanes/laneListSnapshotService.ts @@ -126,15 +126,21 @@ function sessionStatusBucket(args: { runtimeState?: string | null; toolType?: string | null; settledAt?: string | null; + settleOverride?: "settled" | "active" | null; attentionRequestedAt?: string | null; lastTurnFailedAt?: string | null; }): "running" | "awaiting-input" | "ended" { // `ade chat ask` escalation outranks everything; a declared settle maps to // the quiet bucket for lane rollups (badges/counters) but only AT REST so a // background wake still counts as running; a dead chat turn is not running. - // Mirrors canonicalSessionState in shared/sessionCanonicalState.ts. + // The tri-state override is consulted at the same declared-settle tier: + // "active" is an explicit keep-active pin, "settled" acts like a declared + // settle. Mirrors canonicalSessionState in shared/sessionCanonicalState.ts. if (args.attentionRequestedAt) return "awaiting-input"; - if (args.settledAt && (args.status !== "running" || args.runtimeState === "idle")) return "ended"; + const effectiveSettled = args.settleOverride === "active" + ? false + : args.settleOverride === "settled" || Boolean(args.settledAt); + if (effectiveSettled && (args.status !== "running" || args.runtimeState === "idle")) return "ended"; if (args.lastTurnFailedAt) return "ended"; if (args.status === "running") { if (args.runtimeState === "waiting-input") return "awaiting-input"; diff --git a/apps/desktop/src/main/services/lanes/laneService.test.ts b/apps/desktop/src/main/services/lanes/laneService.test.ts index 848a535e6..460171ae0 100644 --- a/apps/desktop/src/main/services/lanes/laneService.test.ts +++ b/apps/desktop/src/main/services/lanes/laneService.test.ts @@ -471,7 +471,7 @@ describe("laneService createFromUnstaged", () => { vi.mocked(runGit).mockImplementation(async (args: string[], options: { cwd?: string } = {}) => { const laneBranchGitStub = defaultLaneBranchGitStub(args); if (laneBranchGitStub) return laneBranchGitStub; - if (args[0] === "status" && args[1] === "--porcelain=v1") { + if (args[0] === "status" && String(args[1]).startsWith("--porcelain")) { if (options.cwd === sourceWorktreePath) { return { exitCode: 0, stdout: stashPushed ? "" : " M src/file.ts\n?? src/new.ts\n", stderr: "" }; } @@ -535,7 +535,7 @@ describe("laneService createFromUnstaged", () => { vi.mocked(runGit).mockImplementation(async (args: string[], options: { cwd?: string } = {}) => { const laneBranchGitStub = defaultLaneBranchGitStub(args); if (laneBranchGitStub) return laneBranchGitStub; - if (args[0] === "status" && args[1] === "--porcelain=v1" && options.cwd === sourceWorktreePath) { + if (args[0] === "status" && String(args[1]).startsWith("--porcelain") && options.cwd === sourceWorktreePath) { return { exitCode: 0, stdout: "M src/file.ts\n", stderr: "" }; } throw new Error(`Unexpected git call: ${args.join(" ")}`); @@ -594,7 +594,7 @@ describe("laneService createFromUnstaged", () => { vi.mocked(runGit).mockImplementation(async (args: string[], options: { cwd?: string } = {}) => { const laneBranchGitStub = defaultLaneBranchGitStub(args); if (laneBranchGitStub) return laneBranchGitStub; - if (args[0] === "status" && args[1] === "--porcelain=v1") { + if (args[0] === "status" && String(args[1]).startsWith("--porcelain")) { if (options.cwd === sourceWorktreePath) { return { exitCode: 0, stdout: stashPushed ? "" : " M README.md\n", stderr: "" }; } @@ -697,7 +697,7 @@ describe("laneService createFromUnstaged", () => { vi.mocked(runGit).mockImplementation(async (args: string[], options: { cwd?: string } = {}) => { const laneBranchGitStub = defaultLaneBranchGitStub(args); if (laneBranchGitStub) return laneBranchGitStub; - if (args[0] === "status" && args[1] === "--porcelain=v1") { + if (args[0] === "status" && String(args[1]).startsWith("--porcelain")) { if (options.cwd === sourceWorktreePath) { return { exitCode: 0, stdout: stashPushed ? "" : " M src/file.ts\n", stderr: "" }; } @@ -747,7 +747,7 @@ describe("laneService createFromUnstaged", () => { vi.mocked(runGit).mockImplementation(async (args: string[], options: { cwd?: string } = {}) => { const laneBranchGitStub = defaultLaneBranchGitStub(args); if (laneBranchGitStub) return laneBranchGitStub; - if (args[0] === "status" && args[1] === "--porcelain=v1" && options.cwd === sourceWorktreePath) { + if (args[0] === "status" && String(args[1]).startsWith("--porcelain") && options.cwd === sourceWorktreePath) { return { exitCode: 0, stdout: "", stderr: "" }; } throw new Error(`Unexpected git call: ${args.join(" ")}`); @@ -811,7 +811,7 @@ describe("laneService create", () => { if (args[0] === "push" && args[1] === "-u") { return { exitCode: 0, stdout: "", stderr: "" }; } - if (args[0] === "status" && args[1] === "--porcelain=v1") { + if (args[0] === "status" && String(args[1]).startsWith("--porcelain")) { return { exitCode: 0, stdout: "", stderr: "" }; } if (args[0] === "rev-list" && args[1] === "--left-right" && args[2] === "--count") { @@ -910,7 +910,7 @@ describe("laneService create", () => { if (args[0] === "push" && args[1] === "-u") { return { exitCode: 0, stdout: "", stderr: "" }; } - if (args[0] === "status" && args[1] === "--porcelain=v1") { + if (args[0] === "status" && String(args[1]).startsWith("--porcelain")) { return { exitCode: 0, stdout: "", stderr: "" }; } if (args[0] === "rev-list" && args[1] === "--left-right" && args[2] === "--count") { @@ -999,7 +999,7 @@ describe("laneService create", () => { if (args[0] === "push" && args[1] === "-u") { return { exitCode: 0, stdout: "", stderr: "" }; } - if (args[0] === "status" && args[1] === "--porcelain=v1") { + if (args[0] === "status" && String(args[1]).startsWith("--porcelain")) { return { exitCode: 0, stdout: "", stderr: "" }; } if (args[0] === "rev-list" && args[1] === "--left-right" && args[2] === "--count") { @@ -1080,7 +1080,7 @@ describe("laneService create", () => { if (args[0] === "push" && args[1] === "-u") { return { exitCode: 0, stdout: "", stderr: "" }; } - if (args[0] === "status" && args[1] === "--porcelain=v1") { + if (args[0] === "status" && String(args[1]).startsWith("--porcelain")) { return { exitCode: 0, stdout: "", stderr: "" }; } if (args[0] === "rev-list" && args[1] === "--left-right" && args[2] === "--count") { @@ -1154,7 +1154,7 @@ describe("laneService create", () => { if (args[0] === "push" && args[1] === "-u") { return { exitCode: 0, stdout: "", stderr: "" }; } - if (args[0] === "status" && args[1] === "--porcelain=v1") { + if (args[0] === "status" && String(args[1]).startsWith("--porcelain")) { return { exitCode: 0, stdout: "", stderr: "" }; } if (args[0] === "rev-list" && args[1] === "--left-right" && args[2] === "--count") { @@ -1498,7 +1498,7 @@ describe("laneService importBranch", () => { if (args[0] === "show-ref" && args[1] === "--verify" && args[3] === "refs/heads/feature/import") { return { exitCode: 1, stdout: "", stderr: "" }; } - if (args[0] === "status" && args[1] === "--porcelain=v1") { + if (args[0] === "status" && String(args[1]).startsWith("--porcelain")) { return { exitCode: 0, stdout: "", stderr: "" }; } if (args[0] === "rev-list" && args[1] === "--left-right" && args[2] === "--count") { @@ -1706,7 +1706,7 @@ describe("laneService importBranch", () => { if (args[0] === "show-ref" && args[1] === "--verify" && args[3] === "refs/heads/feature/existing-local") { return { exitCode: 0, stdout: "", stderr: "" }; } - if (args[0] === "status" && args[1] === "--porcelain=v1") { + if (args[0] === "status" && String(args[1]).startsWith("--porcelain")) { return { exitCode: 0, stdout: "", stderr: "" }; } if (args[0] === "rev-list" && args[1] === "--left-right" && args[2] === "--count") { @@ -1760,7 +1760,7 @@ describe("laneService importBranch", () => { if (args[0] === "show-ref" && args[1] === "--verify" && args[3] === "refs/heads/feature/import-race") { return { exitCode: 0, stdout: "", stderr: "" }; } - if (args[0] === "status" && args[1] === "--porcelain=v1") { + if (args[0] === "status" && String(args[1]).startsWith("--porcelain")) { return { exitCode: 0, stdout: "", stderr: "" }; } if (args[0] === "rev-list" && args[1] === "--left-right" && args[2] === "--count") { @@ -1974,7 +1974,7 @@ describe("laneService rebaseStart", () => { expect(args[3]).toBe("sha-root-before"); return { exitCode: 1, stdout: "", stderr: "" }; } - if (args[0] === "status" && args[1] === "--porcelain=v1") { + if (args[0] === "status" && String(args[1]).startsWith("--porcelain")) { return { exitCode: 0, stdout: "", stderr: "" }; } if (args[0] === "rebase") { @@ -2039,7 +2039,7 @@ describe("laneService rebaseStart", () => { expect(args[3]).toBe("sha-root-before"); return { exitCode: 1, stdout: "", stderr: "" }; } - if (args[0] === "status" && args[1] === "--porcelain=v1") { + if (args[0] === "status" && String(args[1]).startsWith("--porcelain")) { return { exitCode: 0, stdout: "", stderr: "" }; } if (args[0] === "rebase") { @@ -2091,7 +2091,7 @@ describe("laneService rebaseStart", () => { if (args[0] === "merge-base" && args[1] === "--is-ancestor") { return Promise.resolve({ exitCode: 1, stdout: "", stderr: "" }); } - if (args[0] === "status" && args[1] === "--porcelain=v1") { + if (args[0] === "status" && String(args[1]).startsWith("--porcelain")) { return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); } if (args[0] === "rebase") { @@ -2164,7 +2164,7 @@ describe("laneService rebaseStart", () => { expect(args[3]).toBe("sha-root-pre"); return { exitCode: 1, stdout: "", stderr: "" }; } - if (args[0] === "status" && args[1] === "--porcelain=v1") { + if (args[0] === "status" && String(args[1]).startsWith("--porcelain")) { return { exitCode: 0, stdout: "", stderr: "" }; } if (args[0] === "rebase") { @@ -2230,7 +2230,7 @@ describe("laneService rebaseStart", () => { expect(args[3]).toBe("sha-parent"); return { exitCode: 1, stdout: "", stderr: "" }; } - if (args[0] === "status" && args[1] === "--porcelain=v1") { + if (args[0] === "status" && String(args[1]).startsWith("--porcelain")) { return { exitCode: 0, stdout: "", stderr: "" }; } if (args[0] === "rebase") { @@ -2287,7 +2287,7 @@ describe("laneService rebaseStart", () => { expect(args[2]).toBe("sha-origin-main"); return { exitCode: 1, stdout: "", stderr: "" }; } - if (args[0] === "status" && args[1] === "--porcelain=v1") { + if (args[0] === "status" && String(args[1]).startsWith("--porcelain")) { return { exitCode: 0, stdout: "", stderr: "" }; } if (args[0] === "rebase") { @@ -2393,7 +2393,7 @@ describe("laneService rebaseStart", () => { expect(args[3]).toBe("sha-child-head"); return { exitCode: 1, stdout: "", stderr: "" }; } - if (args[0] === "status" && args[1] === "--porcelain=v1") { + if (args[0] === "status" && String(args[1]).startsWith("--porcelain")) { return { exitCode: 0, stdout: "", stderr: "" }; } if (args[0] === "rebase") { @@ -2532,7 +2532,7 @@ describe("laneService rebaseStart", () => { if (args[0] === "merge-base" && args[1] === "--is-ancestor") { return { exitCode: 1, stdout: "", stderr: "" }; } - if (args[0] === "status" && args[1] === "--porcelain=v1") { + if (args[0] === "status" && String(args[1]).startsWith("--porcelain")) { // Worktree is dirty return { exitCode: 0, stdout: " M src/file.ts\n", stderr: "" }; } @@ -2855,7 +2855,7 @@ describe("laneService createChild", () => { const laneBranchGitStub = defaultLaneBranchGitStub(args); if (laneBranchGitStub) return laneBranchGitStub; if (args[0] === "push" && args[1] === "-u") return { exitCode: 0, stdout: "", stderr: "" }; - if (args[0] === "status" && args[1] === "--porcelain=v1") return { exitCode: 0, stdout: "", stderr: "" }; + if (args[0] === "status" && String(args[1]).startsWith("--porcelain")) return { exitCode: 0, stdout: "", stderr: "" }; if (args[0] === "rev-list" && args[1] === "--left-right" && args[2] === "--count") return { exitCode: 0, stdout: "0\t0\n", stderr: "" }; if (args[0] === "rev-parse" && args[1] === "--abbrev-ref" && args[2] === "--symbolic-full-name" && args[3] === "@{upstream}") return { exitCode: 1, stdout: "", stderr: "" }; if (args[0] === "rev-parse" && args[1] === "--path-format=absolute" && args[2] === "--git-dir") return { exitCode: 1, stdout: "", stderr: "" }; @@ -2916,7 +2916,7 @@ describe("laneService createChild", () => { if (args[0] === "push" && args[1] === "-u") { return { exitCode: 0, stdout: "", stderr: "" }; } - if (args[0] === "status" && args[1] === "--porcelain=v1") { + if (args[0] === "status" && String(args[1]).startsWith("--porcelain")) { return { exitCode: 0, stdout: "", stderr: "" }; } if (args[0] === "rev-list" && args[1] === "--left-right" && args[2] === "--count") { @@ -2999,7 +2999,7 @@ describe("laneService createChild", () => { if (args[0] === "push" && args[1] === "-u") { return { exitCode: 0, stdout: "", stderr: "" }; } - if (args[0] === "status" && args[1] === "--porcelain=v1") { + if (args[0] === "status" && String(args[1]).startsWith("--porcelain")) { return { exitCode: 0, stdout: "", stderr: "" }; } if (args[0] === "rev-list" && args[1] === "--left-right" && args[2] === "--count") { @@ -3161,6 +3161,7 @@ describe("laneService stale worktree status", () => { behind: 0, remoteBehind: -1, rebaseInProgress: false, + headBranchRef: null, }); expect(vi.mocked(runGit).mock.calls.some(([args, opts]) => args[0] === "status" && (opts as { cwd?: string } | undefined)?.cwd === childPath @@ -3980,7 +3981,7 @@ describe("laneService - branchSwitch", () => { if (args[0] === "rev-list" && args[1] === "--left-right" && args[2] === "--count") { return { exitCode: 0, stdout: "0\t0\n", stderr: "" }; } - if (args[0] === "status" && args[1] === "--porcelain=v1") { + if (args[0] === "status" && String(args[1]).startsWith("--porcelain")) { return { exitCode: 0, stdout: "", stderr: "" }; } return { exitCode: 1, stdout: "", stderr: `unhandled: ${args.join(" ")}` }; @@ -4152,7 +4153,7 @@ describe("laneService - branchSwitch", () => { if (args[0] === "show-ref" && args[1] === "--verify" && args[2] === "--quiet") { if (args[3] === "refs/heads/feature/target") return { exitCode: 0, stdout: "", stderr: "" }; } - if (args[0] === "status" && args[1] === "--porcelain=v1" && opts.cwd === path.join(repoRoot, "src")) { + if (args[0] === "status" && String(args[1]).startsWith("--porcelain") && opts.cwd === path.join(repoRoot, "src")) { return { exitCode: 0, stdout: " M file.ts\n", stderr: "" }; } return null; @@ -4243,7 +4244,7 @@ describe("laneService - branchSwitch", () => { vi.mocked(runGit).mockImplementation(makeRunGitResponder((args, opts) => { if (args[0] === "show-ref" && args[3] === "refs/heads/main") return { exitCode: 0, stdout: "", stderr: "" }; - if (args[0] === "status" && args[1] === "--porcelain=v1" && opts.cwd === path.join(repoRoot, "d")) { + if (args[0] === "status" && String(args[1]).startsWith("--porcelain") && opts.cwd === path.join(repoRoot, "d")) { return { exitCode: 0, stdout: " M src/foo.ts\n", stderr: "" }; } return null; @@ -5260,3 +5261,257 @@ describe("laneService rename", () => { } }); }); + +describe("laneService branch drift", () => { + beforeEach(() => { + vi.mocked(getHeadSha).mockReset(); + vi.mocked(runGit).mockReset(); + vi.mocked(runGitOrThrow).mockReset(); + }); + + /** + * Permissive git stub: everything the lane-status refresh needs, with a + * per-worktree HEAD branch so drift can be simulated. + */ + function stubDriftGit(args: { + repoRoot: string; + headBranchByPath: Record; + dirtyPaths?: string[]; + }) { + const checkouts: Array<{ cwd: string; branch: string }> = []; + vi.mocked(runGitOrThrow).mockImplementation(async (gitArgs: string[], opts?: { cwd?: string }) => { + if (gitArgs[0] === "checkout") { + checkouts.push({ cwd: opts?.cwd ?? "", branch: gitArgs[gitArgs.length - 1] ?? "" }); + } + return ""; + }); + vi.mocked(runGit).mockImplementation(async (gitArgs: string[], opts?: { cwd?: string }) => { + const laneBranchGitStub = defaultLaneBranchGitStub(gitArgs); + if (laneBranchGitStub) return laneBranchGitStub; + const cwd = opts?.cwd ?? args.repoRoot; + const head = args.headBranchByPath[cwd] ?? "main"; + const dirty = (args.dirtyPaths ?? []).includes(cwd); + if (gitArgs[0] === "rev-parse" && gitArgs[1] === "--path-format=absolute" && gitArgs[2] === "--show-toplevel") { + return { exitCode: 0, stdout: `${cwd}\n`, stderr: "" }; + } + if (gitArgs[0] === "symbolic-ref") { + return { exitCode: 0, stdout: `${head}\n`, stderr: "" }; + } + if (gitArgs[0] === "status" && String(gitArgs[1]).startsWith("--porcelain=v2")) { + const body = dirty ? "1 .M N... 100644 100644 100644 aaa bbb src/app.ts\n" : ""; + return { exitCode: 0, stdout: `# branch.oid abc\n# branch.head ${head}\n${body}`, stderr: "" }; + } + if (gitArgs[0] === "status") { + return { exitCode: 0, stdout: dirty ? " M src/app.ts\n" : "", stderr: "" }; + } + if (gitArgs[0] === "show-ref") return { exitCode: 0, stdout: "", stderr: "" }; + return { exitCode: 1, stdout: "", stderr: "" }; + }); + return { checkouts }; + } + + it("surfaces branchDrift on the lane summary when HEAD wandered off branch_ref", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-drift-detect-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + await seedProjectAndStack(db, { projectId: "proj-drift-detect", repoRoot }); + const childPath = path.join(repoRoot, "child"); + stubDriftGit({ + repoRoot, + headBranchByPath: { + [childPath]: "hotfix-auth", + [path.join(repoRoot, "parent")]: "feature/parent", + [path.join(repoRoot, "main")]: "main", + }, + }); + + const service = createLaneService({ + db, + projectRoot: repoRoot, + projectId: "proj-drift-detect", + defaultBaseRef: "main", + worktreesDir: path.join(repoRoot, "worktrees"), + }); + + const lanes = await service.list({ includeStatus: true }); + expect(lanes.find((lane) => lane.id === "lane-child")?.branchDrift).toEqual({ + expectedBranchRef: "feature/child", + headBranchRef: "hotfix-auth", + }); + expect(lanes.find((lane) => lane.id === "lane-parent")?.branchDrift).toBeNull(); + }); + + it("switch-back refuses on a dirty worktree and leaves branch_ref untouched", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-drift-dirty-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + await seedProjectAndStack(db, { projectId: "proj-drift-dirty", repoRoot }); + const childPath = path.join(repoRoot, "child"); + const { checkouts } = stubDriftGit({ + repoRoot, + headBranchByPath: { [childPath]: "hotfix-auth" }, + dirtyPaths: [childPath], + }); + + const service = createLaneService({ + db, + projectRoot: repoRoot, + projectId: "proj-drift-dirty", + defaultBaseRef: "main", + worktreesDir: path.join(repoRoot, "worktrees"), + }); + + await expect( + service.resolveBranchDrift({ laneId: "lane-child", resolution: "switch-back" }), + ).rejects.toThrow(/uncommitted changes/i); + expect(checkouts).toHaveLength(0); + expect(db.get("select branch_ref from lanes where id = ?", ["lane-child"])).toMatchObject({ + branch_ref: "feature/child", + }); + }); + + it("switch-back checks the recorded branch back out on a clean worktree", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-drift-switchback-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + await seedProjectAndStack(db, { projectId: "proj-drift-switchback", repoRoot }); + const childPath = path.join(repoRoot, "child"); + const { checkouts } = stubDriftGit({ + repoRoot, + headBranchByPath: { [childPath]: "hotfix-auth" }, + }); + + const service = createLaneService({ + db, + projectRoot: repoRoot, + projectId: "proj-drift-switchback", + defaultBaseRef: "main", + worktreesDir: path.join(repoRoot, "worktrees"), + }); + + const result = await service.resolveBranchDrift({ + laneId: "lane-child", + resolution: "switch-back", + expectedHeadBranchRef: "hotfix-auth", + }); + + expect(result.resolution).toBe("switch-back"); + expect(result.branchRef).toBe("feature/child"); + expect(checkouts).toContainEqual({ cwd: childPath, branch: "feature/child" }); + expect(db.get("select branch_ref from lanes where id = ?", ["lane-child"])).toMatchObject({ + branch_ref: "feature/child", + }); + }); + + it("keep-head re-points branch_ref and the branch-derived lane name in one write", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-drift-keep-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + await seedProjectAndStack(db, { projectId: "proj-drift-keep", repoRoot }); + // The lane name is literally advertising the branch it tracks. + db.run("update lanes set name = ? where id = ?", ["feature/child", "lane-child"]); + const childPath = path.join(repoRoot, "child"); + const { checkouts } = stubDriftGit({ + repoRoot, + headBranchByPath: { [childPath]: "hotfix-auth" }, + }); + + const service = createLaneService({ + db, + projectRoot: repoRoot, + projectId: "proj-drift-keep", + defaultBaseRef: "main", + worktreesDir: path.join(repoRoot, "worktrees"), + }); + + const result = await service.resolveBranchDrift({ + laneId: "lane-child", + resolution: "keep-head", + expectedHeadBranchRef: "hotfix-auth", + }); + + expect(result).toMatchObject({ + resolution: "keep-head", + previousBranchRef: "feature/child", + branchRef: "hotfix-auth", + previousLaneName: "feature/child", + laneName: "hotfix-auth", + }); + // keep-head never touches the worktree — HEAD is already where we want it. + expect(checkouts).toHaveLength(0); + expect(db.get("select branch_ref, name from lanes where id = ?", ["lane-child"])).toMatchObject({ + branch_ref: "hotfix-auth", + name: "hotfix-auth", + }); + }); + + it("keep-head preserves a hand-written lane name", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-drift-keep-name-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + await seedProjectAndStack(db, { projectId: "proj-drift-keep-name", repoRoot }); + // A hand-written name — it advertises no branch, so it must survive. + db.run("update lanes set name = ? where id = ?", ["Auth work", "lane-child"]); + const childPath = path.join(repoRoot, "child"); + stubDriftGit({ repoRoot, headBranchByPath: { [childPath]: "hotfix-auth" } }); + + const service = createLaneService({ + db, + projectRoot: repoRoot, + projectId: "proj-drift-keep-name", + defaultBaseRef: "main", + worktreesDir: path.join(repoRoot, "worktrees"), + }); + + const result = await service.resolveBranchDrift({ laneId: "lane-child", resolution: "keep-head" }); + + expect(result.previousLaneName).toBeNull(); + expect(db.get("select branch_ref, name from lanes where id = ?", ["lane-child"])).toMatchObject({ + branch_ref: "hotfix-auth", + name: "Auth work", + }); + }); + + it("rejects a resolution whose expected HEAD no longer matches the worktree", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-drift-stale-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + await seedProjectAndStack(db, { projectId: "proj-drift-stale", repoRoot }); + const childPath = path.join(repoRoot, "child"); + stubDriftGit({ repoRoot, headBranchByPath: { [childPath]: "hotfix-auth" } }); + + const service = createLaneService({ + db, + projectRoot: repoRoot, + projectId: "proj-drift-stale", + defaultBaseRef: "main", + worktreesDir: path.join(repoRoot, "worktrees"), + }); + + await expect( + service.resolveBranchDrift({ + laneId: "lane-child", + resolution: "keep-head", + expectedHeadBranchRef: "some-other-branch", + }), + ).rejects.toThrow(/Refresh and try again/i); + expect(db.get("select branch_ref from lanes where id = ?", ["lane-child"])).toMatchObject({ + branch_ref: "feature/child", + }); + }); + + it("refuses when the lane is already on its recorded branch", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-drift-none-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + await seedProjectAndStack(db, { projectId: "proj-drift-none", repoRoot }); + const childPath = path.join(repoRoot, "child"); + stubDriftGit({ repoRoot, headBranchByPath: { [childPath]: "feature/child" } }); + + const service = createLaneService({ + db, + projectRoot: repoRoot, + projectId: "proj-drift-none", + defaultBaseRef: "main", + worktreesDir: path.join(repoRoot, "worktrees"), + }); + + await expect( + service.resolveBranchDrift({ laneId: "lane-child", resolution: "switch-back" }), + ).rejects.toThrow(/already on its recorded branch/i); + expect(await service.getBranchDrift({ laneId: "lane-child" })).toBeNull(); + }); +}); diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index 60dfb62d8..578e33a96 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -35,6 +35,7 @@ import type { LaneDeleteStepName, LaneIcon, LaneBranchActiveWorkItem, + LaneBranchDrift, LaneBranchProfile, LaneBranchSwitchArgs, LaneBranchSwitchPreview, @@ -50,6 +51,8 @@ import type { ListLanesArgs, ReparentLaneArgs, ReparentLaneResult, + ResolveLaneBranchDriftArgs, + ResolveLaneBranchDriftResult, RebaseAbortArgs, RebaseRun, RebaseRunEventPayload, @@ -67,6 +70,11 @@ import type { } from "../../../shared/types"; import { resolveAdeLayout } from "../../../shared/adeLayout"; import { codedError, encodeCodedErrorMessage } from "../../../shared/codedError"; +import { + detectLaneBranchDrift, + laneNameAdvertisesBranch, + parseWorktreeStatusPorcelainV2, +} from "./laneBranchDrift"; type LaneRow = { id: string; @@ -210,7 +218,8 @@ function cloneLaneStatus(status: LaneStatus): LaneStatus { ahead: status.ahead, behind: status.behind, remoteBehind: status.remoteBehind, - rebaseInProgress: status.rebaseInProgress + rebaseInProgress: status.rebaseInProgress, + headBranchRef: status.headBranchRef ?? null }; } @@ -519,6 +528,10 @@ function toLaneSummary(args: { parentStatus, isEditProtected: row.is_edit_protected === 1, status, + branchDrift: detectLaneBranchDrift({ + expectedBranchRef: row.branch_ref, + headBranchRef: status.headBranchRef, + }), color: row.color, icon: parseLaneIcon(row.icon), tags: parseLaneTags(row.tags_json), @@ -546,8 +559,14 @@ async function computeLaneStatus(worktreePath: string, baseRef: string, branchRe return cloneLaneStatus(DEFAULT_LANE_STATUS); } - const dirtyRes = await runGit(["status", "--porcelain=v1"], { cwd: worktreePath, timeoutMs: 8_000 }); - const dirty = dirtyRes.exitCode === 0 && dirtyRes.stdout.trim().length > 0; + // `--porcelain=v2 --branch` carries the live HEAD branch in its header, so + // branch-drift detection rides along on the dirty check with no extra spawn. + const dirtyRes = await runGit(["status", "--porcelain=v2", "--branch"], { cwd: worktreePath, timeoutMs: 8_000 }); + const parsedStatus = dirtyRes.exitCode === 0 + ? parseWorktreeStatusPorcelainV2(dirtyRes.stdout) + : { dirty: false, headBranchRef: null }; + const dirty = parsedStatus.dirty; + const headBranchRef = parsedStatus.headBranchRef; const countsRes = await runGit(["rev-list", "--left-right", "--count", `${baseRef}...${branchRef}`], { cwd: worktreePath, @@ -593,7 +612,7 @@ async function computeLaneStatus(worktreePath: string, baseRef: string, branchRe // ignore } - return { dirty, ahead, behind, remoteBehind, rebaseInProgress }; + return { dirty, ahead, behind, remoteBehind, rebaseInProgress, headBranchRef }; } async function resolveParentRebaseTarget(args: { @@ -2949,7 +2968,9 @@ export function createLaneService({ throw args.cause instanceof Error ? args.cause : new Error(originalMessage); } - return { + // Named so a few methods (branch-drift resolution) can delegate to sibling + // methods instead of duplicating their transaction/rollback handling. + const laneServiceApi = { async ensurePrimaryLane(): Promise { await ensurePrimaryLane(); }, @@ -4121,6 +4142,187 @@ export function createLaneService({ }; }, + /** + * On-demand branch-drift read for a single lane. + * + * The lane list already carries `branchDrift` (computed from the status + * refresh); this exists for callers that need a fresh answer right before + * acting — e.g. immediately ahead of a PR operation or a new chat turn. + */ + async getBranchDrift(args: { laneId: string }): Promise { + const laneId = args.laneId.trim(); + if (!laneId) return null; + const row = getLaneRow(laneId); + if (!row || row.status === "archived") return null; + if (!(await isExpectedGitWorktreeRoot(row.worktree_path))) return null; + const headRes = await runGit(["symbolic-ref", "--quiet", "--short", "HEAD"], { + cwd: row.worktree_path, + timeoutMs: 5_000, + }).catch(() => null); + const headBranchRef = headRes?.exitCode === 0 ? headRes.stdout.trim() : null; + return detectLaneBranchDrift({ expectedBranchRef: row.branch_ref, headBranchRef }); + }, + + /** + * Resolve branch drift. This is the single entry point the ADE action + * registry / IPC layer should call for both affordances. + * + * - `switch-back` restores the worktree to the recorded `branch_ref`. It + * delegates to `switchBranch`, which refuses (throwing, changing nothing) + * when the worktree is dirty, and rolls the checkout back if the DB write + * fails. + * - `keep-head` re-points `branch_ref` at the live HEAD and, when the lane + * name was merely advertising the old branch, renames the lane to match — + * both inside one transaction, so the lane can never end up pointing at + * one branch while its name advertises another. + */ + async resolveBranchDrift(args: ResolveLaneBranchDriftArgs): Promise { + const laneId = args.laneId.trim(); + if (!laneId) throw new Error("laneId is required."); + const row = getLaneRow(laneId); + if (!row) throw new Error(`Lane not found: ${laneId}`); + if (row.status === "archived") throw new Error("Lane is archived."); + if (!(await isExpectedGitWorktreeRoot(row.worktree_path))) { + throw new Error("This lane's worktree is unavailable."); + } + + const headRes = await runGit(["symbolic-ref", "--quiet", "--short", "HEAD"], { + cwd: row.worktree_path, + timeoutMs: 5_000, + }); + const liveHeadBranchRef = headRes.exitCode === 0 ? headRes.stdout.trim() : ""; + const drift = detectLaneBranchDrift({ + expectedBranchRef: row.branch_ref, + headBranchRef: liveHeadBranchRef, + }); + if (!drift) { + throw new Error("This lane is already on its recorded branch."); + } + const expectedHeadBranchRef = args.expectedHeadBranchRef?.trim(); + if (expectedHeadBranchRef && normalizeBranchKey(expectedHeadBranchRef) !== drift.headBranchRef) { + throw new Error( + `This lane is now on '${drift.headBranchRef}', not '${expectedHeadBranchRef}'. Refresh and try again.`, + ); + } + + if (args.resolution === "switch-back") { + const result = await laneServiceApi.switchBranch({ + laneId: row.id, + branchName: drift.expectedBranchRef, + mode: "existing", + acknowledgeActiveWork: args.acknowledgeActiveWork ?? true, + }); + return { + lane: result.lane, + resolution: "switch-back", + previousBranchRef: drift.headBranchRef, + branchRef: result.lane.branchRef, + previousLaneName: null, + laneName: result.lane.name, + }; + } + + const targetBranchRef = drift.headBranchRef; + const duplicate = findActiveBranchOwner(targetBranchRef, row.id); + if (duplicate) { + throw new Error(`Branch '${targetBranchRef}' is already active in lane '${duplicate.name}'.`); + } + + // Only re-point the name when it is literally advertising the branch the + // lane no longer tracks. A hand-written lane name ("Auth work") advertises + // nothing and must survive. + const previousLaneName = row.name; + const nameAdvertisesOldBranch = laneNameAdvertisesBranch(row.name, drift.expectedBranchRef); + const nameTaken = db.get<{ id: string }>( + ` + select id from lanes + where project_id = ? + and id != ? + and archived_at is null + and lower(name) = lower(?) + limit 1 + `, + [projectId, row.id, targetBranchRef], + ); + const nextLaneName = row.lane_type !== "primary" && nameAdvertisesOldBranch && !nameTaken + ? targetBranchRef + : row.name; + + db.run("begin"); + try { + const existingProfile = getBranchProfileRow(row.id, targetBranchRef); + upsertBranchProfileForRow(row, { + branchRef: targetBranchRef, + baseRef: existingProfile?.base_ref || row.base_ref || defaultBaseRef, + parentLaneId: existingProfile?.parent_lane_id ?? row.parent_lane_id, + sourceBranchRef: existingProfile?.source_branch_ref ?? drift.expectedBranchRef, + lastCheckedOutAt: new Date().toISOString(), + }); + const profile = getBranchProfileRow(row.id, targetBranchRef); + db.run( + ` + update lanes + set branch_ref = ?, + base_ref = ?, + parent_lane_id = ?, + name = ? + where id = ? + and project_id = ? + `, + [ + targetBranchRef, + profile?.base_ref ?? row.base_ref ?? defaultBaseRef, + profile?.parent_lane_id ?? row.parent_lane_id, + nextLaneName, + row.id, + projectId, + ], + ); + // Same rationale as switchBranch: PR rows whose head branch no longer + // matches the lane are stale references and must not bleed into PR + // lookups now that the lane tracks a different branch. + const stalePrRows = db.all<{ id: string }>( + ` + select id from pull_requests + where lane_id = ? + and project_id = ? + and head_branch <> ? + `, + [row.id, projectId, targetBranchRef], + ); + if (stalePrRows.length > 0) { + deletePullRequestRowsByIds(db, projectId, stalePrRows.map((entry) => entry.id)); + } + db.run("commit"); + } catch (err) { + try { db.run("rollback"); } catch { /* swallow rollback failures */ } + throw err; + } + invalidateLaneListCache(); + + if (nextLaneName !== previousLaneName) { + broadcastLifecycleEvent({ + type: "lane-renamed", + laneId: row.id, + laneName: nextLaneName, + previousLaneName, + color: row.color, + }); + } + + const refreshed = (await listLanes({ includeArchived: false, includeStatus: true })) + .find((lane) => lane.id === row.id); + if (!refreshed) throw new Error(`Lane not found after drift resolution: ${row.id}`); + return { + lane: refreshed, + resolution: "keep-head", + previousBranchRef: drift.expectedBranchRef, + branchRef: targetBranchRef, + previousLaneName: nextLaneName !== previousLaneName ? previousLaneName : null, + laneName: nextLaneName, + }; + }, + async getChildren(laneId: string): Promise { // Query only children rows directly instead of fetching and filtering all lanes. const childRows = getChildrenRows(laneId, false); @@ -5664,4 +5866,6 @@ export function createLaneService({ }, }; + + return laneServiceApi; } diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index 38f097bde..f46354b8f 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -4089,6 +4089,7 @@ export function createPtyService({ const session = sessionService.get(sessionId); if ( session?.settledAt + || session?.settleOverride || session?.attentionRequestedAt || session?.lastTurnFailedAt ) { diff --git a/apps/desktop/src/main/services/sessions/sessionService.test.ts b/apps/desktop/src/main/services/sessions/sessionService.test.ts index 6e5fae7d5..81af00c70 100644 --- a/apps/desktop/src/main/services/sessions/sessionService.test.ts +++ b/apps/desktop/src/main/services/sessions/sessionService.test.ts @@ -1428,3 +1428,387 @@ describe("sessionService resume metadata", () => { expect(service.get("session-output")?.lastTurnFailedAt).toBe("2026-03-17T03:05:00.000Z"); }); }); + +describe("sessionService snooze overlay", () => { + async function makeService(prefix: string) { + const projectRoot = makeProjectRoot(prefix); + const db = await openKvDb(path.join(projectRoot, ".ade", "ade.db"), createLogger() as any); + activeDisposers.push(async () => db.close()); + insertProjectGraph(db); + const service = createSessionService({ db }); + service.create({ + sessionId: "session-snooze", + laneId: "lane-1", + ptyId: "pty-snooze", + tracked: true, + title: "Snoozed session", + startedAt: "2026-03-17T00:00:00.000Z", + transcriptPath: "/tmp/session-snooze.log", + toolType: "claude-chat", + }); + return { db, service }; + } + + it("persists snoozedUntil/snoozedAt and clears a stale woke marker", async () => { + const { service } = await makeService("ade-session-service-snooze-"); + + expect(service.snoozeSession("session-snooze", "2026-03-17T04:00:00.000Z", { + snoozedAt: "2026-03-17T01:00:00.000Z", + })).toBe(true); + expect(service.get("session-snooze")).toEqual(expect.objectContaining({ + snoozedUntil: "2026-03-17T04:00:00.000Z", + snoozedAt: "2026-03-17T01:00:00.000Z", + wokeAt: null, + wokeReason: null, + })); + + // Snooze is an overlay: it must not touch a single lifecycle column. + expect(service.get("session-snooze")?.settledAt).toBeNull(); + expect(service.get("session-snooze")?.status).toBe("running"); + + expect(service.wakeSession("session-snooze")).toBe(true); + expect(service.get("session-snooze")).toEqual(expect.objectContaining({ + snoozedUntil: null, + snoozedAt: null, + wokeReason: "manual", + })); + expect(service.get("session-snooze")?.wokeAt).toBeTruthy(); + + // A second wake is a no-op — there is nothing left asleep. + expect(service.wakeSession("session-snooze")).toBe(false); + + // Re-snoozing clears the previous woke marker so the UI does not show a + // stale "woke because…" chip on a row that is asleep again. + service.snoozeSession("session-snooze", "2026-03-17T05:00:00.000Z"); + expect(service.get("session-snooze")).toEqual(expect.objectContaining({ + wokeAt: null, + wokeReason: null, + })); + + expect(service.clearWokeMarker("session-snooze")).toBe(true); + expect(service.snoozeSession("session-snooze", "not-a-date")).toBe(false); + expect(service.snoozeSession("missing-session", "2026-03-17T05:00:00.000Z")).toBe(false); + }); + + it("does NOT early-wake on the error the snooze was taken on top of", async () => { + const { service } = await makeService("ade-session-service-snooze-error-"); + + // The load-bearing case: snooze AFTER a failure, then let the same (or an + // older) failure timestamp be re-stamped. Without the newer-than + // comparison the row re-wakes instantly and snooze does nothing. + service.markLastTurnFailed("session-snooze", "2026-03-17T01:00:00.000Z"); + service.snoozeSession("session-snooze", "2026-03-17T06:00:00.000Z", { + snoozedAt: "2026-03-17T02:00:00.000Z", + }); + + service.markLastTurnFailed("session-snooze", "2026-03-17T01:30:00.000Z"); + expect(service.get("session-snooze")).toEqual(expect.objectContaining({ + snoozedUntil: "2026-03-17T06:00:00.000Z", + wokeReason: null, + })); + + // Exactly at snoozed_at is still the error being snoozed. + service.markLastTurnFailed("session-snooze", "2026-03-17T02:00:00.000Z"); + expect(service.get("session-snooze")?.snoozedUntil).toBe("2026-03-17T06:00:00.000Z"); + + // Strictly newer wakes it, and records why. + service.markLastTurnFailed("session-snooze", "2026-03-17T02:00:00.001Z"); + expect(service.get("session-snooze")).toEqual(expect.objectContaining({ + snoozedUntil: null, + snoozedAt: null, + wokeReason: "error", + })); + }); + + it("early-wakes on a pending input request and on turn completion", async () => { + const { service } = await makeService("ade-session-service-snooze-handraise-"); + + service.snoozeSession("session-snooze", "2026-03-17T06:00:00.000Z", { + snoozedAt: "2026-03-17T02:00:00.000Z", + }); + service.requestAttention("session-snooze", "Approve this?"); + expect(service.get("session-snooze")).toEqual(expect.objectContaining({ + snoozedUntil: null, + wokeReason: "needs_you", + })); + + service.snoozeSession("session-snooze", "2026-03-17T07:00:00.000Z", { + snoozedAt: "2026-03-17T03:00:00.000Z", + }); + // `clearLastTurnFailed` is the "a running turn completed" write site. + service.clearLastTurnFailed("session-snooze"); + expect(service.get("session-snooze")).toEqual(expect.objectContaining({ + snoozedUntil: null, + wokeReason: "turn_complete", + })); + + // An un-snoozed row never records a wake reason from a hand-raise. + service.clearWokeMarker("session-snooze"); + service.clearLastTurnFailed("session-snooze"); + expect(service.get("session-snooze")?.wokeReason).toBeNull(); + expect(service.wakeSessionIfSnoozed("session-snooze", "turn_complete")).toBeNull(); + }); + + it("supports the bulk snooze/wake variants", async () => { + const { service } = await makeService("ade-session-service-snooze-bulk-"); + service.create({ + sessionId: "session-snooze-2", + laneId: "lane-1", + ptyId: "pty-snooze-2", + tracked: true, + title: "Second session", + startedAt: "2026-03-17T00:00:00.000Z", + transcriptPath: "/tmp/session-snooze-2.log", + toolType: "codex", + }); + + expect(service.snoozeSessions( + ["session-snooze", " session-snooze-2 ", "session-snooze", "missing"], + "2026-03-17T08:00:00.000Z", + )).toEqual(["session-snooze", "session-snooze-2"]); + expect(service.get("session-snooze-2")?.snoozedUntil).toBe("2026-03-17T08:00:00.000Z"); + + expect(service.wakeSessions(["session-snooze", "session-snooze-2", "missing"])) + .toEqual(["session-snooze", "session-snooze-2"]); + expect(service.get("session-snooze")?.snoozedUntil).toBeNull(); + expect(service.get("session-snooze-2")?.wokeReason).toBe("manual"); + + expect(service.snoozeSessions([], "2026-03-17T08:00:00.000Z")).toEqual([]); + expect(service.snoozeSessions(["session-snooze"], "nope")).toEqual([]); + }); + + // Regression: the hand-raise contract ("a snoozed session comes back when it + // errors") was wired ONLY through chat paths — `markLastTurnFailed`. A tracked + // CLI session that DIED stayed hidden, with no persisted woke marker, until + // its deadline, which "Until I'm asked" puts ~100 years out. + it("early-wakes a snoozed CLI session that ends with a non-zero exit code", async () => { + const { service } = await makeService("ade-session-service-snooze-exit-"); + service.create({ + sessionId: "session-cli", + laneId: "lane-1", + ptyId: "pty-cli", + tracked: true, + title: "Tracked CLI", + startedAt: "2026-03-17T00:00:00.000Z", + transcriptPath: "/tmp/session-cli.log", + toolType: "claude", + }); + + service.snoozeSession("session-cli", "2026-03-17T06:00:00.000Z", { + snoozedAt: "2026-03-17T02:00:00.000Z", + }); + service.end({ + sessionId: "session-cli", + endedAt: "2026-03-17T03:00:00.000Z", + exitCode: 1, + status: "failed", + }); + + expect(service.get("session-cli")).toEqual(expect.objectContaining({ + snoozedUntil: null, + snoozedAt: null, + wokeReason: "error", + exitCode: 1, + })); + expect(service.get("session-cli")?.wokeAt).toBeTruthy(); + }); + + it("does NOT wake a snoozed session on a clean exit 0 (that is the settled path)", async () => { + const { service } = await makeService("ade-session-service-snooze-exit-zero-"); + service.create({ + sessionId: "session-cli-clean", + laneId: "lane-1", + ptyId: "pty-cli-clean", + tracked: true, + title: "Tracked CLI", + startedAt: "2026-03-17T00:00:00.000Z", + transcriptPath: "/tmp/session-cli-clean.log", + toolType: "claude", + }); + + service.snoozeSession("session-cli-clean", "2026-03-17T06:00:00.000Z", { + snoozedAt: "2026-03-17T02:00:00.000Z", + }); + service.end({ + sessionId: "session-cli-clean", + endedAt: "2026-03-17T03:00:00.000Z", + exitCode: 0, + status: "completed", + }); + + expect(service.get("session-cli-clean")).toEqual(expect.objectContaining({ + snoozedUntil: "2026-03-17T06:00:00.000Z", + snoozedAt: "2026-03-17T02:00:00.000Z", + wokeAt: null, + wokeReason: null, + })); + + // A user/system stop is not a hand-raise either. + service.end({ + sessionId: "session-cli-clean", + endedAt: "2026-03-17T03:30:00.000Z", + exitCode: null, + status: "disposed", + }); + expect(service.get("session-cli-clean")?.snoozedUntil).toBe("2026-03-17T06:00:00.000Z"); + }); + + it("keeps a session snoozed when it dies on the failure it was snoozed on top of", async () => { + const { service } = await makeService("ade-session-service-snooze-exit-older-"); + service.create({ + sessionId: "session-cli-old", + laneId: "lane-1", + ptyId: "pty-cli-old", + tracked: true, + title: "Tracked CLI", + startedAt: "2026-03-17T00:00:00.000Z", + transcriptPath: "/tmp/session-cli-old.log", + toolType: "claude", + }); + + service.snoozeSession("session-cli-old", "2026-03-17T06:00:00.000Z", { + snoozedAt: "2026-03-17T02:00:00.000Z", + }); + // An end stamped at/older than `snoozed_at` is the death being snoozed. + service.end({ + sessionId: "session-cli-old", + endedAt: "2026-03-17T02:00:00.000Z", + exitCode: 137, + status: "failed", + }); + expect(service.get("session-cli-old")).toEqual(expect.objectContaining({ + snoozedUntil: "2026-03-17T06:00:00.000Z", + wokeReason: null, + })); + }); +}); + +describe("sessionService settle override", () => { + async function makeService(prefix: string) { + const projectRoot = makeProjectRoot(prefix); + const db = await openKvDb(path.join(projectRoot, ".ade", "ade.db"), createLogger() as any); + activeDisposers.push(async () => db.close()); + insertProjectGraph(db); + const service = createSessionService({ db }); + service.create({ + sessionId: "session-override", + laneId: "lane-1", + ptyId: "pty-override", + tracked: true, + title: "Override session", + startedAt: "2026-03-17T00:00:00.000Z", + transcriptPath: "/tmp/session-override.log", + toolType: "codex", + }); + return { db, service }; + } + + it("round-trips the tri-state and rejects junk values", async () => { + const { service } = await makeService("ade-session-service-override-"); + + expect(service.get("session-override")?.settleOverride).toBeNull(); + expect(service.setSettleOverride("session-override", "active")).toBe(true); + expect(service.get("session-override")?.settleOverride).toBe("active"); + expect(service.setSettleOverride("session-override", "settled")).toBe(true); + expect(service.get("session-override")?.settleOverride).toBe("settled"); + expect(service.setSettleOverride("session-override", null)).toBe(true); + expect(service.get("session-override")?.settleOverride).toBeNull(); + expect(service.setSettleOverride("missing-session", "active")).toBe(false); + + // Unknown persisted values normalize away rather than leaking to the UI. + service.setSettleOverride("session-override", "bogus" as never); + expect(service.get("session-override")?.settleOverride).toBeNull(); + }); + + it("clears the override on real activity, exactly like settled_at", async () => { + const { service } = await makeService("ade-session-service-override-activity-"); + + service.setSettleOverride("session-override", "active"); + service.setLastOutputPreview("session-override", "final answer"); + // Preview writes that preserve settle must preserve the override too. + expect(service.get("session-override")?.settleOverride).toBe("active"); + + service.setLastOutputPreview("session-override", "working", { clearSettled: true }); + expect(service.get("session-override")?.settleOverride).toBeNull(); + + service.setSettleOverride("session-override", "settled"); + service.touchSessionActivity("session-override", "2026-03-17T01:00:00.000Z", { clearSettled: false }); + expect(service.get("session-override")?.settleOverride).toBe("settled"); + service.touchSessionActivity("session-override", "2026-03-17T01:01:00.000Z"); + expect(service.get("session-override")?.settleOverride).toBeNull(); + + service.setSettleOverride("session-override", "active"); + service.clearTurnStartMarkers("session-override"); + expect(service.get("session-override")?.settleOverride).toBeNull(); + + service.setSettleOverride("session-override", "settled"); + service.markLastTurnFailed("session-override", "2026-03-17T02:00:00.000Z"); + expect(service.get("session-override")?.settleOverride).toBeNull(); + + service.setSettleOverride("session-override", "settled"); + service.requestAttention("session-override", "Approve?"); + expect(service.get("session-override")?.settleOverride).toBeNull(); + }); + + it("keeps settle and the keep-active pin from contradicting each other", async () => { + const { service } = await makeService("ade-session-service-override-settle-"); + + // An explicit settle drops a stale keep-active pin. + service.setSettleOverride("session-override", "active"); + service.settleSession("session-override", { settledAt: "2026-03-17T03:00:00.000Z" }); + expect(service.get("session-override")).toEqual(expect.objectContaining({ + settledAt: "2026-03-17T03:00:00.000Z", + settleOverride: null, + })); + + // Unsettle drops a 'settled' override… + service.setSettleOverride("session-override", "settled"); + service.unsettleSession("session-override"); + expect(service.get("session-override")).toEqual(expect.objectContaining({ + settledAt: null, + settleOverride: null, + })); + + // …but must not undo an explicit keep-active decision. + service.setSettleOverride("session-override", "active"); + service.unsettleSession("session-override"); + expect(service.get("session-override")?.settleOverride).toBe("active"); + }); + + it("supports the bulk override variant", async () => { + const { service } = await makeService("ade-session-service-override-bulk-"); + service.create({ + sessionId: "session-override-2", + laneId: "lane-1", + ptyId: "pty-override-2", + tracked: true, + title: "Second override session", + startedAt: "2026-03-17T00:00:00.000Z", + transcriptPath: "/tmp/session-override-2.log", + toolType: "codex", + }); + + expect(service.setSettleOverrides(["session-override", "session-override-2", "missing"], "active")) + .toEqual(["session-override", "session-override-2"]); + expect(service.get("session-override-2")?.settleOverride).toBe("active"); + service.setSettleOverrides(["session-override", "session-override-2"], null); + expect(service.get("session-override")?.settleOverride).toBeNull(); + expect(service.setSettleOverrides([], "active")).toEqual([]); + }); + + it("bulk settle clears the pin and bulk unsettle preserves it", async () => { + const { service } = await makeService("ade-session-service-override-bulk-settle-"); + + service.setSettleOverride("session-override", "active"); + expect(service.settleSessions(["session-override"])).toEqual(["session-override"]); + expect(service.get("session-override")?.settleOverride).toBeNull(); + + service.setSettleOverride("session-override", "settled"); + service.unsettleSessions(["session-override"]); + expect(service.get("session-override")?.settleOverride).toBeNull(); + + service.setSettleOverride("session-override", "active"); + service.unsettleSessions(["session-override"]); + expect(service.get("session-override")?.settleOverride).toBe("active"); + }); +}); diff --git a/apps/desktop/src/main/services/sessions/sessionService.ts b/apps/desktop/src/main/services/sessions/sessionService.ts index d864fb4c5..ea2319408 100644 --- a/apps/desktop/src/main/services/sessions/sessionService.ts +++ b/apps/desktop/src/main/services/sessions/sessionService.ts @@ -2,6 +2,8 @@ import fs from "node:fs"; import type { AdeDb } from "../state/kvDb"; import type { ClaudeSessionPointer, + SessionSettleOverride, + SessionWakeReason, TerminalSessionDetail, TerminalSessionChangedEvent, TerminalResumeMetadata, @@ -13,7 +15,12 @@ import type { ListSessionsArgs, UpdateSessionMetaArgs, } from "../../../shared/types"; -import { isTrackedAgentCliToolType } from "../../../shared/types"; +import { + isTrackedAgentCliToolType, + parseSessionSettleOverride, + SESSION_WAKE_REASONS, +} from "../../../shared/types"; +import { isWakingSessionError } from "../../../shared/sessionCanonicalState"; import { stripAnsi } from "../../utils/ansiStrip"; import { readHistoryFileSync } from "../storage/historyCompression"; import { @@ -48,6 +55,11 @@ type SessionRow = { attentionRequestedAt: string | null; attentionMessage: string | null; lastTurnFailedAt: string | null; + settleOverride: string | null; + snoozedUntil: string | null; + snoozedAt: string | null; + wokeAt: string | null; + wokeReason: string | null; exitCode: number | null; transcriptPath: string; headShaStart: string | null; @@ -95,6 +107,11 @@ const SESSION_COLUMNS = ` s.attention_requested_at as attentionRequestedAt, s.attention_message as attentionMessage, s.last_turn_failed_at as lastTurnFailedAt, + s.settle_override as settleOverride, + s.snoozed_until as snoozedUntil, + s.snoozed_at as snoozedAt, + s.woke_at as wokeAt, + s.woke_reason as wokeReason, s.exit_code as exitCode, s.transcript_path as transcriptPath, s.head_sha_start as headShaStart, @@ -305,6 +322,27 @@ function normalizeOptionalText(value: unknown, maxChars: number): string | null return text.length ? text.slice(0, maxChars) : null; } +function normalizeSettleOverride(value: unknown): SessionSettleOverride | null { + // undefined (unrecognized) collapses to null here — this is the persistence + // boundary, and the throwing parsers upstream have already rejected garbage. + return parseSessionSettleOverride(value) ?? null; +} + +function normalizeWakeReason(value: unknown): SessionWakeReason | null { + const text = typeof value === "string" ? value.trim().toLowerCase() : ""; + return (SESSION_WAKE_REASONS as readonly string[]).includes(text) + ? (text as SessionWakeReason) + : null; +} + +function normalizeSessionIds(sessionIds: string[]): string[] { + return Array.from(new Set( + (Array.isArray(sessionIds) ? sessionIds : []) + .map((sessionId) => (typeof sessionId === "string" ? sessionId.trim() : "")) + .filter(Boolean), + )); +} + export function createSessionService({ db }: { db: AdeDb }) { const changeListeners = new Set<(event: TerminalSessionChangedEvent) => void>(); @@ -325,6 +363,77 @@ export function createSessionService({ db }: { db: AdeDb }) { return true; }; + /** + * Early wake (hand-raising), shared by every trigger site. + * + * A snoozed row wakes BEFORE its timer when a pending approval / input + * request appears, when a session error strictly NEWER than `snoozed_at` + * lands, or when a running turn completes. The newer-than comparison is + * load-bearing: without it the very error the user snoozed on top of + * re-wakes the row instantly and snooze does nothing. + * + * Waking clears the snooze columns and records why, so the UI can show a + * "woke" marker with its reason until the user visits the row. Timer expiry + * is NOT handled here — it is derived from `snoozed_until` at read time, so + * no scheduler or watchdog exists. + * + * Returns the recorded reason, or null when the row was not snoozed / the + * signal did not qualify. Does not broadcast; call sites are already inside + * a mutation that emits. + */ + const wakeSnoozedRow = ( + sessionId: string, + reason: SessionWakeReason, + opts: { errorAt?: string | null } = {}, + ): SessionWakeReason | null => { + const row = db.get<{ snoozedUntil: string | null; snoozedAt: string | null }>( + "select snoozed_until as snoozedUntil, snoozed_at as snoozedAt from terminal_sessions where id = ? limit 1", + [sessionId], + ); + const snoozedUntil = normalizeIsoTimestamp(row?.snoozedUntil); + if (!snoozedUntil) return null; + if ( + reason === "error" + && !isWakingSessionError( + { snoozedUntil, snoozedAt: normalizeIsoTimestamp(row?.snoozedAt) }, + opts.errorAt, + ) + ) { + return null; + } + db.run( + ` + update terminal_sessions + set snoozed_until = null, + snoozed_at = null, + woke_at = ?, + woke_reason = ? + where id = ? + `, + [new Date().toISOString(), reason, sessionId], + ); + return reason; + }; + + /** + * Did this session END in failure? The PTY-backed mirror of the canonical + * failure tier (`canonicalSessionState` rules 3-4), minus the runtime-state + * check the end write site cannot see: + * - a non-zero exit code is the process reporting it died, + * - status "failed" covers spawn/setup deaths that never got an exit code, + * - "disposed" is a user/system stop, not a failure, + * - exit code 0 is the SETTLED path — the process declaring it's done — and + * must never raise a hand. + */ + const isFailedSessionEnd = ( + exitCode: number | null | undefined, + status: TerminalSessionStatus, + ): boolean => { + if (status === "disposed") return false; + if (typeof exitCode === "number" && Number.isFinite(exitCode)) return exitCode !== 0; + return status === "failed"; + }; + const emitChanged = (event: TerminalSessionChangedEvent): void => { for (const listener of changeListeners) { try { @@ -422,6 +531,11 @@ export function createSessionService({ db }: { db: AdeDb }) { attentionRequestedAt: normalizeIsoTimestamp(row.attentionRequestedAt), attentionMessage: normalizeOptionalText(row.attentionMessage, 500), lastTurnFailedAt: normalizeIsoTimestamp(row.lastTurnFailedAt), + settleOverride: normalizeSettleOverride(row.settleOverride), + snoozedUntil: normalizeIsoTimestamp(row.snoozedUntil), + snoozedAt: normalizeIsoTimestamp(row.snoozedAt), + wokeAt: normalizeIsoTimestamp(row.wokeAt), + wokeReason: normalizeWakeReason(row.wokeReason), chatSessionId: row.chatSessionId ?? null, ownerPid: normalizeOwnerPid(row.ownerPid), ownerProcessStartedAt: normalizeOwnerProcessStartedAt(row.ownerProcessStartedAt), @@ -822,8 +936,15 @@ export function createSessionService({ db }: { db: AdeDb }) { ...params, ], ); + // Same hand-raise rule as the single-session `end()`: a reconcile that + // ends rows AS FAILED wakes any snoozed row it touched (the exit code is + // always null here, so only the status can carry the failure). The usual + // "detached" reconcile is not a failure and wakes nothing, and a clean + // exit never reaches this path at all. + const reconcileFailed = isFailedSessionEnd(null, finalStatus); for (const row of rows) { if (typeof row.id === "string" && row.id.trim().length) { + if (reconcileFailed) wakeSnoozedRow(row.id, "error", { errorAt: finalEndedAt }); emitChanged({ sessionId: row.id, reason: "meta-updated" }); } } @@ -1102,7 +1223,7 @@ export function createSessionService({ db }: { db: AdeDb }) { setLastOutputPreview(sessionId: string, preview: string, opts?: { clearSettled?: boolean }): void { db.run( opts?.clearSettled - ? "update terminal_sessions set last_output_preview = ?, last_output_at = ?, settled_at = null where id = ?" + ? "update terminal_sessions set last_output_preview = ?, last_output_at = ?, settled_at = null, settle_override = null where id = ?" : "update terminal_sessions set last_output_preview = ?, last_output_at = ? where id = ?", [preview, new Date().toISOString(), sessionId] ); @@ -1125,7 +1246,7 @@ export function createSessionService({ db }: { db: AdeDb }) { db.run( opts?.clearSettled === false ? "update terminal_sessions set last_output_at = ? where id = ?" - : "update terminal_sessions set last_output_at = ?, settled_at = null where id = ?", + : "update terminal_sessions set last_output_at = ?, settled_at = null, settle_override = null where id = ?", [at, sessionId] ); }, @@ -1180,6 +1301,17 @@ export function createSessionService({ db }: { db: AdeDb }) { status, sessionId ]); + // A session that DIED is a hand-raise, exactly like a failed chat turn: + // it wakes a snoozed row early and records why, so the row carries a + // persisted "woke · errored" marker instead of staying hidden until its + // (possibly ~100-year "until I'm asked") deadline. Reason "error" keeps + // the newer-than-`snoozed_at` guard, so snoozing on top of an already + // dead session stays snoozed. A clean exit 0 does NOT wake — that is the + // settled path. + if (isFailedSessionEnd(exitCode, status)) { + const woke = wakeSnoozedRow(sessionId, "error", { errorAt: endedAt }); + if (woke) emitChanged({ sessionId, reason: "meta-updated" }); + } }, archiveSession(sessionId: string, archivedAt: string = new Date().toISOString()): boolean { @@ -1215,11 +1347,14 @@ export function createSessionService({ db }: { db: AdeDb }) { const settledAt = normalizeIsoTimestamp(opts.settledAt) ?? new Date().toISOString(); const outcome = normalizeOptionalText(opts.outcome, 200); return mutateSessionMeta(sessionId, (id) => { + // An explicit settle also drops a stale keep-active pin — otherwise the + // override would silently veto the settle the user just asked for. if (outcome) { db.run( ` update terminal_sessions set settled_at = coalesce(settled_at, ?), + settle_override = null, status_note = ?, attention_requested_at = null, attention_message = null @@ -1232,6 +1367,7 @@ export function createSessionService({ db }: { db: AdeDb }) { ` update terminal_sessions set settled_at = coalesce(settled_at, ?), + settle_override = null, attention_requested_at = null, attention_message = null where id = ? @@ -1242,18 +1378,63 @@ export function createSessionService({ db }: { db: AdeDb }) { }); }, + /** + * Clears a declared settle plus any `'settled'` override. An `'active'` + * pin survives, because un-settling must not undo an explicit keep-active + * decision. Rows that derive settle from a clean exit need + * `setSettleOverride(id, "active")`, not unsettle — there is no + * `settled_at` for unsettle to clear on those. + */ unsettleSession(sessionId: string): boolean { return mutateSessionMeta(sessionId, (id) => { - db.run("update terminal_sessions set settled_at = null where id = ?", [id]); + db.run( + ` + update terminal_sessions + set settled_at = null, + settle_override = case when settle_override = 'settled' then null else settle_override end + where id = ? + `, + [id], + ); + }); + }, + + /** + * Tri-state settle override: `"settled"` behaves like a declared settle, + * `"active"` is the explicit keep-active pin that beats the derived exit-0 + * auto-settle, `null` hands the row back to the derived rules. Real + * activity clears it at the same write sites that clear `settled_at`. + */ + setSettleOverride(sessionId: string, override: SessionSettleOverride | null): boolean { + const normalized = override == null ? null : normalizeSettleOverride(override); + return mutateSessionMeta(sessionId, (id) => { + db.run("update terminal_sessions set settle_override = ? where id = ?", [normalized, id]); }); }, + setSettleOverrides(sessionIds: string[], override: SessionSettleOverride | null): string[] { + const ids = normalizeSessionIds(sessionIds); + if (!ids.length) return []; + const normalized = override == null ? null : normalizeSettleOverride(override); + const placeholders = ids.map(() => "?").join(", "); + const present = db.all<{ id: string }>( + `select id from terminal_sessions where id in (${placeholders})`, + ids, + ).map((row) => row.id); + if (!present.length) return []; + const updatePlaceholders = present.map(() => "?").join(", "); + db.run( + `update terminal_sessions set settle_override = ? where id in (${updatePlaceholders})`, + [normalized, ...present], + ); + for (const id of present) { + emitChanged({ sessionId: id, reason: "meta-updated" }); + } + return present; + }, + settleSessions(sessionIds: string[]): string[] { - const ids = Array.from(new Set( - sessionIds - .map((sessionId) => typeof sessionId === "string" ? sessionId.trim() : "") - .filter(Boolean), - )); + const ids = normalizeSessionIds(sessionIds); if (!ids.length) return []; const placeholders = ids.map(() => "?").join(", "); const newlySettled = db.all<{ id: string }>( @@ -1266,6 +1447,7 @@ export function createSessionService({ db }: { db: AdeDb }) { ` update terminal_sessions set settled_at = ?, + settle_override = null, attention_requested_at = null, attention_message = null where id in (${updatePlaceholders}) @@ -1279,15 +1461,16 @@ export function createSessionService({ db }: { db: AdeDb }) { }, unsettleSessions(sessionIds: string[]): void { - const ids = Array.from(new Set( - sessionIds - .map((sessionId) => typeof sessionId === "string" ? sessionId.trim() : "") - .filter(Boolean), - )); + const ids = normalizeSessionIds(sessionIds); if (!ids.length) return; const placeholders = ids.map(() => "?").join(", "); db.run( - `update terminal_sessions set settled_at = null where id in (${placeholders})`, + ` + update terminal_sessions + set settled_at = null, + settle_override = case when settle_override = 'settled' then null else settle_override end + where id in (${placeholders}) + `, ids, ); for (const id of ids) { @@ -1295,6 +1478,126 @@ export function createSessionService({ db }: { db: AdeDb }) { } }, + // ----------------------------------------------------------------------- + // Snooze — synced VISIBILITY overlay. It never touches lifecycle columns + // and `canonicalSessionState()` never reads it; only the UI's filing does. + // ----------------------------------------------------------------------- + + /** + * Snooze a session until `untilIso`. Stamps `snoozed_at` (the baseline the + * early-wake error comparison needs) and clears any stale "woke" marker. + * Returns false for a missing row or an unparseable deadline. + */ + snoozeSession( + sessionId: string, + untilIso: string, + opts: { snoozedAt?: string } = {}, + ): boolean { + const until = normalizeIsoTimestamp(untilIso); + if (!until) return false; + const snoozedAt = normalizeIsoTimestamp(opts.snoozedAt) ?? new Date().toISOString(); + return mutateSessionMeta(sessionId, (id) => { + db.run( + ` + update terminal_sessions + set snoozed_until = ?, + snoozed_at = ?, + woke_at = null, + woke_reason = null + where id = ? + `, + [until, snoozedAt, id], + ); + }); + }, + + /** Bulk snooze; mirrors `settleSessions` and returns the ids it changed. */ + snoozeSessions(sessionIds: string[], untilIso: string, opts: { snoozedAt?: string } = {}): string[] { + const until = normalizeIsoTimestamp(untilIso); + if (!until) return []; + const ids = normalizeSessionIds(sessionIds); + if (!ids.length) return []; + const placeholders = ids.map(() => "?").join(", "); + const present = db.all<{ id: string }>( + `select id from terminal_sessions where id in (${placeholders})`, + ids, + ).map((row) => row.id); + if (!present.length) return []; + const snoozedAt = normalizeIsoTimestamp(opts.snoozedAt) ?? new Date().toISOString(); + const updatePlaceholders = present.map(() => "?").join(", "); + db.run( + ` + update terminal_sessions + set snoozed_until = ?, + snoozed_at = ?, + woke_at = null, + woke_reason = null + where id in (${updatePlaceholders}) + `, + [until, snoozedAt, ...present], + ); + for (const id of present) { + emitChanged({ sessionId: id, reason: "meta-updated" }); + } + return present; + }, + + /** + * Wake a snoozed session now and record why. Returns false when the row is + * missing or was not snoozed (nothing to wake). + */ + wakeSession(sessionId: string, reason: SessionWakeReason = "manual"): boolean { + const trimmed = typeof sessionId === "string" ? sessionId.trim() : ""; + if (!trimmed) return false; + const woke = wakeSnoozedRow(trimmed, normalizeWakeReason(reason) ?? "manual"); + if (!woke) return false; + emitChanged({ sessionId: trimmed, reason: "meta-updated" }); + return true; + }, + + /** Bulk wake; mirrors `unsettleSessions`. */ + wakeSessions(sessionIds: string[], reason: SessionWakeReason = "manual"): string[] { + const ids = normalizeSessionIds(sessionIds); + if (!ids.length) return []; + const normalizedReason = normalizeWakeReason(reason) ?? "manual"; + const woken: string[] = []; + for (const id of ids) { + if (wakeSnoozedRow(id, normalizedReason)) woken.push(id); + } + for (const id of woken) { + emitChanged({ sessionId: id, reason: "meta-updated" }); + } + return woken; + }, + + /** + * Early-wake entry point for hand-raise signals owned by other services + * (chat runtimes, PTY, the action registry). `reason: "error"` additionally + * requires `errorAt` to be strictly newer than the row's `snoozed_at`; + * everything else wakes unconditionally when the row is snoozed. Returns + * the recorded reason, or null when the row stayed asleep. + */ + wakeSessionIfSnoozed( + sessionId: string, + reason: SessionWakeReason, + opts: { errorAt?: string | null } = {}, + ): SessionWakeReason | null { + const trimmed = typeof sessionId === "string" ? sessionId.trim() : ""; + if (!trimmed) return null; + const normalizedReason = normalizeWakeReason(reason); + if (!normalizedReason) return null; + const woke = wakeSnoozedRow(trimmed, normalizedReason, opts); + if (woke) emitChanged({ sessionId: trimmed, reason: "meta-updated" }); + return woke; + }, + + /** Drop the "woke" marker once the user has visited the row. */ + clearWokeMarker(sessionId: string): boolean { + return mutateSessionMeta(sessionId, (id) => { + db.run("update terminal_sessions set woke_at = null, woke_reason = null where id = ?", [id]); + }); + }, + setStatusNote(sessionId: string, note: string | null): boolean { return mutateSessionMeta(sessionId, (id) => { db.run( @@ -1304,6 +1607,11 @@ export function createSessionService({ db }: { db: AdeDb }) { }); }, + /** + * A pending approval / input request is the loudest hand-raise there is: + * it un-settles (including any override) and it wakes a snoozed row early, + * before its timer. + */ requestAttention(sessionId: string, message: string | null): boolean { return mutateSessionMeta(sessionId, (id) => { db.run( @@ -1311,11 +1619,13 @@ export function createSessionService({ db }: { db: AdeDb }) { update terminal_sessions set attention_requested_at = ?, attention_message = ?, - settled_at = null + settled_at = null, + settle_override = null where id = ? `, [new Date().toISOString(), normalizeOptionalText(message, 500), id], ); + wakeSnoozedRow(id, "needs_you"); }); }, @@ -1329,22 +1639,31 @@ export function createSessionService({ db }: { db: AdeDb }) { }, markLastTurnFailed(sessionId: string, at?: string): boolean { + const failedAt = normalizeIsoTimestamp(at) ?? new Date().toISOString(); return mutateSessionMeta(sessionId, (id) => { // A turn failure also un-settles: the declared outcome is now in doubt // and the row must surface red, not hide in the quiet tier. This keeps // settled/failed mutually exclusive at write time, so every surface's // precedence order agrees by construction. db.run( - "update terminal_sessions set last_turn_failed_at = ?, settled_at = null where id = ?", - [normalizeIsoTimestamp(at) ?? new Date().toISOString(), id], + "update terminal_sessions set last_turn_failed_at = ?, settled_at = null, settle_override = null where id = ?", + [failedAt, id], ); + // Early wake, but ONLY for an error newer than the snooze. Snoozing on + // top of an existing failure must stay snoozed. + wakeSnoozedRow(id, "error", { errorAt: failedAt }); }); }, - /** A completed turn supersedes an earlier failure; never touches settle/attention. */ + /** + * A completed turn supersedes an earlier failure; never touches + * settle/attention. It is also the "running turn completed" early-wake + * trigger, so a snoozed row comes back as soon as its work is done. + */ clearLastTurnFailed(sessionId: string): boolean { return mutateSessionMeta(sessionId, (id) => { db.run("update terminal_sessions set last_turn_failed_at = null where id = ?", [id]); + wakeSnoozedRow(id, "turn_complete"); }); }, @@ -1355,6 +1674,7 @@ export function createSessionService({ db }: { db: AdeDb }) { update terminal_sessions set last_turn_failed_at = null, settled_at = null, + settle_override = null, attention_requested_at = null, attention_message = null where id = ? diff --git a/apps/desktop/src/main/services/state/kvDb.test.ts b/apps/desktop/src/main/services/state/kvDb.test.ts index d5b415af1..51ee4e672 100644 --- a/apps/desktop/src/main/services/state/kvDb.test.ts +++ b/apps/desktop/src/main/services/state/kvDb.test.ts @@ -256,6 +256,39 @@ describe("lane_linear_issue_links schema", () => { }); }); +describe("terminal_sessions snooze + settle-override schema", () => { + it("adds the columns as nullable text", async () => { + const projectRoot = makeProjectRoot("ade-kvdb-terminal-sessions-snooze-"); + const dbPath = path.join(projectRoot, ".ade", "ade.db"); + const db = await openKvDb(dbPath, createLogger() as any); + activeDisposers.push(async () => db.close()); + + const columns = db.all<{ name: string; type: string; notnull: number }>( + "pragma table_info('terminal_sessions')", + ); + for (const name of ["settle_override", "snoozed_until", "snoozed_at", "woke_at", "woke_reason"]) { + const column = columns.find((entry) => entry.name === name); + expect(column, `${name} column missing`).toBeTruthy(); + expect(column?.type.toLowerCase()).toBe("text"); + // Nullable is mandatory: these columns replicate to iOS through + // cr-sqlite and existing rows must merge without a value. + expect(column?.notnull).toBe(0); + } + }); + + it("carries no non-PK unique index that would block crsql_as_crr", async () => { + const projectRoot = makeProjectRoot("ade-kvdb-terminal-sessions-index-"); + const dbPath = path.join(projectRoot, ".ade", "ade.db"); + const db = await openKvDb(dbPath, createLogger() as any); + activeDisposers.push(async () => db.close()); + + const uniqueIndexes = db.all<{ name: string }>( + "select name from sqlite_master where type = 'index' and tbl_name = 'terminal_sessions' and sql like '%unique%'", + ); + expect(uniqueIndexes).toHaveLength(0); + }); +}); + describe("session_linear_issues schema", () => { it("creates the table with session/lane/issue columns", async () => { const projectRoot = makeProjectRoot("ade-kvdb-session-linear-cols-"); diff --git a/apps/desktop/src/main/services/state/kvDb.ts b/apps/desktop/src/main/services/state/kvDb.ts index 01a7afe8f..e823196f2 100644 --- a/apps/desktop/src/main/services/state/kvDb.ts +++ b/apps/desktop/src/main/services/state/kvDb.ts @@ -2035,6 +2035,11 @@ function migrate(db: MigrationDb, rawDb: DatabaseSyncType) { attention_requested_at text, attention_message text, last_turn_failed_at text, + settle_override text, + snoozed_until text, + snoozed_at text, + woke_at text, + woke_reason text, chat_session_id text, owner_process_started_at text, foreign key(lane_id) references lanes(id) @@ -2057,6 +2062,19 @@ function migrate(db: MigrationDb, rawDb: DatabaseSyncType) { safeAddColumn(db, "alter table terminal_sessions add column attention_requested_at text"); safeAddColumn(db, "alter table terminal_sessions add column attention_message text"); safeAddColumn(db, "alter table terminal_sessions add column last_turn_failed_at text"); + // Tri-state settle override ('settled' | 'active' | null) consulted before + // the derived exit-0 auto-settle, plus the snooze visibility overlay + // (snoozed_until / snoozed_at) and its "woke" marker. All nullable with NO + // unique index: `terminal_sessions` replicates to iOS through cr-sqlite and + // `crsql_as_crr` rejects any non-PK unique index. The same columns exist in + // apps/ios/ADE/Resources/DatabaseBootstrap.sql + Database.swift's + // ensureColumn migrations — a missing iOS half does not fail here, it + // surfaces as changeset-apply errors on the phone. + safeAddColumn(db, "alter table terminal_sessions add column settle_override text"); + safeAddColumn(db, "alter table terminal_sessions add column snoozed_until text"); + safeAddColumn(db, "alter table terminal_sessions add column snoozed_at text"); + safeAddColumn(db, "alter table terminal_sessions add column woke_at text"); + safeAddColumn(db, "alter table terminal_sessions add column woke_reason text"); safeAddColumn(db, "alter table terminal_sessions add column chat_session_id text"); try { db.run("create index if not exists idx_terminal_sessions_chat_session_id on terminal_sessions(chat_session_id)"); } catch {} // owner_pid identifies the ADE OS process that owns this row's runtime diff --git a/apps/desktop/src/main/services/usage/usageStatsStore.ts b/apps/desktop/src/main/services/usage/usageStatsStore.ts index 05fdeb09d..6efba507a 100644 --- a/apps/desktop/src/main/services/usage/usageStatsStore.ts +++ b/apps/desktop/src/main/services/usage/usageStatsStore.ts @@ -83,6 +83,8 @@ const MEANINGFUL_ACTIONS = new Set([ "work.stopRuntime", "work.settleSession", "work.unsettleSession", + "work.snoozeSession", + "work.wakeSession", "lanes.create", "lanes.createChild", "lanes.createFromUnstaged", @@ -152,6 +154,8 @@ export function usageActionFromIpcChannel(channel: string): string { // Settle lifecycle: single + bulk collapse to one coarse action each. if (action === "sessions.settle" || action === "sessions.settleMany") return "work.settleSession"; if (action === "sessions.unsettle" || action === "sessions.unsettleMany") return "work.unsettleSession"; + if (action === "sessions.snooze" || action === "sessions.snoozeMany") return "work.snoozeSession"; + if (action === "sessions.wake" || action === "sessions.wakeMany") return "work.wakeSession"; return action; } @@ -210,6 +214,10 @@ export function usageActionFromRpcDomain(domain: string, action: string): string if (domain === "session") { if (action === "settleSelfSession" || action === "settleSessions") return "work.settleSession"; if (action === "unsettleSelfSession" || 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"; + if (action === "wakeSession" || action === "wakeSessions") return "work.wakeSession"; } return `${domain}.${action}`; } diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index ca0319457..9551f4760 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -51,9 +51,12 @@ import type { CreateLaneArgs, CreateChildLaneArgs, CreateLaneFromUnstagedArgs, + LaneBranchDrift, LaneBranchSwitchArgs, LaneBranchSwitchPreview, LaneBranchSwitchResult, + ResolveLaneBranchDriftArgs, + ResolveLaneBranchDriftResult, DeleteLaneArgs, DevToolsCheckResult, DiffChanges, @@ -490,6 +493,8 @@ import type { SuggestResolverTargetResult, SessionDeltaSummary, SessionLinearIssueLink, + SessionSettleOverride, + SessionWakeReason, TerminalSessionChangedEvent, StackChainItem, StopTestRunArgs, @@ -1286,6 +1291,10 @@ declare global { switchBranch: ( args: LaneBranchSwitchArgs, ) => Promise; + getBranchDrift: (args: { laneId: string }) => Promise; + resolveBranchDrift: ( + args: ResolveLaneBranchDriftArgs, + ) => Promise; attach: (args: AttachLaneArgs) => Promise; listUnregisteredWorktrees: () => Promise; adoptAttached: (args: AdoptAttachedLaneArgs) => Promise; @@ -1416,6 +1425,27 @@ declare global { unsettle: (sessionId: string) => Promise; settleMany: (sessionIds: string[]) => Promise; unsettleMany: (sessionIds: string[]) => Promise; + snoozeSession: ( + sessionId: string, + untilIso: string, + ) => Promise; + wakeSession: ( + sessionId: string, + reason?: SessionWakeReason, + ) => Promise; + snoozeSessions: ( + sessionIds: string[], + untilIso: string, + ) => Promise; + wakeSessions: ( + sessionIds: string[], + reason?: SessionWakeReason, + ) => Promise; + setSettleOverride: ( + sessionId: string, + override: SessionSettleOverride | null, + ) => Promise; + clearWokeMarker: (sessionId: string) => Promise; readTranscriptTail: (args: ReadTranscriptTailArgs) => Promise; getDelta: (sessionId: string) => Promise; onChanged: ( diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 07bd796db..e20ebf3b5 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -160,8 +160,11 @@ import type { CreateChildLaneArgs, CreateLaneFromUnstagedArgs, LaneBranchSwitchArgs, + LaneBranchDrift, LaneBranchSwitchPreview, LaneBranchSwitchResult, + ResolveLaneBranchDriftArgs, + ResolveLaneBranchDriftResult, DeleteLaneArgs, DevToolsCheckResult, DiffChanges, @@ -555,6 +558,8 @@ import type { DecodeOAuthStateResult, RunTestSuiteArgs, SessionDeltaSummary, + SessionSettleOverride, + SessionWakeReason, TerminalSessionChangedEvent, StackChainItem, StopTestRunArgs, @@ -4644,6 +4649,23 @@ contextBridge.exposeInMainWorld("ade", { clearGitReadCaches(); return result as LaneBranchSwitchResult; }, + getBranchDrift: async (args: { laneId: string }): Promise => + callProjectRuntimeActionOr("lane", "getBranchDrift", { args }, () => + ipcRenderer.invoke(IPC.lanesGetBranchDrift, args), + ), + resolveBranchDrift: async ( + args: ResolveLaneBranchDriftArgs, + ): Promise => { + clearGitReadCaches(); + const result = await callProjectRuntimeActionOr( + "lane", + "resolveBranchDrift", + { args }, + () => ipcRenderer.invoke(IPC.lanesResolveBranchDrift, args), + ); + clearGitReadCaches(); + return result as ResolveLaneBranchDriftResult; + }, attach: async (args: AttachLaneArgs): Promise => { clearGitReadCaches(); const lane = await callProjectRuntimeActionOr( @@ -5290,6 +5312,78 @@ contextBridge.exposeInMainWorld("ade", { ); if (!runtime.handled) await ipcRenderer.invoke(IPC.sessionsUnsettleMany, { sessionIds }); }, + snoozeSession: async (sessionId: string, untilIso: string): Promise => { + const runtime = await callProjectRuntimeActionIfBound( + "session", + "snoozeSession", + { args: { sessionId, untilIso } }, + ); + return runtime.handled + ? runtime.result === true + : ipcRenderer.invoke(IPC.sessionsSnooze, { sessionId, untilIso }); + }, + wakeSession: async ( + sessionId: string, + reason?: SessionWakeReason, + ): Promise => { + const runtime = await callProjectRuntimeActionIfBound( + "session", + "wakeSession", + { args: { sessionId, ...(reason ? { reason } : {}) } }, + ); + return runtime.handled + ? runtime.result === true + : ipcRenderer.invoke(IPC.sessionsWake, { sessionId, ...(reason ? { reason } : {}) }); + }, + snoozeSessions: async ( + sessionIds: string[], + untilIso: string, + ): Promise => { + const runtime = await callProjectRuntimeActionIfBound( + "session", + "snoozeSessions", + { args: { sessionIds, untilIso } }, + ); + return runtime.handled + ? runtime.result ?? [] + : ipcRenderer.invoke(IPC.sessionsSnoozeMany, { sessionIds, untilIso }); + }, + wakeSessions: async ( + sessionIds: string[], + reason?: SessionWakeReason, + ): Promise => { + const runtime = await callProjectRuntimeActionIfBound( + "session", + "wakeSessions", + { args: { sessionIds, ...(reason ? { reason } : {}) } }, + ); + return runtime.handled + ? runtime.result ?? [] + : ipcRenderer.invoke(IPC.sessionsWakeMany, { sessionIds, ...(reason ? { reason } : {}) }); + }, + setSettleOverride: async ( + sessionId: string, + override: SessionSettleOverride | null, + ): Promise => { + const runtime = await callProjectRuntimeActionIfBound( + "session", + "setSettleOverride", + { args: { sessionId, override } }, + ); + return runtime.handled + ? runtime.result === true + : ipcRenderer.invoke(IPC.sessionsSetSettleOverride, { sessionId, override }); + }, + clearWokeMarker: async (sessionId: string): Promise => { + const runtime = await callProjectRuntimeActionIfBound( + "session", + "clearWokeMarker", + { args: { sessionId } }, + ); + return runtime.handled + ? runtime.result === true + : ipcRenderer.invoke(IPC.sessionsClearWokeMarker, { sessionId }); + }, readTranscriptTail: async ( args: ReadTranscriptTailArgs, ): Promise => { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx index 5040a1642..c06557e39 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen, waitFor, within, type RenderResult } from "@testing-library/react"; import type { ComponentProps } from "react"; -import type { NormalizedLinearIssue } from "../../../shared/types"; +import type { IosElementContextItem, NormalizedLinearIssue } from "../../../shared/types"; import { AgentChatComposer } from "./AgentChatComposer"; function installMatchMediaMock(): void { @@ -180,6 +180,19 @@ const CAPTION_FREE_PERMISSION_CASES: Array<{ }, ]; +function makeIosContextItem(id: string): IosElementContextItem { + return { + kind: "ios_element", + id, + componentId: `Component-${id}`, + sourceFile: "Sources/App/ContentView.swift", + sourceLine: 12, + frame: null, + metadata: {}, + selectedAt: "2026-07-26T00:00:00.000Z", + }; +} + function makeLinearIssue(overrides: Partial = {}): NormalizedLinearIssue { return { id: "issue-1", @@ -2012,4 +2025,95 @@ describe("AgentChatComposer", () => { expect(screen.getByText("Creating child lanes…")).toBeTruthy(); }); + it("marks the chips a selection intersects and clears the stale ones", async () => { + const props = buildComposerProps({ + draft: "", + turnActive: false, + shouldAutofocus: false, + iosElementContextItems: [makeIosContextItem("ios-1"), makeIosContextItem("ios-2")], + }); + render(); + + const editor = screen.getByRole("textbox"); + const chips = () => Array.from(editor.querySelectorAll("[data-composer-chip]")); + const selectedChipIds = () => + Array.from(editor.querySelectorAll("[data-composer-chip-selected]")).map((chip) => chip.dataset.iosContextId); + await waitFor(() => expect(chips()).toHaveLength(2)); + + editor.focus(); + const selection = window.getSelection(); + if (!selection) throw new Error("jsdom selection unavailable"); + const selectAll = document.createRange(); + selectAll.selectNodeContents(editor); + selection.removeAllRanges(); + selection.addRange(selectAll); + document.dispatchEvent(new Event("selectionchange")); + + await waitFor(() => expect(selectedChipIds()).toEqual(["ios-1", "ios-2"])); + + // Shrinking the selection onto the first chip must release the second one. + const firstChipOnly = document.createRange(); + firstChipOnly.setStartBefore(chips()[0]); + firstChipOnly.setEndAfter(chips()[0]); + selection.removeAllRanges(); + selection.addRange(firstChipOnly); + document.dispatchEvent(new Event("selectionchange")); + + await waitFor(() => expect(selectedChipIds()).toEqual(["ios-1"])); + + // Collapsing to a plain caret drops every mark. + const caret = document.createRange(); + caret.setStart(editor, 0); + caret.collapse(true); + selection.removeAllRanges(); + selection.addRange(caret); + document.dispatchEvent(new Event("selectionchange")); + + await waitFor(() => expect(selectedChipIds()).toEqual([])); + }); + + it("listens for selectionchange only while the rich composer is focused and holds a chip", async () => { + const url = "https://github.com/arul28/ADE/pull/835"; + (window as any).ade = { + agentChat: { + resolveSmartLinkPreview: vi.fn().mockResolvedValue({ + url, + provider: "github", + kind: "github_pr", + label: "arul28/ADE#835", + }), + }, + }; + const addSpy = vi.spyOn(document, "addEventListener"); + const removeSpy = vi.spyOn(document, "removeEventListener"); + const attachCount = () => addSpy.mock.calls.filter(([type]) => type === "selectionchange").length; + const detachCount = () => removeSpy.mock.calls.filter(([type]) => type === "selectionchange").length; + + try { + const props = buildComposerProps({ draft: url, turnActive: false, shouldAutofocus: false }); + const view = render(); + const editor = screen.getByRole("textbox"); + await waitFor(() => expect(editor.querySelectorAll("[data-composer-chip]")).toHaveLength(1)); + + // Chipped but unfocused: nothing global is attached. + expect(attachCount()).toBe(0); + + editor.focus(); + expect(attachCount()).toBe(1); + + // Chip count drops to zero while still focused: the listener goes away. + view.rerender(); + await waitFor(() => { + expect(editor.querySelectorAll("[data-composer-chip]")).toHaveLength(0); + expect(detachCount()).toBe(1); + }); + + view.unmount(); + expect(detachCount()).toBe(attachCount()); + } finally { + addSpy.mockRestore(); + removeSpy.mockRestore(); + } + }); + }); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index 82ad1afe6..f6974cfb3 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -93,6 +93,15 @@ const ISSUE_CONTEXT_MENU_GAP = 8; const ISSUE_CONTEXT_MENU_VIEWPORT_GUTTER = 8; const IMAGE_URL_EXTENSION_RE = /\.(png|jpe?g|gif|webp|bmp|svg|ico|tiff?)$/i; +// Every rich-composer chip carries `data-composer-chip`. Chips are +// contentEditable="false", so the browser skips them when painting the native +// selection and a drag across one looks like the highlight breaks in half; we +// mirror the selection onto them with `data-composer-chip-selected`, which +// index.css paints as an ::after overlay in the platform selection color. +const COMPOSER_CHIP_SELECTOR = "[data-composer-chip]"; +const COMPOSER_CHIP_SELECTED_ATTR = "data-composer-chip-selected"; +const COMPOSER_CHIP_SELECTED_SELECTOR = `[${COMPOSER_CHIP_SELECTED_ATTR}]`; + // Icon slot styling for smart-link chips. Real brand marks / favicons render as // a clean square glyph; only the text-monogram fallback keeps the tiled badge. const SMART_LINK_ICON_MARK_CLASS = @@ -2539,6 +2548,7 @@ export function AgentChatComposer({ const createIosContextChipNode = useCallback((item: IosElementContextItem): HTMLElement => { const chip = document.createElement("span"); chip.contentEditable = "false"; + chip.dataset.composerChip = "ios-context"; chip.dataset.iosContextId = item.id; chip.className = "mx-0.5 inline-flex max-w-[260px] translate-y-[1px] items-center gap-1.5 rounded-md border border-cyan-300/22 bg-cyan-500/12 px-2 py-0.5 font-sans text-[length:calc(var(--chat-font-size)*11/14)] leading-5 text-cyan-50/85 align-baseline"; chip.title = item.sourceFile ? `${iosContextDisplayLabel(item)} - ${item.sourceFile}${item.sourceLine ? `:${item.sourceLine}` : ""}` : iosContextDisplayLabel(item); @@ -2564,6 +2574,7 @@ export function AgentChatComposer({ const createAppControlContextChipNode = useCallback((item: AppControlContextItem): HTMLElement => { const chip = document.createElement("span"); chip.contentEditable = "false"; + chip.dataset.composerChip = "app-control-context"; chip.dataset.appControlContextId = item.id; chip.className = "mx-0.5 inline-flex max-w-[260px] translate-y-[1px] items-center gap-1.5 rounded-md border border-sky-300/22 bg-sky-500/12 px-2 py-0.5 font-sans text-[length:calc(var(--chat-font-size)*11/14)] leading-5 text-sky-50/85 align-baseline"; chip.title = item.sourceFile @@ -2591,6 +2602,7 @@ export function AgentChatComposer({ const createBuiltInBrowserContextChipNode = useCallback((item: BuiltInBrowserContextItem): HTMLElement => { const chip = document.createElement("span"); chip.contentEditable = "false"; + chip.dataset.composerChip = "built-in-browser-context"; chip.dataset.builtInBrowserContextId = item.id; chip.className = "mx-0.5 inline-flex max-w-[260px] translate-y-[1px] items-center gap-1.5 rounded-md border border-teal-300/22 bg-teal-500/12 px-2 py-0.5 font-sans text-[length:calc(var(--chat-font-size)*11/14)] leading-5 text-teal-50/85 align-baseline"; chip.title = `${builtInBrowserContextDisplayLabel(item)} - ${builtInBrowserContextSourceDescription(item)}`; @@ -2711,6 +2723,119 @@ export function AgentChatComposer({ onDraftChange(next); }, [appControlContextItems, builtInBrowserContextItems, createAppControlContextChipNode, createBuiltInBrowserContextChipNode, createIosContextChipNode, draft, insertNodeAtTextOffset, iosElementContextItems, onDraftChange, serializeRichEditor, tokenizeSmartLinksInEditor, useRichComposer]); + // ── Chip selection highlight ───────────────────────────────────────────── + // The native selection is not painted over contentEditable="false" chips, so + // a drag across one renders as two disconnected highlight runs. Mark the + // chips the selection intersects and let index.css overlay them. + // + // PERF: `selectionchange` fires on every caret move on the Work tab's hottest + // input path. The document listener therefore exists only while the editor is + // focused AND holds a chip, a plain caret costs one boolean, every DOM write + // is coalesced into a single rAF, and all queries are scoped to the editor. + useEffect(() => { + const editor = useRichComposer ? richEditorRef.current : null; + if (!editor) return; + + let frame: number | null = null; + let listening = false; + let marked = false; + + const clearMarks = () => { + if (!marked) return; + editor.querySelectorAll(COMPOSER_CHIP_SELECTED_SELECTOR).forEach((chip) => { + chip.removeAttribute(COMPOSER_CHIP_SELECTED_ATTR); + }); + marked = false; + }; + + const paintSelectedChips = () => { + frame = null; + const selection = window.getSelection(); + const range = selection && selection.rangeCount > 0 && !selection.isCollapsed + ? selection.getRangeAt(0) + : null; + // `getRangeAt` is already start-before-end, so backwards drags need no + // special casing; anything outside the editor just drops the marks. + if (!range || !editor.contains(range.commonAncestorContainer) || typeof range.intersectsNode !== "function") { + clearMarks(); + return; + } + let anySelected = false; + editor.querySelectorAll(COMPOSER_CHIP_SELECTOR).forEach((chip) => { + let selected = false; + try { + selected = range.intersectsNode(chip); + } catch { + selected = false; + } + if (selected) { + anySelected = true; + if (!chip.hasAttribute(COMPOSER_CHIP_SELECTED_ATTR)) chip.setAttribute(COMPOSER_CHIP_SELECTED_ATTR, "true"); + } else if (chip.hasAttribute(COMPOSER_CHIP_SELECTED_ATTR)) { + chip.removeAttribute(COMPOSER_CHIP_SELECTED_ATTR); + } + }); + marked = anySelected; + }; + + const handleSelectionChange = () => { + const selection = window.getSelection(); + // A collapsed caret is the overwhelmingly common case and only needs a + // frame when an earlier selection left highlights behind. + if (!marked && (!selection || selection.isCollapsed)) return; + if (frame != null) return; + frame = window.requestAnimationFrame(paintSelectedChips); + }; + + const stopListening = () => { + if (frame != null) { + window.cancelAnimationFrame(frame); + frame = null; + } + if (listening) { + document.removeEventListener("selectionchange", handleSelectionChange); + listening = false; + } + clearMarks(); + }; + + const syncListener = () => { + const shouldListen = document.activeElement === editor && editor.querySelector(COMPOSER_CHIP_SELECTOR) != null; + if (shouldListen === listening) return; + if (!shouldListen) { + stopListening(); + return; + } + document.addEventListener("selectionchange", handleSelectionChange); + listening = true; + handleSelectionChange(); + }; + + // Chips are inserted/removed by direct DOM writes rather than React + // renders, so watch the editor's structure — never its character data — + // and only while it is focused. + const chipObserver = new MutationObserver(syncListener); + const handleFocus = () => { + chipObserver.observe(editor, { childList: true, subtree: true }); + syncListener(); + }; + const handleBlur = () => { + chipObserver.disconnect(); + stopListening(); + }; + + editor.addEventListener("focus", handleFocus); + editor.addEventListener("blur", handleBlur); + if (document.activeElement === editor) handleFocus(); + + return () => { + editor.removeEventListener("focus", handleFocus); + editor.removeEventListener("blur", handleBlur); + chipObserver.disconnect(); + stopListening(); + }; + }, [useRichComposer]); + const handleSlashSelect = useCallback((cmd: SlashCommandEntry) => { // Local-only commands handled client-side if (cmd.command === "/clear" && cmd.source === "local" && onClearEvents) { onClearEvents(); onDraftChange(""); return; } @@ -4910,7 +5035,11 @@ export function AgentChatComposer({ aria-label={composerInputAccessibleLabel} className={cn( "block w-full resize-none bg-transparent px-4 py-2.5 text-left text-[length:calc(var(--chat-font-size)*13/14)] leading-[1.6] text-fg/88 outline-none transition-colors placeholder:text-muted-fg/30", - plainOverlayContent ? "relative z-[1] text-transparent" : "", + // The textarea sits above the token overlay with transparent text, so the + // default (opaque) selection background would paint over the overlay and + // make the selected text vanish entirely. A translucent selection reads as + // a selection while letting the glyphs underneath stay legible. + plainOverlayContent ? "relative z-[1] text-transparent selection:bg-fg/25" : "", dragActive ? "opacity-30" : "", parallelLaunchBusy || composerInputLocked ? "cursor-not-allowed opacity-50" : "", )} diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 49740b972..f0c1f0cb2 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -157,6 +157,7 @@ import { buildChatAppearanceRootStyle } from "./chatAppearance"; import { copyLaunchPromptToClipboard } from "../../lib/launchPromptClipboard"; import { shouldShowClaudeChatLoginPrompt } from "../../lib/claudeAuthPrompt"; import { LaneAccentDot } from "../lanes/LaneAccentDot"; +import { armLaneBranchDriftWarning, LaneBranchDriftStrip } from "../lanes/LaneBranchDrift"; import { effectiveNewLaneBaseSource, fetchNewLaneBaseBranches, @@ -8787,6 +8788,9 @@ export function AgentChatPane({ }, [refreshSessions, selectedSessionId, touchSession]); const submit = useCallback(async (activeTurnDispatchMode?: AgentChatDispatchSteerMode) => { + // A turn is about to run against this worktree — surface the branch-drift + // strip if HEAD has wandered off the lane's branch. No-op when it hasn't. + armLaneBranchDriftWarning(laneId); if (submitInFlightRef.current || busy || parallelLaunchBusy || projectTransitionBlocksChat) { if (submitInFlightRef.current) { setError("Still sending the previous message. Wait a moment and try again."); @@ -10757,6 +10761,9 @@ export function AgentChatPane({ onLaneChipClick={laneId ? () => navigate(openLaneInLanesTabPath(laneId)) : undefined} showCacheBadge={showClaudeCacheTimer} cacheIdleSinceAt={selectedSession?.idleSinceAt ?? null} + // Ambient settled/snoozed chips — the chat pane otherwise has no + // lifecycle awareness at all. The composer slot below stays with drift. + lifecycleSessionId={selectedSessionId ?? null} showGitToolbar={showWorkspaceChrome} onTogglePrPane={showWorkspaceChrome && laneId ? () => setPrPaneOpen((v) => !v) : undefined} prPaneOpen={prPaneOpen} @@ -11517,6 +11524,7 @@ export function AgentChatPane({ })} {authStickyBar} {awayDigestStrip} + {composerElement} ); @@ -11876,6 +11884,7 @@ export function AgentChatPane({
{authStickyBar} {awayDigestStrip} + {composerElement}
) : null} diff --git a/apps/desktop/src/renderer/components/chat/ChatGitToolbar.tsx b/apps/desktop/src/renderer/components/chat/ChatGitToolbar.tsx index b587079b8..a3953e06f 100644 --- a/apps/desktop/src/renderer/components/chat/ChatGitToolbar.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatGitToolbar.tsx @@ -15,6 +15,7 @@ import { import { AnimatePresence, motion } from "motion/react"; import { cn } from "../ui/cn"; import type { DiffChanges, PrSummary, PrCheck } from "../../../shared/types"; +import { armLaneBranchDriftWarning } from "../lanes/LaneBranchDrift"; import { useLaneGitActionRuntimeState } from "../lanes/LaneGitActionsPane"; import { formatPrBadgeLabel } from "../prs/shared/prFormatters"; import { useAppStore } from "../../state/appStore"; @@ -270,6 +271,9 @@ export const ChatGitToolbar = React.memo(function ChatGitToolbar({ }, [laneId, linkedPr, refreshPr]); const handlePr = useCallback(async () => { + // A PR operation is about to run against this worktree — arm the drift + // warning strip so a wrong-branch PR is caught before it is opened. + armLaneBranchDriftWarning(laneId); if (linkedPr) { navigate(`/prs?tab=normal&prId=${encodeURIComponent(linkedPr.id)}`); return; @@ -562,7 +566,11 @@ export const ChatGitToolbar = React.memo(function ChatGitToolbar({ + + + + ); +} diff --git a/apps/desktop/src/renderer/components/terminals/SessionCard.test.tsx b/apps/desktop/src/renderer/components/terminals/SessionCard.test.tsx index 8296d6c53..3ce8c106b 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionCard.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionCard.test.tsx @@ -676,3 +676,57 @@ describe("SessionCard next wake chip", () => { }, ); }); + +describe("SessionCard snooze and woke markers", () => { + it("shows a snoozed row's wake time and offers Wake now instead of a duration menu", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-09T12:00:00.000Z")); + render( + , + ); + + expect(screen.getByLabelText("Snoozed, wakes in 3h")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Wake session now" })).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Snooze session" })).toBeNull(); + }); + + it("carries a woke marker with the specific reason once the row is back", () => { + render( + , + ); + + expect(screen.getByLabelText("Woke, needs approval")).toBeTruthy(); + }); + + it("labels the hover control for a row that is not snoozed", () => { + render( + , + ); + + expect(screen.getByRole("button", { name: "Snooze session" })).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/renderer/components/terminals/SessionCard.tsx b/apps/desktop/src/renderer/components/terminals/SessionCard.tsx index d3aaf4f20..52b1ec907 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionCard.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionCard.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { CircleNotch, GridFour, WarningCircle, Clock } from "@phosphor-icons/react"; +import { CircleNotch, GridFour, WarningCircle, Clock, Moon } from "@phosphor-icons/react"; import { useNavigate } from "react-router-dom"; import type { AgentChatSpawnKind, LaneSummary, TerminalSessionSummary } from "../../../shared/types"; import type { OrchestrationRole } from "../../../shared/types/orchestration"; @@ -31,6 +31,8 @@ import { shouldShowClaudeCacheTtl } from "../../lib/claudeCacheTtl"; import { ChatSubagentGlyph, chatSubagentColor } from "../chat/chatSubagentIdentity"; import { navigateToSpawnedChat } from "../chat/spawnNavigation"; import { requestLinearIssueQuickView } from "../../lib/linearIssueQuickViewNavigation"; +import { isSessionSnoozed, sessionWokeMarker, snoozeWakeLabel } from "../../lib/sessionSnooze"; +import { SessionSnoozeControl } from "./SessionSnoozeControl"; const DELTA_CHIP_STYLE: React.CSSProperties = { fontSize: 10, @@ -337,6 +339,70 @@ function NextWakeChip({ ); } +/** + * Snoozed rows say when they come back. Snooze is a visibility overlay, so this + * chip is deliberately calm — it never competes with the attention capsule and + * carries a moon glyph so snoozed never reads as settled on shape alone. + */ +function SnoozeWakeChip({ + snoozedUntil, + compact, +}: { + snoozedUntil?: string | null; + compact: boolean; +}) { + const [, tick] = React.useReducer((value: number) => value + 1, 0); + const label = snoozeWakeLabel(snoozedUntil, Date.now()); + + React.useEffect(() => { + if (!snoozedUntil) return undefined; + const intervalId = window.setInterval(tick, 60_000); + return () => window.clearInterval(intervalId); + }, [snoozedUntil]); + + if (!label) return null; + return ( + + + {label} + + ); +} + +/** "Woke" marker + why, carried until the user opens the row. */ +function WokeMarkerChip({ + label, + reason, + compact, +}: { + label: string; + reason: string; + compact: boolean; +}) { + return ( + + Woke + · {label} + + ); +} + export const SessionCard = React.memo(function SessionCard({ session, lane, @@ -437,6 +503,10 @@ export const SessionCard = React.memo(function SessionCard({ : null; const isActiveGrid = gridBadge === "active"; const gridLabel = isActiveGrid ? "In the active grid" : "In another grid"; + // Snooze never touches the canonical phase — it is read straight off the two + // snooze columns via the shared derivation, exactly like the sidebar filing. + const snoozed = isSessionSnoozed(session); + const wokeMarker = sessionWokeMarker(session); return (
{ @@ -525,6 +602,10 @@ export const SessionCard = React.memo(function SessionCard({ ) : null} {capsuleBadge ? : null} + {snoozed ? : null} + {!snoozed && wokeMarker ? ( + + ) : null}
{importedFrom ? ( ) : 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 297b60802..6176a406e 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionContextMenu.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionContextMenu.test.tsx @@ -1,8 +1,8 @@ /* @vitest-environment jsdom */ import React from "react"; -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { TerminalSessionSummary } from "../../../shared/types"; import { SessionContextMenu } from "./SessionContextMenu"; @@ -38,6 +38,7 @@ function renderMenu( session: TerminalSessionSummary, onSetChatTag = vi.fn(), onSettle = vi.fn(), + onUnsettle = vi.fn(), ) { const onClose = vi.fn(); render( @@ -54,9 +55,10 @@ function renderMenu( onRename={vi.fn()} onSetChatTag={onSetChatTag} onSettle={onSettle} + onUnsettle={onUnsettle} />, ); - return { onClose, onSetChatTag, onSettle }; + return { onClose, onSetChatTag, onSettle, onUnsettle }; } describe("SessionContextMenu Claude tags", () => { @@ -148,3 +150,96 @@ describe("SessionContextMenu settle safety", () => { expect(onSettle).not.toHaveBeenCalled(); }); }); + +describe("SessionContextMenu snooze and derived-settle lifecycle", () => { + let sessionsApi: Record>; + + beforeEach(() => { + sessionsApi = { + setSettleOverride: vi.fn().mockResolvedValue(true), + snoozeSession: vi.fn().mockResolvedValue(true), + wakeSession: vi.fn().mockResolvedValue(true), + }; + (window as unknown as { ade: unknown }).ade = { sessions: sessionsApi }; + }); + + afterEach(() => { + delete (window as unknown as { ade?: unknown }).ade; + vi.clearAllMocks(); + }); + + /** exit-0 PTY with no `settledAt`: canonically settled, but nothing declared it. */ + function derivedSettledSession(): TerminalSessionSummary { + return makeSession({ + toolType: "shell", + status: "completed", + runtimeState: "exited", + endedAt: "2026-07-10T12:30:00.000Z", + exitCode: 0, + settledAt: null, + }); + } + + it("gives a DERIVED settled row an Unsettle action backed by the keep-active override", async () => { + // Regression: this row previously fell out of every branch of the settle + // chain and rendered no lifecycle action at all. + const { onUnsettle } = renderMenu(derivedSettledSession()); + + fireEvent.click(screen.getByRole("button", { name: "Unsettle" })); + + await waitFor(() => { + expect(sessionsApi.setSettleOverride).toHaveBeenCalledWith("chat-1", "active"); + }); + // There is no `settledAt` column to clear, so the declared path stays unused. + expect(onUnsettle).not.toHaveBeenCalled(); + // "Keep active" would be the identical call here, so it is not duplicated. + expect(screen.queryByRole("button", { name: "Keep active" })).toBeNull(); + }); + + it("keeps the declared-settle path for settledAt rows and adds a keep-active pin", async () => { + const session = makeSession({ + toolType: "shell", + status: "completed", + runtimeState: "exited", + endedAt: "2026-07-10T12:30:00.000Z", + exitCode: 0, + settledAt: "2026-07-10T12:31:00.000Z", + }); + const { onUnsettle } = renderMenu(session); + + fireEvent.click(screen.getByRole("button", { name: "Unsettle" })); + expect(onUnsettle).toHaveBeenCalledWith(session); + expect(sessionsApi.setSettleOverride).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole("button", { name: "Keep active" })); + await waitFor(() => { + expect(sessionsApi.setSettleOverride).toHaveBeenCalledWith("chat-1", "active"); + }); + }); + + it("expands Snooze into concrete durations and sends an ISO deadline", async () => { + renderMenu(makeSession()); + + fireEvent.click(screen.getByRole("button", { name: "Snooze…" })); + fireEvent.click(screen.getByRole("button", { name: "1 hour" })); + + await waitFor(() => expect(sessionsApi.snoozeSession).toHaveBeenCalledTimes(1)); + const [sessionId, untilIso] = sessionsApi.snoozeSession.mock.calls[0]!; + expect(sessionId).toBe("chat-1"); + expect(Date.parse(untilIso as string)).toBeGreaterThan(Date.now()); + }); + + it("replaces Snooze with Wake now while the row is snoozed", async () => { + renderMenu(makeSession({ + snoozedUntil: new Date(Date.now() + 3_600_000).toISOString(), + snoozedAt: new Date(Date.now() - 60_000).toISOString(), + })); + + expect(screen.queryByRole("button", { name: "Snooze…" })).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: /Wake now/ })); + + await waitFor(() => { + expect(sessionsApi.wakeSession).toHaveBeenCalledWith("chat-1", "manual"); + }); + }); +}); diff --git a/apps/desktop/src/renderer/components/terminals/SessionContextMenu.tsx b/apps/desktop/src/renderer/components/terminals/SessionContextMenu.tsx index 4aaafbb40..ada89fa12 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionContextMenu.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionContextMenu.tsx @@ -3,6 +3,20 @@ import type { TerminalSessionSummary } from "../../../shared/types"; import { useClampedFixedPosition } from "../../hooks/useClampedFixedPosition"; import { isChatToolType } from "../../lib/sessions"; import { sessionCanonicalUiState } from "../../lib/terminalAttention"; +import { + isSessionSnoozed, + snoozeWakeLabel, + SNOOZE_DURATION_OPTIONS, + type SnoozeDurationKey, +} from "../../lib/sessionSnooze"; +import { + setSessionSettleOverride, + snoozeSessionForDuration, + wakeSessionNow, +} from "./sessionLifecycleActions"; + +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"; export type SessionContextMenuState = { session: TerminalSessionSummary; @@ -56,6 +70,7 @@ export function SessionContextMenu({ }: SessionContextMenuProps) { const [renaming, setRenaming] = useState(false); const [tagging, setTagging] = useState(false); + const [snoozing, setSnoozing] = useState(false); const [draft, setDraft] = useState(""); const inputRef = useRef(null); const finalizedRef = useRef(false); @@ -68,6 +83,7 @@ export function SessionContextMenu({ useEffect(() => { setRenaming(false); setTagging(false); + setSnoozing(false); setDraft(""); finalizedRef.current = false; }, [menu]); @@ -94,6 +110,22 @@ export function SessionContextMenu({ canonicalPhase !== "needs_you" || isChat || Boolean(session.attentionRequestedAt); + // Snooze is a visibility overlay, so it is read from the shared snooze + // derivation and never inferred from the canonical phase. + const isSnoozed = isSessionSnoozed(session); + const snoozeWake = isSnoozed ? snoozeWakeLabel(session.snoozedUntil) : null; + const isSettled = canonicalPhase === "settled"; + /** + * A DERIVED settle — a clean exit-0 (or a `settleOverride: "settled"`) with no + * `settledAt` — used to fall out of every branch here and end up with no + * lifecycle action at all. The keep-active override is the unsettle for those + * rows: it outranks the derived rule so the row leaves the quiet tier. + */ + const isDeclaredSettled = Boolean(session.settledAt); + const chooseSnooze = (key: SnoozeDurationKey) => { + void snoozeSessionForDuration(session, key); + onClose(); + }; const commitRename = () => { if (finalizedRef.current) return; @@ -207,16 +239,73 @@ export function SessionContextMenu({ ) : null} - {session.settledAt && onUnsettle ? ( + {/* 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 ? ( - ) : canonicalPhase !== "settled" && !isActivelyRunning && onSettle && canDismissNeedsYou ? ( + ) : snoozing ? ( + SNOOZE_DURATION_OPTIONS.map((option) => ( + + )) + ) : ( + )} + + {isSettled ? ( + <> + + {isDeclaredSettled ? ( + + ) : null} + + ) : !isActivelyRunning && onSettle && canDismissNeedsYou ? ( + + {!collapsed ?
{renderCards(list)}
: null} +
+ ); + }; /** - * Lane folder body: active rows first, then a quiet collapsible settled tail - * (`settled:` section) so finished work stays openable in-stream - * without occupying the folder's prime rows. + * 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. */ const renderLaneSessionLists = (laneKey: string, list: TerminalSessionSummary[]) => { - const active = list.filter((session) => !settledIdSet.has(session.id)); + 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)); - // Settled tails start collapsed without needing to persist one entry per - // lane. Presence of the open marker means the user explicitly expanded it. - const settledOpenMarker = `settled-open:${laneKey}`; - const settledCollapsed = !workCollapsedSectionIds.includes(settledOpenMarker); return ( <> {renderCards(active)} - {settled.length > 0 ? ( -
- - {!settledCollapsed ?
{renderCards(settled)}
: null} -
- ) : null} + {renderLaneQuietTail(`snoozed-open:${laneKey}`, snoozedSectionIcon, "snoozed", snoozed)} + {renderLaneQuietTail(`settled-open:${laneKey}`, settledSectionIcon, "settled", settled)} ); }; + // Snoozed sits directly ABOVE Settled: hidden-for-now ranks above done. + const snoozedStatusSection = visibleSnoozed.length > 0 ? ( + toggleWorkSectionCollapsed("status:snoozed")} + > + {renderCards(visibleSnoozed)} + + ) : null; + const settledStatusSection = visibleSettled.length > 0 ? ( {renderCards(endedFiltered)} + {snoozedStatusSection} {settledStatusSection} ); @@ -1079,6 +1150,7 @@ export const SessionListPane = React.memo(function SessionListPane({ {renderHandoffCards(handoffTimeBuckets.older)} {renderCards(timeBuckets.older)} + {snoozedStatusSection} {settledStatusSection} ); diff --git a/apps/desktop/src/renderer/components/terminals/SessionSnoozeControl.tsx b/apps/desktop/src/renderer/components/terminals/SessionSnoozeControl.tsx new file mode 100644 index 000000000..f9495dd1e --- /dev/null +++ b/apps/desktop/src/renderer/components/terminals/SessionSnoozeControl.tsx @@ -0,0 +1,129 @@ +import React, { useCallback, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { Moon } from "@phosphor-icons/react"; +import type { TerminalSessionSummary } from "../../../shared/types"; +import { useClampedFixedPosition } from "../../hooks/useClampedFixedPosition"; +import { SNOOZE_DURATION_OPTIONS, type SnoozeDurationKey } 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. + * + * 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. + */ +export function SessionSnoozeControl({ + session, + snoozed, + compact = false, +}: { + session: Pick; + /** Already snoozed rows offer "Wake now" instead of a duration menu. */ + snoozed: boolean; + compact?: boolean; +}) { + const buttonRef = useRef(null); + const [anchor, setAnchor] = useState<{ x: number; y: number } | null>(null); + const { ref: menuRef, position } = useClampedFixedPosition(anchor); + + const close = useCallback(() => setAnchor(null), []); + + const openMenu = useCallback((event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + const rect = buttonRef.current?.getBoundingClientRect(); + setAnchor(rect ? { x: rect.left, y: rect.bottom + 4 } : { x: event.clientX, y: event.clientY }); + }, []); + + const choose = useCallback( + (key: SnoozeDurationKey) => { + close(); + void snoozeSessionForDuration(session, key); + }, + [close, session], + ); + + const label = snoozed ? "Wake session now" : "Snooze session"; + + return ( + <> + + + {/* 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. */} + {anchor && typeof document !== "undefined" ? createPortal( + <> +
{ + event.stopPropagation(); + close(); + }} + onContextMenu={(event) => { + event.preventDefault(); + event.stopPropagation(); + close(); + }} + /> +
event.stopPropagation()} + onPointerDown={(event) => event.stopPropagation()} + > + {SNOOZE_DURATION_OPTIONS.map((option) => ( + + ))} +
+ , + document.body, + ) : null} + + ); +} diff --git a/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx b/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx index 304c43f13..d9a6e4370 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx @@ -37,6 +37,7 @@ import { type HandoffLaunchJob, } from "../../lib/handoffLaunchJobs"; import { getLaneDeleteStatusLabel } from "../../lib/laneDeleteProgress"; +import { clearSessionWokeMarker } from "./sessionLifecycleActions"; import { useWorkLaneDeleteProgress } from "./useWorkLaneDeleteProgress"; import { buildPtyContinuationLaunchFields } from "./cliLaunch"; import { canonicalInputFromSummary, sessionNeedsYou } from "../../lib/terminalAttention"; @@ -232,6 +233,10 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { setSelectionAnchorId(id); work.setSelectedSessionId(id); work.openSessionTab(id); + // Opening the row IS the acknowledgement — the "woke" marker only exists + // to explain an unexpected return, so it goes as soon as it is seen. + const opened = selectableSessions.find((session) => session.id === id); + if (opened?.wokeAt) clearSessionWokeMarker(id); }, [selectableSessions, selectionAnchorId, work], ); @@ -1110,6 +1115,7 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { awaitingInputFiltered={work.awaitingInputFiltered} endedFiltered={work.endedFiltered} settledFiltered={work.settledFiltered} + snoozedFiltered={work.snoozedFiltered} allSessionsUnfiltered={work.sessions} loading={work.loading} filterLaneId={work.filterLaneId} diff --git a/apps/desktop/src/renderer/components/terminals/sessionLifecycleActions.ts b/apps/desktop/src/renderer/components/terminals/sessionLifecycleActions.ts new file mode 100644 index 000000000..745ae4856 --- /dev/null +++ b/apps/desktop/src/renderer/components/terminals/sessionLifecycleActions.ts @@ -0,0 +1,90 @@ +import type { SessionSettleOverride, TerminalSessionSummary } from "../../../shared/types"; +import { showToast } from "../app/toast/toastStore"; +import { + snoozeConfirmationLabel, + snoozeDeadlineIso, + type SnoozeDurationKey, +} from "../../lib/sessionSnooze"; + +/** + * One place for the Work tab's snooze/wake/keep-active writes, so the sidebar + * row menu, the row context menu, and the chat header chips can never disagree + * about what an action does (or about the copy it confirms with). + */ + +const UNDO_TOAST_MS = 5_000; + +function reportFailure(action: string, sessionId: string, error: unknown): void { + console.error(`[sessionLifecycle] ${action} failed`, { sessionId, error }); + showToast({ + title: `${action} failed`, + message: error instanceof Error ? error.message : String(error), + tone: "error", + }); +} + +/** + * Snooze one session and offer a 5s undo. The deadline is computed here (client + * side) and handed over as a concrete ISO instant — expiry is derived from it + * everywhere, so no scheduler is involved. + */ +export async function snoozeSessionForDuration( + session: Pick, + key: SnoozeDurationKey, + nowMs: number = Date.now(), +): Promise { + const untilIso = snoozeDeadlineIso(key, nowMs); + try { + await window.ade.sessions.snoozeSession(session.id, untilIso); + } catch (error) { + reportFailure("Snooze", session.id, error); + return; + } + showToast({ + id: `session-snooze:${session.id}`, + title: `Snoozed ${snoozeConfirmationLabel(key)}`, + durationMs: UNDO_TOAST_MS, + action: { + label: "Undo", + onClick: () => { + void window.ade.sessions + .wakeSession(session.id, "manual") + .catch((error: unknown) => reportFailure("Undo snooze", session.id, error)); + }, + }, + }); +} + +/** Wake a snoozed row right now (the user asked, so the reason is "manual"). */ +export async function wakeSessionNow(session: Pick): Promise { + try { + await window.ade.sessions.wakeSession(session.id, "manual"); + } catch (error) { + reportFailure("Wake", session.id, error); + } +} + +/** + * Pin a session's lifecycle. `"active"` is the keep-active pin that also + * unsettles a DERIVED settle (clean exit 0 with no `settledAt`), which is the + * only lifecycle action such a row has. + */ +export async function setSessionSettleOverride( + session: Pick, + override: SessionSettleOverride, +): Promise { + try { + await window.ade.sessions.setSettleOverride(session.id, override); + } catch (error) { + reportFailure(override === "active" ? "Keep active" : "Settle", session.id, error); + } +} + +/** Drop a row's "woke" marker once the user has actually looked at it. */ +export function clearSessionWokeMarker(sessionId: string): void { + void window.ade.sessions + ?.clearWokeMarker?.(sessionId) + .catch((error: unknown) => { + console.error("[sessionLifecycle] clearWokeMarker failed", { sessionId, error }); + }); +} diff --git a/apps/desktop/src/renderer/components/terminals/useWorkSessions.test.ts b/apps/desktop/src/renderer/components/terminals/useWorkSessions.test.ts index 86e45f979..4e8e5261a 100644 --- a/apps/desktop/src/renderer/components/terminals/useWorkSessions.test.ts +++ b/apps/desktop/src/renderer/components/terminals/useWorkSessions.test.ts @@ -1424,6 +1424,96 @@ describe("useWorkSessions — refresh-before-focus ordering", () => { expect(result.current.settledFiltered.map((s) => s.id)).toEqual(["session-ended"]); }); + it("partitions snoozed rows out of the flat sidebar buckets and back once the snooze lapses", async () => { + const nowMs = Date.now(); + const iso = (offsetMs: number) => new Date(nowMs + offsetMs).toISOString(); + // Snoozed while it is still running: snooze is a visibility overlay, so it + // outranks the Running bucket rather than deferring to it. + const snoozedRunning = makeSession("session-snoozed-running", "lane-1", { + snoozedUntil: iso(2 * 3600_000), + snoozedAt: iso(-60_000), + }); + const snoozedRunningSoon = makeSession("session-snoozed-soon", "lane-1", { + snoozedUntil: iso(30 * 60_000), + snoozedAt: iso(-60_000), + }); + // Expiry is DERIVED from `snoozedUntil` — a lapsed snooze is not snoozed. + const lapsedSnooze = makeSession("session-lapsed", "lane-1", { + snoozedUntil: iso(-60_000), + snoozedAt: iso(-3600_000), + }); + const plainRunning = makeSession("session-running", "lane-1"); + listSessionsCachedMock.mockResolvedValue([ + snoozedRunning, + snoozedRunningSoon, + lapsedSnooze, + plainRunning, + ]); + + const { result } = renderHook(() => useWorkSessions()); + + await waitFor(() => { + expect(result.current.filtered).toHaveLength(4); + }); + + // Soonest wake first. + expect(result.current.snoozedFiltered.map((s) => s.id)).toEqual([ + "session-snoozed-soon", + "session-snoozed-running", + ]); + expect(result.current.runningFiltered.map((s) => s.id)).toEqual([ + "session-lapsed", + "session-running", + ]); + expect(result.current.awaitingInputFiltered.map((s) => s.id)).toEqual([]); + expect(result.current.endedFiltered.map((s) => s.id)).toEqual([]); + expect(result.current.settledFiltered.map((s) => s.id)).toEqual([]); + }); + + // Regression: "Until I'm asked" snooze hid a needs-you row forever. All three + // early-wake triggers were chat-only, and a tracked CLI row's needs-input + // state is DERIVED (no event exists to hook), so a snoozed CLI session that + // hit a permission prompt could never come back. Snooze must yield to a + // raised hand at filing time. + it("does NOT file a snoozed needs-you row as snoozed in the flat sidebar buckets", async () => { + const nowMs = Date.now(); + const iso = (offsetMs: number) => new Date(nowMs + offsetMs).toISOString(); + // A tracked CLI row blocked at a prompt, snoozed "until I'm asked" (~100y). + const snoozedCliNeedsYou = makeSession("session-cli-needs-you", "lane-1", { + toolType: "claude" as const, + runtimeState: "waiting-input" as const, + snoozedUntil: iso(100 * 365 * 24 * 3600_000), + snoozedAt: iso(-60_000), + }); + // A chat row escalated via `ade chat ask` while snoozed. + const snoozedChatAsk = makeSession("session-chat-ask", "lane-1", { + toolType: "claude-chat" as const, + attentionRequestedAt: iso(-1_000), + snoozedUntil: iso(2 * 3600_000), + snoozedAt: iso(-60_000), + }); + const snoozedQuiet = makeSession("session-snoozed-quiet", "lane-1", { + snoozedUntil: iso(3600_000), + snoozedAt: iso(-60_000), + }); + listSessionsCachedMock.mockResolvedValue([snoozedCliNeedsYou, snoozedChatAsk, snoozedQuiet]); + + const { result } = renderHook(() => useWorkSessions()); + + await waitFor(() => { + expect(result.current.filtered).toHaveLength(3); + }); + + // Only the calm row is hidden; both hand-raises stay in "Your move", loud + // rows first, so the user can actually see and unblock them. + expect(result.current.snoozedFiltered.map((s) => s.id)).toEqual(["session-snoozed-quiet"]); + expect(result.current.awaitingInputFiltered.map((s) => s.id)).toEqual([ + "session-cli-needs-you", + "session-chat-ask", + ]); + expect(result.current.runningFiltered.map((s) => s.id)).toEqual([]); + }); + it("includes Claude session tags in the Work sidebar search", async () => { const taggedSession = makeSession("session-tagged", "lane-1", { title: "Unrelated title", @@ -2118,6 +2208,87 @@ describe("useWorkSessions — grouping defaults and derived tab order", () => { expect(byTime.sessionIds).toEqual(["session-a1", "session-a2", "session-b1", "session-c1"]); }); + it("pulls snoozed rows out of their status bucket into a Snoozed group above Settled", () => { + const nowMs = Date.parse("2026-04-01T12:00:00.000Z"); + const iso = (offsetMs: number) => new Date(nowMs + offsetMs).toISOString(); + + const sessions = [ + makeSession("session-running", "lane-a"), + // Snoozed but still RUNNING: snooze is a visibility overlay, so it must + // leave the Running group even though its phase never changed. + makeSession("session-snoozed-late", "lane-a", { snoozedUntil: iso(4 * 3600_000) }), + makeSession("session-snoozed-soon", "lane-a", { snoozedUntil: iso(3600_000) }), + // Snooze already lapsed — expiry is DERIVED, so this rejoins Running. + makeSession("session-woken", "lane-a", { snoozedUntil: iso(-3600_000) }), + makeSession("session-settled", "lane-a", { + status: "completed" as const, + runtimeState: "exited" as const, + exitCode: 0, + endedAt: iso(-60_000), + }), + ]; + const lanes = [ + { id: "lane-a", name: "Lane A", laneType: "worktree" as const, createdAt: iso(-86400000), color: null as string | null }, + ]; + + const model = buildWorkTabGroupModel({ + sessions, + lanes, + organization: "all-lanes-by-status", + collapsedGroupIds: [], + nowMs, + }); + + expect(model.groups.map((group) => group.id)).toEqual([ + "status:running", + "status:snoozed", + "status:settled", + ]); + expect(model.groups[0]!.sessionIds).toEqual(["session-running", "session-woken"]); + // Snoozed rows rank by when they come back, soonest first. + expect(model.groups[1]!.sessionIds).toEqual(["session-snoozed-soon", "session-snoozed-late"]); + expect(model.groups[1]!.label).toBe("Snoozed"); + expect(model.groups[2]!.sessionIds).toEqual(["session-settled"]); + }); + + // Regression: "Until I'm asked" snooze hid a needs-you row forever — the + // grouped status path lifted snoozed rows out of Your-move with no filing + // exception, and no early-wake event exists for a tracked CLI row (its + // needs-input state is derived). + it("does NOT file a snoozed needs-you row into the Snoozed group", () => { + const nowMs = Date.parse("2026-04-01T12:00:00.000Z"); + const iso = (offsetMs: number) => new Date(nowMs + offsetMs).toISOString(); + + const sessions = [ + // Tracked CLI row blocked at a prompt, snoozed "until I'm asked" (~100y). + makeSession("session-cli-needs-you", "lane-a", { + toolType: "claude" as const, + runtimeState: "waiting-input" as const, + snoozedUntil: iso(100 * 365 * 24 * 3600_000), + snoozedAt: iso(-60_000), + }), + makeSession("session-snoozed-quiet", "lane-a", { snoozedUntil: iso(3600_000) }), + ]; + const lanes = [ + { id: "lane-a", name: "Lane A", laneType: "worktree" as const, createdAt: iso(-86400000), color: null as string | null }, + ]; + + const model = buildWorkTabGroupModel({ + sessions, + lanes, + organization: "all-lanes-by-status", + collapsedGroupIds: [], + nowMs, + }); + + expect(model.groups.map((group) => group.id)).toEqual([ + "status:awaiting-input", + "status:snoozed", + ]); + expect(model.groups[0]!.sessionIds).toEqual(["session-cli-needs-you"]); + expect(model.groups[1]!.sessionIds).toEqual(["session-snoozed-quiet"]); + }); + it("reorders from the displayed pinned tab order", () => { expect(reorderLaneSessionIdsForDisplay({ baseOrder: ["unpinned-a", "pinned-b", "unpinned-c"], diff --git a/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts b/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts index e8d7da75f..73348e5dd 100644 --- a/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts +++ b/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts @@ -18,7 +18,9 @@ import { type WorkSessionListOrganization, } from "../../state/appStore"; import { listSessionsCached, invalidateSessionListCache } from "../../lib/sessionListCache"; -import { canonicalInputFromSummary, sessionStatusBucket, sessionNeedsYou } from "../../lib/terminalAttention"; +import { canonicalInputFromSummary, sessionCanonicalUiState } from "../../lib/terminalAttention"; +import { canonicalStatusBucket, type CanonicalStatusBucket } from "../../../shared/sessionCanonicalState"; +import { isSessionFiledAsSnoozed, nextSnoozeDeadlineMs } from "../../lib/sessionSnooze"; import { buildOptimisticChatSessionSummary } from "../../lib/sessions"; import { shouldRefreshSessionListForChatEvent, @@ -63,6 +65,8 @@ const DEFAULT_PROJECT_WORK_STATE: WorkProjectViewState = { }; const OPTIMISTIC_PTY_SESSION_TTL_MS = 2 * 60 * 1000; +/** Upper bound on the single snooze-expiry timer (setTimeout overflows past ~24.8 days). */ +const SNOOZE_TICK_MAX_DELAY_MS = 10 * 60 * 1000; const STOPPED_RUNTIME_GUARD_TTL_MS = 12_000; const EMPTY_STRING_ARRAY: string[] = []; const EMPTY_LANE_SESSION_ORDER: Record = {}; @@ -75,6 +79,38 @@ function compareSessionsByStartedAtDesc(left: TerminalSessionSummary, right: Ter return new Date(right.startedAt).getTime() - new Date(left.startedAt).getTime(); } +/** + * Settled rows are ranked by when they settled, not when they started. The list they are + * partitioned out of is ordered by startedAt, so without this a session you started + * yesterday and settle right now buries itself under sessions settled long before it. + * Falls back to last activity, then start, so rows missing settledAt still sort stably. + */ +function settledRank(session: TerminalSessionSummary): number { + for (const value of [session.settledAt, session.lastActivityAt, session.startedAt]) { + if (!value) continue; + const ms = new Date(value).getTime(); + if (Number.isFinite(ms)) return ms; + } + return 0; +} + +function compareSessionsBySettledAtDesc(left: TerminalSessionSummary, right: TerminalSessionSummary): number { + return settledRank(right) - settledRank(left); +} + +/** + * Snoozed rows rank by when they come BACK — the whole point of the group is + * "what returns first". Rows without a parseable deadline sink to the bottom. + */ +function snoozeWakeRank(session: TerminalSessionSummary): number { + const ms = session.snoozedUntil ? new Date(session.snoozedUntil).getTime() : Number.NaN; + return Number.isFinite(ms) ? ms : Number.MAX_SAFE_INTEGER; +} + +function compareSessionsByWakeAtAsc(left: TerminalSessionSummary, right: TerminalSessionSummary): number { + return snoozeWakeRank(left) - snoozeWakeRank(right); +} + function upsertSessionByStartedAt( sessions: readonly TerminalSessionSummary[], session: TerminalSessionSummary, @@ -136,9 +172,17 @@ function bucketByTime(session: TerminalSessionSummary): "today" | "yesterday" | return "older"; } -function getStatusBucketLabel(bucket: ReturnType): string { +/** + * Snoozed is a partition of the status grouping, not a canonical bucket — it is + * derived from the snooze columns and pulls the row OUT of whichever status + * bucket it would otherwise land in, unless the row is asking for you. + */ +type WorkStatusGroupBucket = CanonicalStatusBucket | "snoozed"; + +function getStatusBucketLabel(bucket: WorkStatusGroupBucket): string { if (bucket === "running") return "Running"; if (bucket === "awaiting-input") return "Your move"; + if (bucket === "snoozed") return "Snoozed"; if (bucket === "settled") return "Settled"; return "Ended"; } @@ -150,6 +194,8 @@ export function buildWorkTabGroupModel(args: { collapsedGroupIds: string[]; laneSessionOrder?: Record; pinnedSessionIds?: string[]; + /** Injectable clock so snooze expiry stays testable (expiry is derived, never scheduled). */ + nowMs?: number; }): WorkTabGroupModel { const orderedSessions = [...args.sessions].sort(compareSessionsByStartedAtDesc); const collapseSet = new Set(args.collapsedGroupIds); @@ -260,15 +306,29 @@ export function buildWorkTabGroupModel(args: { return { groups, sessionIds: visibleSessions.map((session) => session.id), visibleSessions }; } - const statusBuckets = new Map<"running" | "awaiting-input" | "ended" | "settled", TerminalSessionSummary[]>(); + const nowMs = args.nowMs ?? Date.now(); + const statusBuckets = new Map(); for (const session of orderedSessions) { - const bucket = sessionStatusBucket(canonicalInputFromSummary(session)); + // Snooze is a visibility overlay: it pulls the row out of its normal bucket + // entirely — the same partitioning the flat sidebar list uses — EXCEPT when + // the row's canonical phase is needs_you. The overlay yields to a raised + // hand (`isSessionFiledAsSnoozed`), which is the only thing that makes + // "Until I'm asked" true for tracked CLI rows: their needs-input state is + // derived, so no early-wake event ever fires for them. + const phase = sessionCanonicalUiState(canonicalInputFromSummary(session)).phase; + const bucket: WorkStatusGroupBucket = isSessionFiledAsSnoozed(session, phase, nowMs) + ? "snoozed" + : canonicalStatusBucket(phase); const list = statusBuckets.get(bucket) ?? []; list.push(session); statusBuckets.set(bucket, list); } + // Same rule as the flat list: the settled group ranks by settle time, not start + // time, and the snoozed group ranks by when each row wakes. + statusBuckets.get("settled")?.sort(compareSessionsBySettledAtDesc); + statusBuckets.get("snoozed")?.sort(compareSessionsByWakeAtAsc); - const statusOrder: Array<"running" | "awaiting-input" | "ended" | "settled"> = ["running", "awaiting-input", "ended", "settled"]; + const statusOrder: WorkStatusGroupBucket[] = ["running", "awaiting-input", "ended", "snoozed", "settled"]; const visibleSessions: TerminalSessionSummary[] = []; const groups = statusOrder .filter((bucket) => (statusBuckets.get(bucket)?.length ?? 0) > 0) @@ -392,6 +452,8 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) const [sessions, setSessions] = useState([]); const [loading, setLoading] = useState(false); + /** Bumped when the soonest snooze deadline lapses so the partition re-derives. */ + const [snoozeEpoch, setSnoozeEpoch] = useState(0); const [closingPtyIds, setClosingPtyIds] = useState>(new Set()); const sessionsRef = useRef([]); const refreshInFlightRef = useRef(false); @@ -503,7 +565,9 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) laneSessionOrder, pinnedSessionIds, }), - [lanes, openSessions, sessionListOrganization, workCollapsedTabGroupIds, laneSessionOrder, pinnedSessionIds], + // `snoozeEpoch` re-derives the by-status snoozed group when a deadline lapses. + // eslint-disable-next-line react-hooks/exhaustive-deps + [lanes, openSessions, sessionListOrganization, workCollapsedTabGroupIds, laneSessionOrder, pinnedSessionIds, snoozeEpoch], ); const visibleSessions = openSessions; @@ -1301,20 +1365,37 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) }); }, [sessions, filterLaneId, q]); - const { runningFiltered, awaitingInputFiltered, endedFiltered, settledFiltered } = useMemo(() => { + const { + runningFiltered, + awaitingInputFiltered, + endedFiltered, + settledFiltered, + snoozedFiltered, + } = useMemo(() => { + const nowMs = Date.now(); const running: TerminalSessionSummary[] = []; const loud: TerminalSessionSummary[] = []; const quiet: TerminalSessionSummary[] = []; const ended: TerminalSessionSummary[] = []; const settled: TerminalSessionSummary[] = []; + const snoozed: TerminalSessionSummary[] = []; for (const session of filtered) { - const attentionInput = canonicalInputFromSummary(session); - const bucket = sessionStatusBucket(attentionInput); + // Snooze is a visibility overlay: it pulls the row OUT of whatever bucket + // it would otherwise sit in, including Running — but it YIELDS to a raised + // hand. A needs_you row is filed normally even while snoozed, which is + // what keeps "Until I'm asked" honest for tracked CLI rows (their + // needs-input state is derived, so no early-wake event can fire). + const phase = sessionCanonicalUiState(canonicalInputFromSummary(session)).phase; + if (isSessionFiledAsSnoozed(session, phase, nowMs)) { + snoozed.push(session); + continue; + } + const bucket = canonicalStatusBucket(phase); if (bucket === "running") running.push(session); else if (bucket === "awaiting-input") { // Loud (Needs you) rows float to the top of the Your-move section; the // two partitions each keep startedAt order, so rows never jitter. - if (sessionNeedsYou(attentionInput)) loud.push(session); + if (phase === "needs_you") loud.push(session); else quiet.push(session); } else if (bucket === "settled") settled.push(session); else ended.push(session); @@ -1323,9 +1404,25 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) runningFiltered: running, awaitingInputFiltered: [...loud, ...quiet], endedFiltered: ended, - settledFiltered: settled, + settledFiltered: settled.sort(compareSessionsBySettledAtDesc), + snoozedFiltered: snoozed.sort(compareSessionsByWakeAtAsc), }; - }, [filtered]); + // `snoozeEpoch` re-partitions when the soonest snooze deadline lapses; there + // is no snooze scheduler anywhere, expiry is always derived from now. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filtered, snoozeEpoch]); + + // Exactly one timer, armed only while something is actually snoozed, firing at + // the soonest deadline (clamped so a 100-year "until I'm asked" snooze can't + // overflow setTimeout). No polling and no document-level listener. + useEffect(() => { + if (!isWorkRoute) return undefined; + const deadlineMs = nextSnoozeDeadlineMs(filtered); + if (deadlineMs == null) return undefined; + const delay = Math.min(Math.max(deadlineMs - Date.now(), 250), SNOOZE_TICK_MAX_DELAY_MS); + const timer = window.setTimeout(() => setSnoozeEpoch((value) => value + 1), delay); + return () => window.clearTimeout(timer); + }, [filtered, isWorkRoute, snoozeEpoch]); const sessionsGroupedByLane = useMemo(() => { if (sessionListOrganization !== "by-lane") return null; @@ -1695,6 +1792,7 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) awaitingInputFiltered, endedFiltered, settledFiltered, + snoozedFiltered, runningSessions, visibleSessions, gridLayoutId, diff --git a/apps/desktop/src/renderer/components/work/SessionLifecycleChips.test.tsx b/apps/desktop/src/renderer/components/work/SessionLifecycleChips.test.tsx new file mode 100644 index 000000000..7fc3b6809 --- /dev/null +++ b/apps/desktop/src/renderer/components/work/SessionLifecycleChips.test.tsx @@ -0,0 +1,119 @@ +/* @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 { SessionLifecycleChips } from "./SessionLifecycleChips"; + +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, + }; +} + +function seedSessions(sessions: TerminalSessionSummary[]): void { + useAppStore.setState({ + project: { rootPath: PROJECT_ROOT } as never, + projectBinding: null, + sessionsCacheByProject: { [PROJECT_ROOT]: sessions }, + }); +} + +describe("SessionLifecycleChips", () => { + let sessionsApi: Record>; + + beforeEach(() => { + sessionsApi = { + wakeSession: vi.fn().mockResolvedValue(true), + unsettle: vi.fn().mockResolvedValue(undefined), + setSettleOverride: vi.fn().mockResolvedValue(true), + }; + Object.defineProperty(window, "ade", { + configurable: true, + value: { sessions: sessionsApi }, + }); + }); + + afterEach(() => { + cleanup(); + useAppStore.setState({ sessionsCacheByProject: {} }); + Reflect.deleteProperty(window, "ade"); + vi.clearAllMocks(); + }); + + it("renders nothing for a live chat", () => { + seedSessions([makeSession()]); + const { container } = render(); + expect(container.textContent).toBe(""); + }); + + it("shows a snoozed chip that offers Wake now", async () => { + seedSessions([makeSession({ + snoozedUntil: new Date(Date.now() + 3_600_000).toISOString(), + snoozedAt: new Date(Date.now() - 60_000).toISOString(), + })]); + render(); + + fireEvent.click(screen.getByTestId("chat-session-snoozed-chip")); + fireEvent.click(screen.getByRole("menuitem", { name: "Wake now" })); + + await waitFor(() => expect(sessionsApi.wakeSession).toHaveBeenCalledWith("session-1", "manual")); + }); + + it("shows a settled chip and unsettles a DERIVED settle through the keep-active override", async () => { + seedSessions([makeSession({ + toolType: "shell", + status: "completed", + runtimeState: "exited", + endedAt: "2026-07-09T11:00:00.000Z", + exitCode: 0, + settledAt: null, + })]); + render(); + + fireEvent.click(screen.getByTestId("chat-session-settled-chip")); + fireEvent.click(screen.getByRole("menuitem", { name: "Unsettle" })); + + await waitFor(() => expect(sessionsApi.setSettleOverride).toHaveBeenCalledWith("session-1", "active")); + expect(sessionsApi.unsettle).not.toHaveBeenCalled(); + }); + + it("clears a declared settle through the settle column", async () => { + seedSessions([makeSession({ + status: "completed", + runtimeState: "exited", + endedAt: "2026-07-09T11:00:00.000Z", + settledAt: "2026-07-09T11:01:00.000Z", + })]); + render(); + + fireEvent.click(screen.getByTestId("chat-session-settled-chip")); + fireEvent.click(screen.getByRole("menuitem", { name: "Unsettle" })); + + await waitFor(() => expect(sessionsApi.unsettle).toHaveBeenCalledWith("session-1")); + expect(sessionsApi.setSettleOverride).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/renderer/components/work/SessionLifecycleChips.tsx b/apps/desktop/src/renderer/components/work/SessionLifecycleChips.tsx new file mode 100644 index 000000000..e5aac6c8a --- /dev/null +++ b/apps/desktop/src/renderer/components/work/SessionLifecycleChips.tsx @@ -0,0 +1,174 @@ +import { useMemo, useState } from "react"; +import { Moon } from "@phosphor-icons/react"; + +import type { TerminalSessionSummary } from "../../../shared/types"; +import { selectActiveProjectStateKey, useAppStore } from "../../state/appStore"; +import { canonicalInputFromSummary, sessionCanonicalUiState } from "../../lib/terminalAttention"; +import { isSessionSnoozed, snoozeWakeLabel } from "../../lib/sessionSnooze"; +import { + setSessionSettleOverride, + wakeSessionNow, +} from "../terminals/sessionLifecycleActions"; +import { cn } from "../ui/cn"; + +/** + * Ambient lifecycle chips for a chat surface header. The chat pane had zero + * lifecycle awareness: a settled or snoozed chat looked identical to a live one + * once you were inside it. + * + * These are HEADER chips, not a strip above the composer — that slot belongs to + * lane branch drift. State is resolved from the same derived helpers the Work + * sidebar uses (`sessionCanonicalUiState` + `isSessionSnoozed`), so the chip and + * the row can never disagree. + */ + +const CHIP_CLASS = + "inline-flex h-5 shrink-0 items-center gap-1 rounded-md border border-white/[0.10] bg-white/[0.04] px-1.5 font-sans text-[10px] font-medium text-muted-fg/75 transition-colors hover:border-white/[0.18] hover:text-fg/85"; + +/** + * Read a chat's terminal-session row out of the per-project cache the Work tab + * already mirrors into the store. No extra IPC, and it stays as fresh as the + * sidebar it is mirroring. + */ +export function useSessionLifecycleSnapshot( + sessionId: string | null | undefined, +): TerminalSessionSummary | null { + const projectStateKey = useAppStore(selectActiveProjectStateKey); + const cached = useAppStore((state) => + (projectStateKey ? state.sessionsCacheByProject[projectStateKey] : undefined), + ); + return useMemo(() => { + const id = sessionId?.trim(); + if (!id || !cached) return null; + return cached.find((session) => session.id === id) ?? null; + }, [cached, sessionId]); +} + +function ChipMenu({ + label, + items, + onClose, +}: { + label: string; + items: Array<{ key: string; label: string; onSelect: () => void }>; + onClose: () => void; +}) { + return ( + <> +
+
+ {items.map((item) => ( + + ))} +
+ + ); +} + +export function SessionLifecycleChips({ + sessionId, + className, +}: { + sessionId: string | null | undefined; + className?: string; +}) { + const session = useSessionLifecycleSnapshot(sessionId); + const [openChip, setOpenChip] = useState<"snoozed" | "settled" | null>(null); + + if (!session) return null; + + const snoozed = isSessionSnoozed(session); + const settled = sessionCanonicalUiState(canonicalInputFromSummary(session)).phase === "settled"; + if (!snoozed && !settled) return null; + + const wakeLabel = snoozeWakeLabel(session.snoozedUntil); + + return ( + <> + {snoozed ? ( + + + {openChip === "snoozed" ? ( + setOpenChip(null)} + items={[ + { key: "wake", label: "Wake now", onSelect: () => { void wakeSessionNow(session); } }, + ]} + /> + ) : null} + + ) : null} + + {settled ? ( + + + {openChip === "settled" ? ( + setOpenChip(null)} + items={[ + { + key: "unsettle", + label: "Unsettle", + onSelect: () => { + // Declared settles clear the column; a derived settle (clean + // exit 0, no settledAt) only lifts via the keep-active pin. + if (session.settledAt) { + void window.ade.sessions.unsettle(session.id).catch(() => {}); + return; + } + void setSessionSettleOverride(session, "active"); + }, + }, + ]} + /> + ) : null} + + ) : null} + + ); +} diff --git a/apps/desktop/src/renderer/components/work/WorkSurfaceHeader.tsx b/apps/desktop/src/renderer/components/work/WorkSurfaceHeader.tsx index b6dc801f2..348780f33 100644 --- a/apps/desktop/src/renderer/components/work/WorkSurfaceHeader.tsx +++ b/apps/desktop/src/renderer/components/work/WorkSurfaceHeader.tsx @@ -1,7 +1,9 @@ import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent, type ReactNode } from "react"; import { SidebarSimple } from "@phosphor-icons/react"; import { ChatGitToolbar } from "../chat/ChatGitToolbar"; +import { LaneBranchDriftChip } from "../lanes/LaneBranchDrift"; import { LaneChip } from "../terminals/LaneChip"; +import { SessionLifecycleChips } from "./SessionLifecycleChips"; import { ClaudeCacheTtlBadge } from "../shared/ClaudeCacheTtlBadge"; import { useFloatingPaneEmbeddedChrome } from "../ui/FloatingPane"; import { cn } from "../ui/cn"; @@ -181,6 +183,12 @@ export type WorkSurfaceHeaderProps = { */ showCacheBadge?: boolean; cacheIdleSinceAt?: string | null; + /** + * Session id whose lifecycle (settled / snoozed) should surface as ambient + * header chips. Chips render only when the session is actually in one of those + * states; the composer slot below is owned by lane branch drift. + */ + lifecycleSessionId?: string | null; /** When true and laneId is set, renders the ChatGitToolbar. */ showGitToolbar?: boolean; /** @@ -229,6 +237,7 @@ export function WorkSurfaceHeader({ onLaneChipClick, showCacheBadge = false, cacheIdleSinceAt, + lifecycleSessionId = null, showGitToolbar = false, onTogglePrPane, prPaneOpen, @@ -271,6 +280,8 @@ export function WorkSurfaceHeader({ aria-label={onLaneChipClick ? `Open ${laneChipName} in Lanes tab` : undefined} /> ) : null} + {laneId ? : null} + {lifecycleSessionId ? : null} {showCacheBadge ? ( ) : null} diff --git a/apps/desktop/src/renderer/index.css b/apps/desktop/src/renderer/index.css index eab715221..5dea24aa2 100644 --- a/apps/desktop/src/renderer/index.css +++ b/apps/desktop/src/renderer/index.css @@ -2554,6 +2554,26 @@ button:active, [role="button"]:active { 0 18px 44px -30px rgba(0, 0, 0, 0.72); } +/* Rich composer chips are contentEditable="false", so browsers skip them when + painting the native selection and a drag across a chip looks like it breaks + in half. AgentChatComposer marks the chips the selection intersects with + `data-composer-chip-selected`; overlay those in the platform selection color + (kept translucent so the chip's own label stays readable). */ +[data-composer-chip] { + position: relative; +} + +[data-composer-chip][data-composer-chip-selected]::after { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + pointer-events: none; + background-color: -webkit-focus-ring-color; + background-color: Highlight; + opacity: 0.35; +} + .ade-liquid-glass-menu { border-radius: 16px; border: 1px solid var(--work-popover-border, var(--chat-panel-border)); diff --git a/apps/desktop/src/renderer/lib/sessionSnooze.test.ts b/apps/desktop/src/renderer/lib/sessionSnooze.test.ts new file mode 100644 index 000000000..d6a8e605b --- /dev/null +++ b/apps/desktop/src/renderer/lib/sessionSnooze.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vitest"; +import { + nextSnoozeDeadlineMs, + sessionWokeMarker, + snoozeDeadlineIso, + snoozeWakeLabel, + wakeReasonLabel, +} from "./sessionSnooze"; + +/** Local-time anchor so the evening/morning 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(); +} + +describe("snoozeDeadlineIso", () => { + it("adds exactly an hour for the 1-hour preset", () => { + const now = localMs(2026, 7, 26, 9, 15); + expect(Date.parse(snoozeDeadlineIso("hour", now))).toBe(now + 3_600_000); + }); + + it("targets 6pm today when the evening has not happened yet", () => { + const now = localMs(2026, 7, 26, 9, 15); + expect(Date.parse(snoozeDeadlineIso("evening", now))).toBe(localMs(2026, 7, 26, 18)); + }); + + it("rolls to the next evening once 6pm has passed", () => { + const now = localMs(2026, 7, 26, 20, 0); + expect(Date.parse(snoozeDeadlineIso("evening", now))).toBe(localMs(2026, 7, 27, 18)); + }); + + it("targets 9am the following day for the tomorrow preset, even late at night", () => { + expect(Date.parse(snoozeDeadlineIso("tomorrow", localMs(2026, 7, 26, 23, 30)))) + .toBe(localMs(2026, 7, 27, 9)); + expect(Date.parse(snoozeDeadlineIso("tomorrow", localMs(2026, 7, 26, 1, 0)))) + .toBe(localMs(2026, 7, 27, 9)); + }); + + it("parks 'until I'm asked' far enough out that only a hand-raise brings it back", () => { + const now = localMs(2026, 7, 26, 9, 15); + const until = Date.parse(snoozeDeadlineIso("asked", now)); + expect(until - now).toBeGreaterThan(365 * 24 * 3_600_000); + expect(snoozeWakeLabel(new Date(until).toISOString(), now)).toBe("wakes when asked"); + }); +}); + +describe("snoozeWakeLabel", () => { + const now = localMs(2026, 7, 26, 9, 0); + + it("counts down in minutes under an hour", () => { + expect(snoozeWakeLabel(new Date(now + 25 * 60_000).toISOString(), now)).toBe("wakes in 25m"); + }); + + it("counts down in hours within the same day", () => { + expect(snoozeWakeLabel(new Date(now + 3 * 3_600_000).toISOString(), now)).toBe("wakes in 3h"); + }); + + it("says tomorrow for a next-day wake that is more than half a day out", () => { + expect(snoozeWakeLabel(new Date(localMs(2026, 7, 27, 9)).toISOString(), now)).toBe("wakes tomorrow"); + }); + + it("keeps an hour countdown for a next-day wake that is only hours away", () => { + const lateNight = localMs(2026, 7, 26, 23, 0); + expect(snoozeWakeLabel(new Date(localMs(2026, 7, 27, 2)).toISOString(), lateNight)).toBe("wakes in 3h"); + }); + + it("returns null without a deadline and 'wakes now' once it has lapsed", () => { + expect(snoozeWakeLabel(null, now)).toBeNull(); + expect(snoozeWakeLabel(new Date(now - 1).toISOString(), now)).toBe("wakes now"); + }); +}); + +describe("wakeReasonLabel", () => { + it("uses specific operational copy per reason", () => { + expect(wakeReasonLabel("needs_you")).toBe("needs approval"); + expect(wakeReasonLabel("error")).toBe("errored"); + expect(wakeReasonLabel("turn_complete")).toBe("turn finished"); + expect(wakeReasonLabel("timer")).toBe("snooze ended"); + expect(wakeReasonLabel(null)).toBeNull(); + }); +}); + +describe("sessionWokeMarker", () => { + const now = localMs(2026, 7, 26, 9, 0); + + it("prefers the persisted woke reason", () => { + expect(sessionWokeMarker({ + snoozedUntil: null, + snoozedAt: null, + wokeAt: new Date(now - 60_000).toISOString(), + wokeReason: "needs_you", + }, now)).toEqual({ reason: "needs_you", label: "needs approval" }); + }); + + it("falls back to a derived timer wake when the snooze merely lapsed", () => { + expect(sessionWokeMarker({ + snoozedUntil: new Date(now - 60_000).toISOString(), + snoozedAt: new Date(now - 3_600_000).toISOString(), + wokeAt: null, + wokeReason: null, + }, now)).toEqual({ reason: "timer", label: "snooze ended" }); + }); + + it("shows nothing for a row that is still snoozed", () => { + expect(sessionWokeMarker({ + snoozedUntil: new Date(now + 3_600_000).toISOString(), + snoozedAt: new Date(now - 60_000).toISOString(), + wokeAt: null, + wokeReason: null, + }, now)).toBeNull(); + }); +}); + +describe("nextSnoozeDeadlineMs", () => { + const now = localMs(2026, 7, 26, 9, 0); + + it("returns the soonest future deadline so callers arm exactly one timer", () => { + expect(nextSnoozeDeadlineMs([ + { snoozedUntil: new Date(now + 7_200_000).toISOString() }, + { snoozedUntil: new Date(now + 600_000).toISOString() }, + { snoozedUntil: new Date(now - 600_000).toISOString() }, + { snoozedUntil: null }, + ], now)).toBe(now + 600_000); + }); + + it("returns null when nothing is currently snoozed", () => { + expect(nextSnoozeDeadlineMs([{ snoozedUntil: null }], now)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/renderer/lib/sessionSnooze.ts b/apps/desktop/src/renderer/lib/sessionSnooze.ts new file mode 100644 index 000000000..a35995ed6 --- /dev/null +++ b/apps/desktop/src/renderer/lib/sessionSnooze.ts @@ -0,0 +1,208 @@ +import type { SessionWakeReason, TerminalSessionSummary } from "../../shared/types"; +import { + isSessionFiledAsSnoozed, + isSessionSnoozeExpired, + isSessionSnoozed, + resolveSessionWakeReason, +} from "../../shared/sessionCanonicalState"; + +/** + * Renderer-side snooze presentation. The derivations themselves + * (`isSessionSnoozed` / `isSessionSnoozeExpired` / `resolveSessionWakeReason`) + * live in `shared/sessionCanonicalState` and are shared with the CLI and iOS — + * this module only owns the desktop copy and the client-side deadline math + * behind the duration menu. + * + * Snooze is a VISIBILITY OVERLAY, never a lifecycle phase: nothing here reads + * or writes a canonical phase, so the sidebar files a snoozed row without + * changing what the row's status dot says. + */ + +export type SnoozeDurationKey = "hour" | "evening" | "tomorrow" | "asked"; + +export type SnoozeDurationOption = { + key: SnoozeDurationKey; + label: string; +}; + +/** Menu order is fixed: shortest window first, open-ended last. */ +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: "asked", label: "Until I'm asked" }, +]; + +const HOUR_MS = 60 * 60 * 1000; +const DAY_MS = 24 * HOUR_MS; +/** "Until I'm asked" has no clock deadline, so it parks the row far enough out + * that only a hand-raise (needs-you / error / turn complete) brings it back. */ +const INDEFINITE_MS = 100 * 365 * DAY_MS; +/** Any deadline beyond this reads as open-ended rather than a countdown. */ +const INDEFINITE_LABEL_THRESHOLD_MS = 365 * DAY_MS; + +const EVENING_HOUR = 18; +const MORNING_HOUR = 9; + +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; +} + +/** + * Concrete ISO deadline for a menu choice, computed client-side (there is no + * scheduler anywhere — every surface derives expiry by comparing to now). + */ +export function snoozeDeadlineIso(key: SnoozeDurationKey, nowMs: number = Date.now()): string { + const now = new Date(nowMs); + switch (key) { + case "hour": + 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. + if (evening.getTime() <= nowMs) return atLocalHour(now, EVENING_HOUR, 1).toISOString(); + return evening.toISOString(); + } + case "tomorrow": + return atLocalHour(now, MORNING_HOUR, 1).toISOString(); + case "asked": + default: + return new Date(nowMs + INDEFINITE_MS).toISOString(); + } +} + +/** Short confirmation fragment used by the undo toast ("Snoozed until 9am"). */ +export function snoozeConfirmationLabel(key: SnoozeDurationKey): string { + switch (key) { + case "hour": + return "for 1 hour"; + case "evening": + return "until this evening"; + case "tomorrow": + return "until 9am"; + case "asked": + default: + return "until you're asked"; + } +} + +function parseIsoMs(value: string | null | undefined): number | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + if (!trimmed) return null; + const ms = Date.parse(trimmed); + return Number.isFinite(ms) ? ms : null; +} + +function calendarDayDelta(fromMs: number, toMs: number): number { + const from = new Date(fromMs); + const to = new Date(toMs); + const fromMidnight = new Date(from.getFullYear(), from.getMonth(), from.getDate()).getTime(); + const toMidnight = new Date(to.getFullYear(), to.getMonth(), to.getDate()).getTime(); + return Math.round((toMidnight - fromMidnight) / DAY_MS); +} + +/** + * The per-row wake line in the Snoozed group: "wakes in 3h", "wakes tomorrow", + * "wakes when asked". Returns null when the row has no usable deadline. + */ +export function snoozeWakeLabel( + 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 "wakes now"; + if (remaining >= INDEFINITE_LABEL_THRESHOLD_MS) return "wakes when asked"; + if (remaining < 60_000) return "wakes in 1m"; + if (remaining < HOUR_MS) return `wakes in ${Math.round(remaining / 60_000)}m`; + + const dayDelta = calendarDayDelta(nowMs, untilMs); + if (dayDelta === 0) return `wakes in ${Math.round(remaining / HOUR_MS)}h`; + if (dayDelta === 1) return remaining < 12 * HOUR_MS ? `wakes in ${Math.round(remaining / HOUR_MS)}h` : "wakes tomorrow"; + return `wakes in ${Math.max(1, dayDelta)}d`; +} + +/** + * Specific, operational copy for why a snoozed row came back. Deliberately not + * "woke up" — the user needs to know what changed. + */ +export function wakeReasonLabel(reason: SessionWakeReason | null | undefined): string | null { + switch (reason) { + case "needs_you": + return "needs approval"; + case "error": + return "errored"; + case "turn_complete": + return "turn finished"; + case "timer": + return "snooze ended"; + case "manual": + return "woken by you"; + default: + return null; + } +} + +export type SessionWokeMarker = { + reason: SessionWakeReason; + label: string; +}; + +/** + * The "woke" marker a row carries until it is opened. Prefers the persisted + * `wokeReason`; a row whose snooze merely lapsed (expiry is derived, so the + * backend never wrote a marker) falls back to the shared resolver so timer + * wakes still explain themselves. + */ +export function sessionWokeMarker( + session: Pick< + TerminalSessionSummary, + "snoozedUntil" | "snoozedAt" | "wokeAt" | "wokeReason" | "pendingInputItemId" | "lastTurnFailedAt" + >, + nowMs: number = Date.now(), +): SessionWokeMarker | null { + if (session.wokeAt) { + const reason = session.wokeReason ?? "timer"; + const label = wakeReasonLabel(reason); + return label ? { reason, label } : null; + } + if (!isSessionSnoozeExpired(session, nowMs)) return null; + const reason = resolveSessionWakeReason( + session, + { + hasPendingInput: Boolean(session.pendingInputItemId), + errorAt: session.lastTurnFailedAt ?? null, + }, + nowMs, + ); + const label = wakeReasonLabel(reason); + return reason && label ? { reason, label } : null; +} + +/** + * Soonest future snooze deadline across a list, so a caller can arm exactly one + * timer instead of polling. Null when nothing is currently snoozed. + */ +export function nextSnoozeDeadlineMs( + sessions: readonly Pick[], + nowMs: number = Date.now(), +): number | null { + let soonest: number | null = null; + for (const session of sessions) { + const untilMs = parseIsoMs(session.snoozedUntil); + if (untilMs == null || untilMs <= nowMs) continue; + if (soonest == null || untilMs < soonest) soonest = untilMs; + } + return soonest; +} + +/** + * Re-exported so Work-tab call sites never hand-roll a phase check for snooze. + * `isSessionSnoozed` is the raw column read (chips, menus, wake labels); + * `isSessionFiledAsSnoozed` is the FILING rule every Snoozed group must use, so + * a row whose hand is raised is never hidden by the overlay. + */ +export { isSessionFiledAsSnoozed, isSessionSnoozed, isSessionSnoozeExpired }; diff --git a/apps/desktop/src/renderer/lib/terminalAttention.ts b/apps/desktop/src/renderer/lib/terminalAttention.ts index 3d4b85266..f8444a591 100644 --- a/apps/desktop/src/renderer/lib/terminalAttention.ts +++ b/apps/desktop/src/renderer/lib/terminalAttention.ts @@ -1,4 +1,4 @@ -import type { TerminalRuntimeState, TerminalSessionStatus, TerminalSessionSummary, TerminalToolType } from "../../shared/types"; +import type { SessionSettleOverride, TerminalRuntimeState, TerminalSessionStatus, TerminalSessionSummary, TerminalToolType } from "../../shared/types"; import { canonicalSessionState, canonicalStatusBucket, @@ -108,6 +108,7 @@ type SessionCanonicalUiInput = { lastActivityAt?: string | null; exitCode?: number | null; settledAt?: string | null; + settleOverride?: SessionSettleOverride | null; attentionRequestedAt?: string | null; lastTurnFailedAt?: string | null; nowMs?: number; @@ -128,6 +129,7 @@ export function canonicalInputFromSummary(session: TerminalSessionSummary): Sess lastActivityAt: session.lastActivityAt, exitCode: session.exitCode, settledAt: session.settledAt, + settleOverride: session.settleOverride, attentionRequestedAt: session.attentionRequestedAt, lastTurnFailedAt: session.lastTurnFailedAt, }; @@ -143,6 +145,7 @@ export function sessionCanonicalUiState(session: SessionCanonicalUiInput): Canon lastActivityAt: session.lastActivityAt ?? null, exitCode: session.exitCode ?? null, settledAt: session.settledAt ?? null, + settleOverride: session.settleOverride ?? null, attentionRequestedAt: session.attentionRequestedAt ?? null, lastTurnFailedAt: session.lastTurnFailedAt ?? null, nowMs: session.nowMs, diff --git a/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts b/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts index 58418ec2f..8f14f828c 100644 --- a/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts +++ b/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts @@ -10,7 +10,12 @@ import type { SyncTerminalDataPayload, SyncTerminalSnapshotPayload, } from "../../../../shared/types/sync"; +import { isSessionSnoozed } from "../../../../shared/sessionCanonicalState"; import { createAdeWebAdapter } from "../index"; +import { + SESSION_LIFECYCLE_DISCONNECTED_MESSAGE, + SESSION_LIFECYCLE_UNSUPPORTED_MESSAGE, +} from "../sessionLifecycleSupport"; import type { AdeSyncClient, ChatHandlers, TerminalHandlers } from "../../sync"; import type { BrowserAccountClient, BrowserAccountSnapshot } from "../../account/client"; import { stableCacheKey } from "../infra/cacheKey"; @@ -1636,8 +1641,153 @@ describe("createAdeWebAdapter", () => { unsubscribe(); adapter.dispose(); }); + + // --- Session lifecycle ---------------------------------------------------- + // ADE Web keeps no local database, so every settle/snooze is a sync + // round-trip. These cover the three things that behaviour has to get right: + // paint at once, reconcile against the machine, roll back when it says no. + + it("paints a snooze at once and files the row as snoozed before the host catches up", async () => { + fake.descriptors = descriptors(LIFECYCLE_DESCRIPTORS); + fake.commandResults.set("work.listSessions", [{ id: "session-1", ptyId: "pty-1" }]); + fake.commandResults.set("session.snoozeSession", true); + const adapter = createAdeWebAdapter(fake.asClient()); + adapter.bindProject(project, "project-1"); + + const before = await adapter.ade.sessions.list(); + expect(isSessionSnoozed(before[0]!)).toBe(false); + + const untilIso = new Date(Date.now() + 60 * 60 * 1000).toISOString(); + await expect(adapter.ade.sessions.snoozeSession("session-1", untilIso)).resolves.toBe(true); + + // The host row is deliberately still un-snoozed here: the optimistic patch + // is the only reason the row reads as snoozed, which is what puts it in the + // shared "Snoozed" group without waiting for the changeset pump. + const optimistic = await adapter.ade.sessions.list(); + expect(optimistic[0]!.snoozedUntil).toBe(untilIso); + expect(optimistic[0]!.snoozedAt).toEqual(expect.any(String)); + expect(isSessionSnoozed(optimistic[0]!)).toBe(true); + + adapter.dispose(); + }); + + it("retires the optimistic patch once the machine's own row agrees", async () => { + fake.descriptors = descriptors(LIFECYCLE_DESCRIPTORS); + fake.commandResults.set("work.listSessions", [{ id: "session-1", ptyId: "pty-1" }]); + fake.commandResults.set("session.snoozeSession", true); + const adapter = createAdeWebAdapter(fake.asClient()); + adapter.bindProject(project, "project-1"); + await adapter.ade.sessions.list(); + + await adapter.ade.sessions.snoozeSession("session-1", new Date(Date.now() + 60 * 60 * 1000).toISOString()); + + // The machine stamps its OWN deadline. Reconciliation compares presence, + // not the value, so the host's instant must win as soon as it lands. + const hostUntil = new Date(Date.now() + 61 * 60 * 1000).toISOString(); + fake.commandResults.set("work.listSessions", [ + { id: "session-1", ptyId: "pty-1", snoozedUntil: hostUntil, snoozedAt: "2026-07-20T00:00:00.000Z" }, + ]); + + await adapter.ade.sessions.list(); + await flushMicrotasks(); + const reconciled = await adapter.ade.sessions.list(); + + expect(reconciled[0]!.snoozedUntil).toBe(hostUntil); + expect(reconciled[0]!.snoozedAt).toBe("2026-07-20T00:00:00.000Z"); + + adapter.dispose(); + }); + + it("rolls the row back and rethrows when the host rejects a lifecycle write", async () => { + fake.descriptors = descriptors(LIFECYCLE_DESCRIPTORS); + fake.commandResults.set("work.listSessions", [{ id: "session-1", ptyId: "pty-1" }]); + fake.commandErrors.set("session.snoozeSession", new Error("session is gone")); + const adapter = createAdeWebAdapter(fake.asClient()); + adapter.bindProject(project, "project-1"); + await adapter.ade.sessions.list(); + + const changes: unknown[] = []; + adapter.ade.sessions.onChanged((event) => changes.push(event)); + + await expect( + adapter.ade.sessions.snoozeSession("session-1", new Date(Date.now() + 60 * 60 * 1000).toISOString()), + ).rejects.toThrow("session is gone"); + + // Painted, then rolled back — both need a notification or the row would sit + // showing a snooze the machine never took. + expect(changes).toHaveLength(2); + const after = await adapter.ade.sessions.list(); + expect(after[0]!.snoozedUntil ?? null).toBeNull(); + expect(isSessionSnoozed(after[0]!)).toBe(false); + + adapter.dispose(); + }); + + it("drops the patch when the host reports the row was not snoozed", async () => { + fake.descriptors = descriptors(LIFECYCLE_DESCRIPTORS); + fake.commandResults.set("work.listSessions", [{ id: "session-1", ptyId: "pty-1" }]); + // wakeSession returns false for a row that was never snoozed. + fake.commandResults.set("session.wakeSession", false); + const adapter = createAdeWebAdapter(fake.asClient()); + adapter.bindProject(project, "project-1"); + await adapter.ade.sessions.list(); + + await expect(adapter.ade.sessions.wakeSession("session-1", "manual")).resolves.toBe(false); + + const after = await adapter.ade.sessions.list(); + expect(after[0]!.wokeAt ?? null).toBeNull(); + expect(after[0]!.wokeReason ?? null).toBeNull(); + + adapter.dispose(); + }); + + it("refuses lifecycle writes while the socket is down rather than silently no-op'ing", async () => { + fake.descriptors = descriptors(LIFECYCLE_DESCRIPTORS); + fake.commandResults.set("work.listSessions", [{ id: "session-1", ptyId: "pty-1" }]); + const adapter = createAdeWebAdapter(fake.asClient()); + adapter.bindProject(project, "project-1"); + fake.connectionState = "reconnecting"; + + await expect(adapter.ade.sessions.snoozeSession("session-1", new Date().toISOString())) + .rejects.toThrow(SESSION_LIFECYCLE_DISCONNECTED_MESSAGE); + await expect(adapter.ade.sessions.settle("session-1")).rejects.toThrow(SESSION_LIFECYCLE_DISCONNECTED_MESSAGE); + expect(fake.commandCalls.filter((call) => call.action.startsWith("session."))).toEqual([]); + + adapter.dispose(); + }); + + it("refuses lifecycle writes a host does not advertise", async () => { + // An older ADE registers no `session.*` commands at all. Without the gate + // `commands.call` would resolve to its fallback and the tap would do + // nothing at all. + fake.descriptors = descriptors(["work.listSessions"]); + fake.commandResults.set("work.listSessions", [{ id: "session-1", ptyId: "pty-1" }]); + const adapter = createAdeWebAdapter(fake.asClient()); + adapter.bindProject(project, "project-1"); + + await expect(adapter.ade.sessions.snoozeSession("session-1", new Date().toISOString())) + .rejects.toThrow(SESSION_LIFECYCLE_UNSUPPORTED_MESSAGE); + expect(fake.commandCalls.filter((call) => call.action.startsWith("session."))).toEqual([]); + + adapter.dispose(); + }); }); +const LIFECYCLE_DESCRIPTORS = [ + "work.listSessions", + "session.settleSession", + "session.unsettleSession", + "session.snoozeSession", + "session.wakeSession", + "session.setSettleOverride", + "session.clearWokeMarker", +]; + +/** Let a fire-and-forget background reconcile settle before asserting. */ +async function flushMicrotasks(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)); +} + function descriptors(actions: string[]): SyncRemoteCommandDescriptor[] { return actions.map((action) => ({ action, @@ -1707,6 +1857,8 @@ class FakeAdeSyncClient { ]; activeProjectId: string | null = "project-1"; projectSwitchResult: unknown = null; + /** Transport state, so tests can exercise "the socket is down" paths. */ + connectionState: "connected" | "reconnecting" | "disconnected" = "connected"; private readonly tableListeners = new Set<(tables: Set) => void>(); private readonly chatListeners = new Set<(payload: SyncChatEventPayload) => void>(); @@ -1722,7 +1874,7 @@ class FakeAdeSyncClient { getStatus() { return { - state: "connected" as const, + state: this.connectionState, endpoint: "ws://localhost:8787", envId: "env-1", hostDeviceId: "host-1", diff --git a/apps/desktop/src/renderer/webclient/adapter/__tests__/sessionLifecycleOverlay.test.ts b/apps/desktop/src/renderer/webclient/adapter/__tests__/sessionLifecycleOverlay.test.ts new file mode 100644 index 000000000..ebd07d61c --- /dev/null +++ b/apps/desktop/src/renderer/webclient/adapter/__tests__/sessionLifecycleOverlay.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; +import type { TerminalSessionSummary } from "../../../../shared/types"; +import { isSessionSnoozed } from "../../../../shared/sessionCanonicalState"; +import { + OPTIMISTIC_TTL_MS, + RECONCILE_GRACE_MS, + SessionLifecycleOverlay, +} from "../sessionLifecycleOverlay"; +import { sessionLifecycleSupported } from "../sessionLifecycleSupport"; + +function row(overrides: Partial & { id: string }): TerminalSessionSummary { + return overrides as TerminalSessionSummary; +} + +describe("SessionLifecycleOverlay", () => { + it("paints a snooze onto the host row so the shared derivation files it as snoozed", () => { + const overlay = new SessionLifecycleOverlay(); + const untilIso = new Date(Date.now() + 3_600_000).toISOString(); + const host = row({ id: "s1" }); + + expect(isSessionSnoozed(overlay.decorate(host))).toBe(false); + overlay.begin("s1", { snoozedUntil: untilIso, snoozedAt: new Date().toISOString() }); + + expect(isSessionSnoozed(overlay.decorate(host))).toBe(true); + // The authoritative row is never mutated — only the copy handed to the UI. + expect(host.snoozedUntil).toBeUndefined(); + }); + + it("retires a patch on agreement even though the host stamps its own instant", () => { + const overlay = new SessionLifecycleOverlay(); + overlay.begin("s1", { snoozedUntil: "2026-07-26T10:00:00.000Z", snoozedAt: "2026-07-26T09:00:00.000Z" }); + + const report = overlay.reconcile([ + row({ id: "s1", snoozedUntil: "2026-07-26T10:00:03.000Z", snoozedAt: "2026-07-26T09:00:01.000Z" }), + ]); + + expect(report).toEqual({ reconciled: ["s1"], rolledBack: [] }); + expect(overlay.size).toBe(0); + }); + + it("keeps a patch pending while the host row still disagrees", () => { + const overlay = new SessionLifecycleOverlay(); + overlay.begin("s1", { snoozedUntil: "2026-07-26T10:00:00.000Z" }); + + const report = overlay.reconcile([row({ id: "s1" })]); + + expect(report).toEqual({ reconciled: [], rolledBack: [] }); + expect(overlay.has("s1")).toBe(true); + }); + + it("compares enum columns exactly, so a different override is not agreement", () => { + const overlay = new SessionLifecycleOverlay(); + overlay.begin("s1", { settleOverride: "active" }); + + expect(overlay.reconcile([row({ id: "s1", settleOverride: "settled" })]).reconciled).toEqual([]); + expect(overlay.reconcile([row({ id: "s1", settleOverride: "active" })]).reconciled).toEqual(["s1"]); + }); + + it("drops an unconfirmed patch at its TTL so a lost ack cannot wedge the row", () => { + let nowMs = 1_000; + const overlay = new SessionLifecycleOverlay({ now: () => nowMs }); + overlay.begin("s1", { settledAt: "2026-07-26T10:00:00.000Z" }); + + nowMs += OPTIMISTIC_TTL_MS - 1; + expect(overlay.reconcile([row({ id: "s1" })]).rolledBack).toEqual([]); + + nowMs += 2; + expect(overlay.reconcile([row({ id: "s1" })]).rolledBack).toEqual(["s1"]); + expect(overlay.size).toBe(0); + }); + + it("shortens the window to the reconcile grace once the host acks", () => { + let nowMs = 1_000; + const overlay = new SessionLifecycleOverlay({ now: () => nowMs }); + const token = overlay.begin("s1", { settledAt: "2026-07-26T10:00:00.000Z" }); + overlay.confirm("s1", token); + + nowMs += RECONCILE_GRACE_MS + 1; + expect(overlay.reconcile([row({ id: "s1" })]).rolledBack).toEqual(["s1"]); + }); + + it("rolls back on reject and ignores a stale token from a superseded write", () => { + const overlay = new SessionLifecycleOverlay(); + const first = overlay.begin("s1", { snoozedUntil: "2026-07-26T10:00:00.000Z" }); + const second = overlay.begin("s1", { snoozedUntil: "2026-07-26T18:00:00.000Z" }); + + // The first write losing a race must not tear down the second write's patch. + overlay.reject("s1", first); + expect(overlay.decorate(row({ id: "s1" })).snoozedUntil).toBe("2026-07-26T18:00:00.000Z"); + + overlay.reject("s1", second); + expect(overlay.size).toBe(0); + }); + + it("returns the original list untouched when nothing is pending", () => { + const overlay = new SessionLifecycleOverlay(); + const rows = [row({ id: "s1" })]; + expect(overlay.decorateAll(rows)).toBe(rows); + }); +}); + +describe("sessionLifecycleSupported", () => { + it("requires the per-row settle/snooze/wake actions the controls issue", () => { + expect(sessionLifecycleSupported([])).toBe(false); + expect(sessionLifecycleSupported( + ["work.listSessions", "session.snoozeSession"].map((action) => ({ + action, + scope: "project" as const, + policy: { viewerAllowed: true }, + })), + )).toBe(false); + expect(sessionLifecycleSupported( + ["session.settleSession", "session.snoozeSession", "session.wakeSession"].map((action) => ({ + action, + scope: "project" as const, + policy: { viewerAllowed: true }, + })), + )).toBe(true); + }); +}); diff --git a/apps/desktop/src/renderer/webclient/adapter/lanes.ts b/apps/desktop/src/renderer/webclient/adapter/lanes.ts index ef3b22888..1971e915f 100644 --- a/apps/desktop/src/renderer/webclient/adapter/lanes.ts +++ b/apps/desktop/src/renderer/webclient/adapter/lanes.ts @@ -56,6 +56,9 @@ export function createLanesNamespace(infra: AdapterInfra): AdeNamespace<"lanes"> }, previewBranchSwitch: (args: unknown) => call("lanes.previewBranchSwitch", args, null), switchBranch: (args: unknown) => call("lanes.switchBranch", args, { ok: false, error: "unsupported" }, false), + getBranchDrift: (args: unknown) => call("lanes.getBranchDrift", args, null), + resolveBranchDrift: (args: unknown) => + call("lanes.resolveBranchDrift", args, { ok: false, error: "unsupported" }, false), attach: (args: unknown) => call("lanes.attach", args, null, false), listUnregisteredWorktrees: () => call("lanes.listUnregisteredWorktrees", {}, []), adoptAttached: (args: unknown) => call("lanes.adoptAttached", args, null, false), diff --git a/apps/desktop/src/renderer/webclient/adapter/sessionLifecycleOverlay.ts b/apps/desktop/src/renderer/webclient/adapter/sessionLifecycleOverlay.ts new file mode 100644 index 000000000..2082387cb --- /dev/null +++ b/apps/desktop/src/renderer/webclient/adapter/sessionLifecycleOverlay.ts @@ -0,0 +1,200 @@ +import type { TerminalSessionSummary } from "../../../shared/types"; + +/** + * Optimistic overlay for session-lifecycle writes in the web client. + * + * Desktop and iOS both own a local database, so a settle/snooze lands in local + * state the instant it is written and the UI repaints from that row. ADE Web + * has NO local database: every lifecycle mutation is a sync round-trip to the + * paired machine, and the only way the row changes is when a later + * `work.listSessions` read comes back. Without an overlay the user taps + * "Snooze" and the row sits in place for the whole round-trip, then jumps. + * + * So each mutation records a patch here, the UI is nudged to re-read straight + * away (the read is decorated with the patch), and the entry is retired in + * exactly one of three ways: + * + * - reconciled — an authoritative row arrives that already satisfies the + * intent, so the overlay is redundant and is dropped, + * - rejected — the host refused or the transport failed, so the overlay is + * dropped immediately and the row visibly snaps back (the caller also + * surfaces the failure toast), + * - expired — a hard TTL so a lost ack can never wedge a row into showing a + * state the machine does not agree with. + * + * The overlay only ever writes the lifecycle columns. It never touches phase: + * `canonicalSessionState` derives that, and snooze is a visibility overlay that + * the canonical derivation deliberately does not read. + */ + +export type SessionLifecyclePatch = Partial< + Pick< + TerminalSessionSummary, + "settledAt" | "settleOverride" | "snoozedUntil" | "snoozedAt" | "wokeAt" | "wokeReason" + > +>; + +/** + * Instant columns the HOST owns. We optimistically stamp our own clock into + * them, which will never equal the machine's, so reconciliation compares + * presence (set vs cleared) — the only part of a timestamp the write actually + * intended. + */ +const TIMESTAMP_KEYS = ["settledAt", "snoozedUntil", "snoozedAt", "wokeAt"] as const satisfies + readonly (keyof SessionLifecyclePatch)[]; + +/** Enum columns the client fully determines, so these compare exactly. */ +const ENUM_KEYS = ["settleOverride", "wokeReason"] as const satisfies + readonly (keyof SessionLifecyclePatch)[]; + +/** + * Ceiling on an unconfirmed optimistic patch. Longer than the sync command + * timeout so a slow-but-alive round-trip is not yanked out from under the user, + * short enough that a dropped ack self-heals without a reload. + */ +export const OPTIMISTIC_TTL_MS = 20_000; + +/** + * Once the host has acked, the authoritative row still has to travel back + * through the changeset pump. Hold the patch for this long so the row does not + * flicker back to its old state in the gap, then trust the host. + */ +export const RECONCILE_GRACE_MS = 8_000; + +type PendingEntry = { + token: number; + patch: SessionLifecyclePatch; + expiresAtMs: number; +}; + +export type SessionLifecycleOverlayOptions = { + now?: () => number; +}; + +export type SessionLifecycleReconcileReport = { + /** Patches the machine's rows now agree with. */ + reconciled: string[]; + /** Patches dropped without agreement — the row visibly returns to host state. */ + rolledBack: string[]; +}; + +export class SessionLifecycleOverlay { + /** + * One entry per session: lifecycle actions are per-row and last-write-wins, + * so a second action on the same row supersedes the first rather than + * stacking a patch history nobody could reconcile. + */ + private readonly pending = new Map(); + private nextToken = 1; + private readonly now: () => number; + + constructor(options: SessionLifecycleOverlayOptions = {}) { + this.now = options.now ?? (() => Date.now()); + } + + /** Apply a patch immediately and return the token used to settle it later. */ + begin(sessionId: string, patch: SessionLifecyclePatch): number { + const token = this.nextToken++; + if (!sessionId) return token; + this.pending.set(sessionId, { + token, + patch, + expiresAtMs: this.now() + OPTIMISTIC_TTL_MS, + }); + return token; + } + + /** Host accepted: keep painting the patch until the authoritative row lands. */ + confirm(sessionId: string, token: number): void { + const entry = this.pending.get(sessionId); + if (!entry || entry.token !== token) return; + entry.expiresAtMs = this.now() + RECONCILE_GRACE_MS; + } + + /** Host rejected (or the transport did): drop the patch so the row rolls back. */ + reject(sessionId: string, token: number): void { + const entry = this.pending.get(sessionId); + if (!entry || entry.token !== token) return; + this.pending.delete(sessionId); + } + + has(sessionId: string): boolean { + return this.pending.has(sessionId); + } + + get size(): number { + return this.pending.size; + } + + clear(): void { + this.pending.clear(); + } + + /** + * Retire entries the authoritative rows have caught up with (or that have + * outlived their TTL). Call with the raw host rows BEFORE decorating them. + * + * `reconciled` entries agreed with the machine, so the painted row already + * matches and nothing needs to move. `rolledBack` entries were dropped + * without the machine ever agreeing, so the row is about to change back and + * the caller must tell the UI. + */ + reconcile( + rows: readonly TerminalSessionSummary[], + nowMs: number = this.now(), + ): SessionLifecycleReconcileReport { + const reconciled: string[] = []; + const rolledBack: string[] = []; + if (this.pending.size === 0) return { reconciled, rolledBack }; + for (const row of rows) { + const entry = this.pending.get(row.id); + if (!entry) continue; + if (!patchSatisfiedBy(entry.patch, row)) continue; + this.pending.delete(row.id); + reconciled.push(row.id); + } + for (const [sessionId, entry] of this.pending) { + if (entry.expiresAtMs > nowMs) continue; + this.pending.delete(sessionId); + rolledBack.push(sessionId); + } + return { reconciled, rolledBack }; + } + + /** Overlay any pending patch onto one authoritative row. */ + decorate(row: T): T { + const entry = this.pending.get(row.id); + if (!entry) return row; + return { ...row, ...entry.patch }; + } + + /** Overlay pending patches onto a list, leaving untouched rows referentially stable. */ + decorateAll(rows: readonly T[]): T[] { + if (this.pending.size === 0) return rows as T[]; + return rows.map((row) => this.decorate(row)); + } +} + +/** + * An authoritative row satisfies a patch when every field the patch wrote + * already reads that way on the host row — presence for host-stamped instants, + * exact value for the enums the client decides. + */ +export function patchSatisfiedBy( + patch: SessionLifecyclePatch, + row: TerminalSessionSummary, +): boolean { + for (const key of TIMESTAMP_KEYS) { + if (!(key in patch)) continue; + if ((normalizeNullish(patch[key]) != null) !== (normalizeNullish(row[key]) != null)) return false; + } + for (const key of ENUM_KEYS) { + if (!(key in patch)) continue; + if (normalizeNullish(patch[key]) !== normalizeNullish(row[key])) return false; + } + return true; +} + +function normalizeNullish(value: T | null | undefined): T | null { + return value ?? null; +} diff --git a/apps/desktop/src/renderer/webclient/adapter/sessionLifecycleSupport.ts b/apps/desktop/src/renderer/webclient/adapter/sessionLifecycleSupport.ts new file mode 100644 index 000000000..cf2b0e45c --- /dev/null +++ b/apps/desktop/src/renderer/webclient/adapter/sessionLifecycleSupport.ts @@ -0,0 +1,75 @@ +import type { SyncRemoteCommandDescriptor } from "../../../shared/types/sync"; + +/** + * Whether the paired machine can do session lifecycle at all, and the copy the + * web client uses when it can't. + * + * ADE Web talks to whatever ADE version the user happens to be running on their + * Mac. Support is feature-detected exactly the way the phone does it — from the + * `hello_ok.features.commandRouting.actions` list the host advertises, surfaced + * by `AdeSyncClient.getCommandDescriptors()`. An older host simply does not + * register the `session.*` namespace, and a command sent to an unknown action + * resolves to the caller's fallback, i.e. a silent no-op. Gating on the + * advertised list turns that into an honest, explainable refusal. + */ + +/** Every remote command the Work tab's lifecycle controls can issue. */ +export const SESSION_LIFECYCLE_ACTIONS = [ + "session.settleSession", + "session.unsettleSession", + "session.settleSessions", + "session.unsettleSessions", + "session.snoozeSession", + "session.snoozeSessions", + "session.wakeSession", + "session.wakeSessions", + "session.setSettleOverride", + "session.clearWokeMarker", +] as const; + +/** + * The subset that must be present for the controls to be worth showing. A host + * that can settle and snooze one row can drive every affordance on the row; + * the bulk and marker commands degrade individually. + */ +const REQUIRED_ACTIONS = [ + "session.settleSession", + "session.snoozeSession", + "session.wakeSession", +] as const; + +export const SESSION_LIFECYCLE_DISCONNECTED_MESSAGE = + "Can't reach this Mac right now, so nothing was changed."; + +export const SESSION_LIFECYCLE_UNSUPPORTED_MESSAGE = + "This Mac is running an older ADE that can't settle or snooze sessions."; + +export type SessionLifecycleUnavailableCode = "disconnected" | "unsupported"; + +/** + * Thrown instead of silently resolving, so the shared Work-tab action helpers + * report a real failure and any optimistic patch rolls back visibly. + */ +export class SessionLifecycleUnavailableError extends Error { + constructor(readonly code: SessionLifecycleUnavailableCode, message: string) { + super(message); + this.name = "SessionLifecycleUnavailableError"; + } + + static disconnected(): SessionLifecycleUnavailableError { + return new SessionLifecycleUnavailableError("disconnected", SESSION_LIFECYCLE_DISCONNECTED_MESSAGE); + } + + static unsupported(): SessionLifecycleUnavailableError { + return new SessionLifecycleUnavailableError("unsupported", SESSION_LIFECYCLE_UNSUPPORTED_MESSAGE); + } +} + +/** True when the host advertises enough of `session.*` to drive the controls. */ +export function sessionLifecycleSupported( + descriptors: readonly SyncRemoteCommandDescriptor[], +): boolean { + if (descriptors.length === 0) return false; + const advertised = new Set(descriptors.map((descriptor) => String(descriptor.action))); + return REQUIRED_ACTIONS.every((action) => advertised.has(action)); +} diff --git a/apps/desktop/src/renderer/webclient/adapter/sessionsPty.ts b/apps/desktop/src/renderer/webclient/adapter/sessionsPty.ts index cfb21b22e..45ed6aea6 100644 --- a/apps/desktop/src/renderer/webclient/adapter/sessionsPty.ts +++ b/apps/desktop/src/renderer/webclient/adapter/sessionsPty.ts @@ -4,6 +4,8 @@ import type { PtyCreateResult, PtyDisposeResult, PtySendToSessionResult, + SessionWakeReason, + TerminalSessionDetail, TerminalSessionSummary, } from "../../../shared/types"; import type { @@ -12,11 +14,25 @@ import type { } from "../../../shared/types/sync"; import type { AdapterInfra, AdeNamespace } from "./types"; import { chatTerminalFromSummary } from "./infra/registries"; +import { stableCacheKey } from "./infra/cacheKey"; +import { + SessionLifecycleOverlay, + type SessionLifecyclePatch, +} from "./sessionLifecycleOverlay"; +import { SessionLifecycleUnavailableError } from "./sessionLifecycleSupport"; // Full snapshots replace xterm state, so they must be at least as complete as // TerminalView's initial hydration. The host caps this at the same 2 MB. const LIVE_TERMINAL_SUBSCRIBE_MAX_BYTES = 2_000_000; +/** + * How many distinct `work.listSessions` argument shapes keep a mirrored copy of + * their last authoritative rows. The Work tab reads a handful (all sessions, + * per-lane, limited); this only has to cover those, and it is bounded so a + * long-lived tab can't accumulate one entry per lane it ever visited. + */ +const SESSION_MIRROR_MAX_KEYS = 8; + export type SessionsPtyNamespaces = { sessions: AdeNamespace<"sessions">; pty: AdeNamespace<"pty">; @@ -26,6 +42,14 @@ export type SessionsPtyNamespaces = { export function createSessionsPtyNamespaces(infra: AdapterInfra): SessionsPtyNamespaces { const { client, commands, events, terminalRegistry } = infra; const terminalSubscriptions = new Map void>(); + const lifecycle = new SessionLifecycleOverlay(); + /** + * Last authoritative rows per `work.listSessions` argument shape. ADE Web has + * no local database, so this is the only thing an optimistic lifecycle patch + * can be painted onto without first paying a full sync round-trip. + */ + const sessionMirror = new Map(); + const sessionRefreshInFlight = new Map>(); function subscribeSession(sessionId: string, ptyId?: string | null): void { if (!sessionId || terminalSubscriptions.has(sessionId)) return; @@ -83,6 +107,10 @@ export function createSessionsPtyNamespaces(infra: AdapterInfra): SessionsPtyNam infra.addDispose(events.on("projectBoundary", () => { for (const unsubscribe of terminalSubscriptions.values()) unsubscribe(); terminalSubscriptions.clear(); + // Rows and pending patches belong to the project that was left behind. + sessionMirror.clear(); + sessionRefreshInFlight.clear(); + lifecycle.clear(); })); infra.addDispose( @@ -94,13 +122,65 @@ export function createSessionsPtyNamespaces(infra: AdapterInfra): SessionsPtyNam }) ); + function rememberSessionRows(key: string, rows: TerminalSessionSummary[]): void { + sessionMirror.delete(key); + sessionMirror.set(key, rows); + while (sessionMirror.size > SESSION_MIRROR_MAX_KEYS) { + const oldest = sessionMirror.keys().next().value as string | undefined; + if (!oldest) break; + sessionMirror.delete(oldest); + } + } + + async function fetchSessions(key: string, args: Record): Promise { + const existing = sessionRefreshInFlight.get(key); + if (existing) return await existing; + const request = (async () => { + const sessions = await commands.call("work.listSessions", args, { + fallback: [], + idempotent: true, + }); + terminalRegistry.registerSummaries(sessions); + rememberSessionRows(key, sessions); + return sessions; + })(); + sessionRefreshInFlight.set(key, request); + try { + return await request; + } finally { + if (sessionRefreshInFlight.get(key) === request) sessionRefreshInFlight.delete(key); + } + } + async function listSessions(args?: unknown): Promise { - const sessions = await commands.call("work.listSessions", asRecord(args), { - fallback: [], - idempotent: true, - }); - terminalRegistry.registerSummaries(sessions); - return sessions; + const record = asRecord(args); + const key = stableCacheKey(record); + const mirrored = sessionMirror.get(key); + if (lifecycle.size > 0 && mirrored) { + // A lifecycle write is in flight. Answer from the mirror so the change is + // on screen at once, and reconcile against the authoritative read in the + // background — the row only moves again if the machine disagrees. + void fetchSessions(key, record) + .then((fresh) => { + const report = lifecycle.reconcile(fresh); + // Agreement needs no repaint — the row already shows what the machine + // says. A patch dropped WITHOUT agreement is a rollback the user has + // to see, so nudge the UI to re-read the authoritative row. + if (report.rolledBack.length > 0) { + for (const sessionId of report.rolledBack) { + events.emit("sessionsChanged", { sessionId, reason: "meta-updated" }); + } + } + }) + .catch(() => { + // A failed refresh leaves the patch pending until its TTL lapses; + // the transport error is already surfaced by the connection status. + }); + return lifecycle.decorateAll(mirrored); + } + const sessions = await fetchSessions(key, record); + lifecycle.reconcile(sessions); + return lifecycle.decorateAll(sessions); } async function captureSnapshot(sessionId: string, maxBytes?: number | null): Promise { @@ -114,13 +194,88 @@ export function createSessionsPtyNamespaces(infra: AdapterInfra): SessionsPtyNam return historyToSnapshot(history); } + function notifySessionsChanged(sessionIds: readonly string[]): void { + for (const sessionId of sessionIds) { + events.emit("sessionsChanged", { sessionId, reason: "meta-updated" }); + } + } + + /** + * Refuse honestly instead of no-op'ing. + * + * `commands.call` resolves an unadvertised action to its fallback, and a + * command sent while the socket is down never reaches the machine — either + * way the row would silently stay put. Throwing means the shared Work-tab + * helpers show a real failure and any optimistic patch rolls back. + */ + function assertLifecycleAvailable(command: string): void { + if (client.getStatus().state !== "connected") throw SessionLifecycleUnavailableError.disconnected(); + if (!commands.hasAction(command)) throw SessionLifecycleUnavailableError.unsupported(); + } + + /** + * Session-lifecycle mutations all follow one shape: paint the change locally, + * send the non-idempotent sync command, then reconcile. + * + * Desktop and iOS write to a local database and repaint from it. ADE Web has + * no database, so the patch is held in `lifecycle` and the row is re-read + * from the mirror while the round-trip is in flight. A rejection drops the + * patch and re-notifies, so the row visibly returns to what the machine says. + */ + async function lifecycleCall( + command: string, + payload: Record, + changed: string | string[], + patch: SessionLifecyclePatch, + /** + * Did the host actually change THIS row? Several lifecycle commands are + * no-ops for rows that were not in the expected state (waking a row that + * was never snoozed), and reporting a no-op as applied would leave the + * patch painting a state the machine never entered. + */ + applied?: (result: T | null, sessionId: string) => boolean, + ): Promise { + const sessionIds = (Array.isArray(changed) ? changed : [changed]).filter( + (sessionId): sessionId is string => Boolean(sessionId), + ); + assertLifecycleAvailable(command); + const tokens = sessionIds.map((sessionId) => ({ sessionId, token: lifecycle.begin(sessionId, patch) })); + notifySessionsChanged(sessionIds); + try { + const result = await commands.call(command, payload, { + fallback: null, + idempotent: false, + }); + for (const entry of tokens) { + if (applied && !applied(result, entry.sessionId)) lifecycle.reject(entry.sessionId, entry.token); + else lifecycle.confirm(entry.sessionId, entry.token); + } + // Re-read against the machine now that it has acked; the patch keeps the + // row steady until the authoritative row catches up. + notifySessionsChanged(sessionIds); + return result; + } catch (error) { + for (const entry of tokens) lifecycle.reject(entry.sessionId, entry.token); + notifySessionsChanged(sessionIds); + throw error; + } + } + + const appliedToAll = (result: unknown): boolean => result === true; + const appliedToId = (result: unknown, sessionId: string): boolean => + Array.isArray(result) ? result.includes(sessionId) : true; + const sessions: Record = { list: listSessions, - get: (sessionId: string) => - commands.call("work.getSession", { sessionId }, { + get: async (sessionId: string) => { + const detail = await commands.call("work.getSession", { sessionId }, { fallback: null, idempotent: true, - }), + }); + // Same overlay as the list read, so opening a row you just snoozed does + // not show it un-snoozed while the machine catches up. + return detail ? lifecycle.decorate(detail) : null; + }, delete: async (args: unknown) => { await commands.call("work.deleteSession", asRecord(args), { fallback: undefined, @@ -143,6 +298,75 @@ export function createSessionsPtyNamespaces(infra: AdapterInfra): SessionsPtyNam }); return result; }, + settle: async (sessionId: string, opts?: { outcome?: string; dismissPendingInput?: boolean }) => { + await lifecycleCall("session.settleSession", { + sessionId, + ...(opts?.outcome ? { outcome: opts.outcome } : {}), + ...(opts?.dismissPendingInput ? { dismissPendingInput: true } : {}), + }, sessionId, settlePatch()); + }, + unsettle: async (sessionId: string) => { + await lifecycleCall("session.unsettleSession", { sessionId }, sessionId, UNSETTLE_PATCH); + }, + settleMany: async (sessionIds: string[]) => + (await lifecycleCall( + "session.settleSessions", + { sessionIds }, + sessionIds, + settlePatch(), + appliedToId, + )) ?? [], + unsettleMany: async (sessionIds: string[]) => { + await lifecycleCall("session.unsettleSessions", { sessionIds }, sessionIds, UNSETTLE_PATCH); + }, + snoozeSession: async (sessionId: string, untilIso: string) => + (await lifecycleCall( + "session.snoozeSession", + { sessionId, untilIso }, + sessionId, + snoozePatch(untilIso), + appliedToAll, + )) === true, + wakeSession: async (sessionId: string, reason?: string) => + (await lifecycleCall( + "session.wakeSession", + { sessionId, ...(reason ? { reason } : {}) }, + sessionId, + wakePatch(reason), + appliedToAll, + )) === true, + snoozeSessions: async (sessionIds: string[], untilIso: string) => + (await lifecycleCall( + "session.snoozeSessions", + { sessionIds, untilIso }, + sessionIds, + snoozePatch(untilIso), + appliedToId, + )) ?? [], + wakeSessions: async (sessionIds: string[], reason?: string) => + (await lifecycleCall( + "session.wakeSessions", + { sessionIds, ...(reason ? { reason } : {}) }, + sessionIds, + wakePatch(reason), + appliedToId, + )) ?? [], + setSettleOverride: async (sessionId: string, override: "settled" | "active" | null) => + (await lifecycleCall( + "session.setSettleOverride", + { sessionId, override }, + sessionId, + { settleOverride: override }, + appliedToAll, + )) === true, + clearWokeMarker: async (sessionId: string) => + (await lifecycleCall( + "session.clearWokeMarker", + { sessionId }, + sessionId, + CLEAR_WOKE_PATCH, + appliedToAll, + )) === true, readTranscriptTail: async (args: unknown) => { const record = asRecord(args); const sessionId = stringField(record, "sessionId"); @@ -330,6 +554,53 @@ export function createSessionsPtyNamespaces(infra: AdapterInfra): SessionsPtyNam }; } +/** + * The optimistic column writes, mirroring exactly what the host's + * `sessionService` does for each command. Instants are stamped with the browser + * clock and reconciled by PRESENCE, never by value — the machine's instant is + * the real one. + */ + +/** Host: `settled_at = coalesce(settled_at, now)`, `settle_override = null`. */ +function settlePatch(): SessionLifecyclePatch { + return { settledAt: new Date().toISOString(), settleOverride: null }; +} + +/** + * Host: `settled_at = null`, and it clears a `"settled"` override only. An + * `"active"` pin survives, so this patch deliberately leaves the column alone + * rather than claiming a clear the machine may not make. + */ +const UNSETTLE_PATCH: SessionLifecyclePatch = { settledAt: null }; + +/** Host: sets both snooze columns and drops any previous woke marker. */ +function snoozePatch(untilIso: string): SessionLifecyclePatch { + return { + snoozedUntil: untilIso, + snoozedAt: new Date().toISOString(), + wokeAt: null, + wokeReason: null, + }; +} + +/** Host: clears both snooze columns and records why the row came back. */ +function wakePatch(reason?: string): SessionLifecyclePatch { + return { + snoozedUntil: null, + snoozedAt: null, + wokeAt: new Date().toISOString(), + wokeReason: normalizeWakeReason(reason), + }; +} + +const CLEAR_WOKE_PATCH: SessionLifecyclePatch = { wokeAt: null, wokeReason: null }; + +const WAKE_REASONS = new Set(["timer", "needs_you", "error", "turn_complete", "manual"]); + +function normalizeWakeReason(reason?: string): SessionWakeReason { + return WAKE_REASONS.has(reason as SessionWakeReason) ? (reason as SessionWakeReason) : "manual"; +} + function asRecord(args: unknown): Record { return args && typeof args === "object" ? (args as Record) : {}; } diff --git a/apps/desktop/src/renderer/webclient/shell/WebClientRoot.tsx b/apps/desktop/src/renderer/webclient/shell/WebClientRoot.tsx index e843375f0..d2b23b829 100644 --- a/apps/desktop/src/renderer/webclient/shell/WebClientRoot.tsx +++ b/apps/desktop/src/renderer/webclient/shell/WebClientRoot.tsx @@ -25,6 +25,7 @@ import { ScreenShell } from "./ScreenShell"; import { ProjectPicker } from "./ProjectPicker"; import { WebShell } from "./WebShell"; import { MachinePicker } from "./MachinePicker"; +import { installSessionLifecycleChrome } from "./sessionLifecycleChrome"; import { COLORS, SANS_FONT, primaryButton } from "./shellTokens"; type AdeWebAdapter = { @@ -508,6 +509,12 @@ export function WebClientRoot({ return client.onProjectCatalog((payload) => setCatalog(payload.projects)); }, [client]); + // The Work list is the desktop component; give its lifecycle controls a touch + // presentation and hide them on hosts that can't run `session.*` at all. + useEffect(() => { + return installSessionLifecycleChrome(client); + }, [client]); + useEffect(() => { return client.onActiveProjectChanged((change) => { const { project, catalog: nextCatalog } = change; diff --git a/apps/desktop/src/renderer/webclient/shell/__tests__/WebClientRoot.test.tsx b/apps/desktop/src/renderer/webclient/shell/__tests__/WebClientRoot.test.tsx index 15efc532b..d0617b34d 100644 --- a/apps/desktop/src/renderer/webclient/shell/__tests__/WebClientRoot.test.tsx +++ b/apps/desktop/src/renderer/webclient/shell/__tests__/WebClientRoot.test.tsx @@ -122,6 +122,9 @@ function syncClient(overrides: Record = {}): AdeSyncClient { listEnvironments: vi.fn(async () => []), pruneAccountOwnedEnvironments: vi.fn(async () => pruneResult([])), subscribe: vi.fn(() => () => undefined), + // Feature detection for the host's `session.*` command namespace, read by + // the session-lifecycle chrome the shell installs on mount. + getCommandDescriptors: vi.fn(() => []), onProjectCatalog: vi.fn(() => () => undefined), onActiveProjectChanged: vi.fn(() => () => undefined), ...overrides, diff --git a/apps/desktop/src/renderer/webclient/shell/__tests__/sessionLifecycleChrome.test.ts b/apps/desktop/src/renderer/webclient/shell/__tests__/sessionLifecycleChrome.test.ts new file mode 100644 index 000000000..6b6850b85 --- /dev/null +++ b/apps/desktop/src/renderer/webclient/shell/__tests__/sessionLifecycleChrome.test.ts @@ -0,0 +1,182 @@ +/* @vitest-environment jsdom */ + +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { SyncRemoteCommandDescriptor } from "../../../../shared/types/sync"; +import type { AdeSyncClient } from "../../sync"; +import { + LONG_PRESS_MS, + SESSION_LIFECYCLE_ATTRIBUTE, + installSessionLifecycleChrome, +} from "../sessionLifecycleChrome"; + +function descriptors(actions: string[]): SyncRemoteCommandDescriptor[] { + return actions.map((action) => ({ action, scope: "project", policy: { viewerAllowed: true } })); +} + +const FULL_LIFECYCLE = ["session.settleSession", "session.snoozeSession", "session.wakeSession"]; + +class FakeClient { + advertised: SyncRemoteCommandDescriptor[] = []; + private readonly listeners = new Set<() => void>(); + + getCommandDescriptors(): SyncRemoteCommandDescriptor[] { + return this.advertised; + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + emitStatus(): void { + for (const listener of this.listeners) listener(); + } + + asClient(): Pick { + return this as never as Pick; + } +} + +describe("installSessionLifecycleChrome", () => { + let dispose: (() => void) | null = null; + + afterEach(() => { + dispose?.(); + dispose = null; + }); + + it("marks the document unsupported until the host advertises the session commands", () => { + const client = new FakeClient(); + dispose = installSessionLifecycleChrome(client.asClient()); + + expect(document.documentElement.getAttribute(SESSION_LIFECYCLE_ATTRIBUTE)).toBe("unsupported"); + + // A reconnect can land on a different ADE version, so the flag re-reads the + // advertised list on every status change rather than only at install. + client.advertised = descriptors(FULL_LIFECYCLE); + client.emitStatus(); + expect(document.documentElement.getAttribute(SESSION_LIFECYCLE_ATTRIBUTE)).toBe("ready"); + + client.advertised = descriptors(["work.listSessions"]); + client.emitStatus(); + expect(document.documentElement.getAttribute(SESSION_LIFECYCLE_ATTRIBUTE)).toBe("unsupported"); + }); + + it("installs one stylesheet that reveals and enlarges the control on coarse pointers", () => { + const client = new FakeClient(); + client.advertised = descriptors(FULL_LIFECYCLE); + dispose = installSessionLifecycleChrome(client.asClient()); + const secondInstall = installSessionLifecycleChrome(client.asClient()); + + const styles = document.head.querySelectorAll("style#ade-web-session-lifecycle"); + expect(styles).toHaveLength(1); + const css = styles[0]!.textContent ?? ""; + expect(css).toContain("@media (pointer: coarse)"); + expect(css).toContain('[data-testid="session-snooze-button"]'); + expect(css).toContain("pointer-events: auto"); + expect(css).toContain("display: none"); + + secondInstall(); + }); + + it("removes the flag and the stylesheet on dispose", () => { + const client = new FakeClient(); + client.advertised = descriptors(FULL_LIFECYCLE); + installSessionLifecycleChrome(client.asClient())(); + + expect(document.documentElement.hasAttribute(SESSION_LIFECYCLE_ATTRIBUTE)).toBe(false); + expect(document.head.querySelector("style#ade-web-session-lifecycle")).toBeNull(); + }); +}); + +describe("long-press context menu bridge", () => { + let dispose: (() => void) | null = null; + + afterEach(() => { + dispose?.(); + dispose = null; + document.body.innerHTML = ""; + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + function coarsePointer(matches: boolean): void { + vi.stubGlobal("matchMedia", (query: string) => ({ + matches: query.includes("coarse") ? matches : false, + media: query, + addEventListener: () => undefined, + removeEventListener: () => undefined, + })); + } + + /** Minimal stand-in for a shared Work row: a card containing the control. */ + function renderRow(): { row: HTMLElement; title: HTMLElement; contextMenus: MouseEvent[] } { + document.body.innerHTML = ` +
+ +
+
+ `; + const row = document.getElementById("row") as HTMLElement; + const contextMenus: MouseEvent[] = []; + row.addEventListener("contextmenu", (event) => contextMenus.push(event as MouseEvent)); + return { row, title: document.getElementById("title") as HTMLElement, contextMenus }; + } + + function press(target: HTMLElement, init: Partial = {}): void { + // jsdom has no PointerEvent constructor; a MouseEvent carrying the pointer + // fields is enough for the listener under test. + const event = new MouseEvent("pointerdown", { bubbles: true, clientX: 10, clientY: 10, ...init }); + Object.assign(event, { pointerType: "touch", isPrimary: true, ...init }); + target.dispatchEvent(event); + } + + it("synthesises a contextmenu after a hold so touch reaches Settle and Keep active", () => { + vi.useFakeTimers(); + coarsePointer(true); + const client = new FakeClient(); + client.advertised = descriptors(FULL_LIFECYCLE); + dispose = installSessionLifecycleChrome(client.asClient()); + const { title, contextMenus } = renderRow(); + + press(title); + vi.advanceTimersByTime(LONG_PRESS_MS + 1); + + expect(contextMenus).toHaveLength(1); + expect(contextMenus[0]!.clientX).toBe(10); + }); + + it("treats a moved finger as a scroll, not a press", () => { + vi.useFakeTimers(); + coarsePointer(true); + const client = new FakeClient(); + dispose = installSessionLifecycleChrome(client.asClient()); + const { title, contextMenus } = renderRow(); + + press(title); + const move = new MouseEvent("pointermove", { bubbles: true, clientX: 10, clientY: 90 }); + document.dispatchEvent(move); + vi.advanceTimersByTime(LONG_PRESS_MS + 1); + + expect(contextMenus).toEqual([]); + }); + + it("ignores holds on the control's own button and on precise pointers", () => { + vi.useFakeTimers(); + coarsePointer(true); + const client = new FakeClient(); + dispose = installSessionLifecycleChrome(client.asClient()); + const { contextMenus } = renderRow(); + + press(document.querySelector("[data-testid='session-snooze-button']") as HTMLElement); + vi.advanceTimersByTime(LONG_PRESS_MS + 1); + expect(contextMenus).toEqual([]); + + dispose(); + coarsePointer(false); + dispose = installSessionLifecycleChrome(client.asClient()); + press(document.getElementById("title") as HTMLElement); + vi.advanceTimersByTime(LONG_PRESS_MS + 1); + expect(contextMenus).toEqual([]); + }); +}); diff --git a/apps/desktop/src/renderer/webclient/shell/sessionLifecycleChrome.ts b/apps/desktop/src/renderer/webclient/shell/sessionLifecycleChrome.ts new file mode 100644 index 000000000..ad99ab4da --- /dev/null +++ b/apps/desktop/src/renderer/webclient/shell/sessionLifecycleChrome.ts @@ -0,0 +1,195 @@ +import type { AdeSyncClient } from "../sync"; +import { sessionLifecycleSupported } from "../adapter/sessionLifecycleSupport"; + +/** + * Web-only presentation for the shared Work tab's session-lifecycle controls. + * + * The Work list is the desktop component, mounted verbatim by ADE Web, and it + * reveals the row's lifecycle control on pointer hover. A phone or tablet never + * produces hover, so on the web that control would be unreachable — and a host + * running an older ADE advertises no `session.*` commands at all, which would + * leave a control that can only fail. + * + * Both are web-shell concerns, not component concerns, so they are handled here + * with one stylesheet keyed off a single attribute on : + * + * data-ade-session-lifecycle="ready" host can do lifecycle + * data-ade-session-lifecycle="unsupported" host can't — hide the affordance + */ + +const STYLE_ELEMENT_ID = "ade-web-session-lifecycle"; +export const SESSION_LIFECYCLE_ATTRIBUTE = "data-ade-session-lifecycle"; + +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. + */ +const STYLES = ` +@media (pointer: coarse) { + [${SESSION_LIFECYCLE_ATTRIBUTE}="ready"] [data-testid="session-snooze-button"] { + opacity: 1; + height: 2rem; + width: 2rem; + } + /* The row's action wrapper is pointer-events-none until hovered; a coarse + pointer never hovers, so the button would swallow no taps at all. */ + [${SESSION_LIFECYCLE_ATTRIBUTE}="ready"] *:has(> [data-testid="session-snooze-button"]) { + pointer-events: auto; + } + [${SESSION_LIFECYCLE_ATTRIBUTE}="ready"] [role="menu"][aria-label="Snooze session"] [role="menuitem"] { + min-height: 2.75rem; + } +} +[${SESSION_LIFECYCLE_ATTRIBUTE}="unsupported"] [data-testid="session-snooze-button"] { + display: none; +} +`; + +/** Press-and-hold duration that stands in for a right-click on touch. */ +export const LONG_PRESS_MS = 500; +/** Movement past this is a scroll or a drag, not a press. */ +const LONG_PRESS_SLOP_PX = 10; +/** Bounded ancestor walk from the press target up to the session row. */ +const CARD_LOOKUP_MAX_DEPTH = 12; +const SNOOZE_BUTTON_SELECTOR = '[data-testid="session-snooze-button"]'; +/** Stable root anchor rendered by the shared SessionCard. */ +const SESSION_ROW_SELECTOR = "[data-session-row]"; + +/** + * The session row a press landed on, or null. + * + * `SessionCard` marks its root with `data-session-row`, so prefer that. The + * ancestor-walk below is a fallback for a row that predates the attribute: the + * row always renders the lifecycle control as a descendant, so the nearest + * ancestor containing one IS the row. Walking the DOM (rather than a `:has()` + * selector) keeps the fallback working on browsers without `:has` support. + */ +function sessionRowFrom(target: EventTarget | null): HTMLElement | null { + if (!(target instanceof Element)) return null; + // The control opens its own menu; a long press on it is not a row press. + if (target.closest(SNOOZE_BUTTON_SELECTOR)) return null; + if (target.closest('[role="menu"], [role="dialog"]')) return null; + const tagged = target.closest(SESSION_ROW_SELECTOR); + if (tagged instanceof HTMLElement) return tagged; + let node: Element | null = target; + for (let depth = 0; node && depth < CARD_LOOKUP_MAX_DEPTH; depth += 1) { + if (node instanceof HTMLElement && node.querySelector(SNOOZE_BUTTON_SELECTOR)) return node; + node = node.parentElement; + } + return null; +} + +/** + * Bridge press-and-hold to the row's context menu on touch. + * + * Settle, Unsettle and Keep active live only in the shared row's context menu, + * which desktop opens with a right-click. Android fires `contextmenu` on a long + * press; iOS Safari never does, so on the web those three actions would be + * unreachable on a phone. Synthesising the event keeps one menu implementation + * instead of a second, divergent touch menu. + */ +function installLongPressContextMenu(doc: Document): () => void { + const view = doc.defaultView; + if (!view || typeof view.matchMedia !== "function") return () => undefined; + if (!view.matchMedia("(pointer: coarse)").matches) return () => undefined; + + let timer: number | null = null; + let origin: { x: number; y: number; row: HTMLElement } | null = null; + + const cancel = () => { + if (timer != null) view.clearTimeout(timer); + timer = null; + origin = null; + }; + + const onPointerDown = (event: PointerEvent) => { + if (event.pointerType === "mouse" || !event.isPrimary) return; + const row = sessionRowFrom(event.target); + if (!row) return; + origin = { x: event.clientX, y: event.clientY, row }; + timer = view.setTimeout(() => { + const pressed = origin; + cancel(); + if (!pressed) return; + pressed.row.dispatchEvent(new MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + composed: true, + button: 2, + clientX: pressed.x, + clientY: pressed.y, + })); + }, LONG_PRESS_MS); + }; + + const onPointerMove = (event: PointerEvent) => { + if (!origin) return; + if ( + Math.abs(event.clientX - origin.x) > LONG_PRESS_SLOP_PX + || Math.abs(event.clientY - origin.y) > LONG_PRESS_SLOP_PX + ) { + cancel(); + } + }; + + doc.addEventListener("pointerdown", onPointerDown, true); + doc.addEventListener("pointermove", onPointerMove, true); + doc.addEventListener("pointerup", cancel, true); + doc.addEventListener("pointercancel", cancel, true); + doc.addEventListener("scroll", cancel, true); + return () => { + cancel(); + doc.removeEventListener("pointerdown", onPointerDown, true); + doc.removeEventListener("pointermove", onPointerMove, true); + doc.removeEventListener("pointerup", cancel, true); + doc.removeEventListener("pointercancel", cancel, true); + doc.removeEventListener("scroll", cancel, true); + }; +} + +function ensureStyleElement(doc: Document): HTMLStyleElement { + const existing = doc.getElementById(STYLE_ELEMENT_ID); + if (existing instanceof HTMLStyleElement) return existing; + const style = doc.createElement("style"); + style.id = STYLE_ELEMENT_ID; + style.textContent = STYLES; + doc.head.appendChild(style); + return style; +} + +export function applySessionLifecycleChromeState( + state: SessionLifecycleChromeState, + doc: Document = document, +): void { + doc.documentElement.setAttribute(SESSION_LIFECYCLE_ATTRIBUTE, state); +} + +/** + * Install the stylesheet and keep the capability flag in step with the host. + * The advertised command list is re-read on every status change because a + * reconnect (or a machine switch) can land on a different ADE version. + */ +export function installSessionLifecycleChrome( + client: Pick, + doc: Document = document, +): () => void { + ensureStyleElement(doc); + const sync = () => { + applySessionLifecycleChromeState( + sessionLifecycleSupported(client.getCommandDescriptors()) ? "ready" : "unsupported", + doc, + ); + }; + sync(); + const unsubscribe = client.subscribe(sync); + const uninstallLongPress = installLongPressContextMenu(doc); + return () => { + unsubscribe(); + uninstallLongPress(); + doc.documentElement.removeAttribute(SESSION_LIFECYCLE_ATTRIBUTE); + doc.getElementById(STYLE_ELEMENT_ID)?.remove(); + }; +} diff --git a/apps/desktop/src/shared/ipc.ts b/apps/desktop/src/shared/ipc.ts index 346def9c6..eb55c29a6 100644 --- a/apps/desktop/src/shared/ipc.ts +++ b/apps/desktop/src/shared/ipc.ts @@ -124,6 +124,8 @@ export const IPC = { lanesImportBranch: "ade.lanes.importBranch", lanesPreviewBranchSwitch: "ade.lanes.previewBranchSwitch", lanesSwitchBranch: "ade.lanes.switchBranch", + lanesGetBranchDrift: "ade.lanes.getBranchDrift", + lanesResolveBranchDrift: "ade.lanes.resolveBranchDrift", lanesAttach: "ade.lanes.attach", lanesListUnregisteredWorktrees: "ade.lanes.listUnregisteredWorktrees", lanesAdoptAttached: "ade.lanes.adoptAttached", @@ -206,6 +208,12 @@ export const IPC = { sessionsUnsettle: "ade.sessions.unsettle", sessionsSettleMany: "ade.sessions.settleMany", sessionsUnsettleMany: "ade.sessions.unsettleMany", + sessionsSnooze: "ade.sessions.snooze", + sessionsWake: "ade.sessions.wake", + sessionsSnoozeMany: "ade.sessions.snoozeMany", + sessionsWakeMany: "ade.sessions.wakeMany", + sessionsSetSettleOverride: "ade.sessions.setSettleOverride", + sessionsClearWokeMarker: "ade.sessions.clearWokeMarker", sessionsChanged: "ade.sessions.changed", sessionsReadTranscriptTail: "ade.sessions.readTranscriptTail", agentChatList: "ade.agentChat.list", diff --git a/apps/desktop/src/shared/sessionCanonicalState.test.ts b/apps/desktop/src/shared/sessionCanonicalState.test.ts index 46c0bdee8..dcf782a57 100644 --- a/apps/desktop/src/shared/sessionCanonicalState.test.ts +++ b/apps/desktop/src/shared/sessionCanonicalState.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "vitest"; +import { parseSessionSettleOverride } from "./types/sessions"; import { canonicalSessionState, + isSessionFiledAsSnoozed, + isSessionSnoozed, + isSessionSnoozeExpired, + isWakingSessionError, + resolveSessionWakeReason, SESSION_STALE_AFTER_MS, type CanonicalSessionInputs, } from "./sessionCanonicalState"; @@ -81,3 +87,215 @@ describe("stale boundary", () => { expect(state({ lastActivityAt: "not-a-date" }).phase).toBe("running"); }); }); + +describe("settle override tri-state", () => { + // The bug this exists to fix: exit 0 auto-settles WITHOUT stamping + // settled_at, so the row had no lifecycle action at all and was pinned to + // the quiet tier forever. + const cleanExit: Partial = { status: "detached", exitCode: 0, runtimeState: "exited" }; + + it("null override leaves the derived exit-0 auto-settle intact", () => { + expect(state({ ...cleanExit }).phase).toBe("settled"); + expect(state({ ...cleanExit, settleOverride: null }).phase).toBe("settled"); + }); + + it("'active' override beats the derived exit-0 rule", () => { + const result = state({ ...cleanExit, settleOverride: "active" }); + expect(result.phase).toBe("ended"); + expect(result.badge).toBeNull(); + }); + + it("'active' override also suppresses a declared settle", () => { + expect(state({ ...cleanExit, settledAt: "2026-07-06T11:00:00.000Z", settleOverride: "active" }).phase) + .toBe("ended"); + }); + + it("'settled' override behaves like a declared settle without settled_at", () => { + expect(state({ status: "detached", exitCode: 2, settleOverride: "settled" }).phase).toBe("settled"); + expect(state({ toolType: "claude-chat", runtimeState: "idle", settleOverride: "settled" }).phase) + .toBe("settled"); + }); + + it("'settled' override is still only honored at rest", () => { + expect(state({ toolType: "claude-chat", runtimeState: "running", settleOverride: "settled" }).phase) + .toBe("running"); + }); + + it("deterministic attention still outranks every override", () => { + expect(state({ settleOverride: "settled", pendingInputItemId: "i-1", runtimeState: "idle" }).phase) + .toBe("needs_you"); + }); +}); + +describe("snooze is a visibility overlay, not a phase", () => { + const snoozedUntil = new Date(NOW + 60_000).toISOString(); + const snoozedAt = new Date(NOW - 60_000).toISOString(); + + it("never changes the canonical phase", () => { + // Snooze fields are deliberately absent from CanonicalSessionInputs; this + // asserts the contract holds for the row a snoozed session represents. + expect(state({ lastOutputPreview: "compiling..." }).phase).toBe("running"); + expect(isSessionSnoozed({ snoozedUntil, snoozedAt }, NOW)).toBe(true); + }); + + it("derives timer expiry from snoozed_until with no scheduler", () => { + expect(isSessionSnoozed({ snoozedUntil, snoozedAt }, NOW)).toBe(true); + expect(isSessionSnoozeExpired({ snoozedUntil, snoozedAt }, NOW)).toBe(false); + + // One millisecond past the deadline flips both, purely from the clock. + const at = Date.parse(snoozedUntil); + expect(isSessionSnoozed({ snoozedUntil, snoozedAt }, at)).toBe(false); + expect(isSessionSnoozeExpired({ snoozedUntil, snoozedAt }, at)).toBe(true); + expect(isSessionSnoozed({ snoozedUntil, snoozedAt }, at + 1)).toBe(false); + expect(isSessionSnoozeExpired({ snoozedUntil, snoozedAt }, at + 1)).toBe(true); + }); + + it("treats a missing or unparseable deadline as not snoozed", () => { + expect(isSessionSnoozed({}, NOW)).toBe(false); + expect(isSessionSnoozed({ snoozedUntil: null }, NOW)).toBe(false); + expect(isSessionSnoozed({ snoozedUntil: " " }, NOW)).toBe(false); + expect(isSessionSnoozed({ snoozedUntil: "not-a-date" }, NOW)).toBe(false); + expect(isSessionSnoozeExpired({ snoozedUntil: "not-a-date" }, NOW)).toBe(false); + }); +}); + +// Regression: an "Until I'm asked" snooze (~100 years) hid a needs-you row +// forever. Every early-wake trigger (`ade chat ask`, chat turn failure, chat +// turn complete) was chat-only, and a tracked CLI row's needs-input state is +// DERIVED (runtime "waiting-input" / preview heuristic) with no event to hook — +// so the filing rule, not an event, is what has to bring the row back. +describe("snooze filing yields to a raised hand (isSessionFiledAsSnoozed)", () => { + const snoozedUntil = new Date(NOW + 60_000).toISOString(); + const snoozedAt = new Date(NOW - 60_000).toISOString(); + const snoozed = { snoozedUntil, snoozedAt }; + + it("does NOT file a snoozed needs-you row as snoozed", () => { + expect(isSessionFiledAsSnoozed(snoozed, "needs_you", NOW)).toBe(false); + // An indefinite "until I'm asked" deadline is the case that used to hide + // a blocked CLI row for a century. + const indefinite = { snoozedUntil: new Date(NOW + 100 * 365 * 86_400_000).toISOString(), snoozedAt }; + expect(isSessionFiledAsSnoozed(indefinite, "needs_you", NOW)).toBe(false); + }); + + it("keeps isSessionSnoozed a RAW column read for the same row", () => { + // Chips, menus, and wake labels still want "is it snoozed?" regardless of + // where the list files it. + expect(isSessionSnoozed(snoozed, NOW)).toBe(true); + expect(canonicalSessionState({ + status: "running", + runtimeState: "waiting-input", + nowMs: NOW, + }).phase).toBe("needs_you"); + }); + + it("still files every calm phase as snoozed", () => { + for (const phase of ["running", "starting", "stale", "ready", "idle", "failed", "ended", "stopped", "settled"] as const) { + expect(isSessionFiledAsSnoozed(snoozed, phase, NOW)).toBe(true); + } + // No phase known (callers that only have the columns) files as snoozed too. + expect(isSessionFiledAsSnoozed(snoozed, null, NOW)).toBe(true); + expect(isSessionFiledAsSnoozed(snoozed, undefined, NOW)).toBe(true); + }); + + it("never files a row that is not snoozed at all", () => { + expect(isSessionFiledAsSnoozed({}, "running", NOW)).toBe(false); + expect(isSessionFiledAsSnoozed({}, "needs_you", NOW)).toBe(false); + // Lapsed deadline: expiry is derived, so the row is simply awake. + expect(isSessionFiledAsSnoozed( + { snoozedUntil: new Date(NOW - 1).toISOString(), snoozedAt }, + "running", + NOW, + )).toBe(false); + }); +}); + +describe("early wake: the newer-than-snoozed_at error comparison", () => { + const snoozedAt = "2026-07-06T11:00:00.000Z"; + const snoozedUntil = "2026-07-06T13:00:00.000Z"; + const session = { snoozedUntil, snoozedAt }; + + it("does NOT wake on the error the snooze was taken on top of", () => { + // This is the whole point: an older/equal error must not resurrect the row, + // otherwise snooze does nothing at all. + expect(isWakingSessionError(session, "2026-07-06T10:59:59.999Z")).toBe(false); + expect(isWakingSessionError(session, snoozedAt)).toBe(false); + }); + + it("wakes on an error strictly newer than snoozed_at", () => { + expect(isWakingSessionError(session, "2026-07-06T11:00:00.001Z")).toBe(true); + expect(isWakingSessionError(session, "2026-07-06T12:00:00.000Z")).toBe(true); + }); + + it("fails closed when there is no usable timestamp on either side", () => { + expect(isWakingSessionError(session, null)).toBe(false); + expect(isWakingSessionError(session, "not-a-date")).toBe(false); + expect(isWakingSessionError({ snoozedUntil }, "2026-07-06T12:00:00.000Z")).toBe(false); + expect(isWakingSessionError({ snoozedUntil, snoozedAt: "garbage" }, "2026-07-06T12:00:00.000Z")).toBe(false); + }); +}); + +describe("resolveSessionWakeReason", () => { + const snoozedAt = "2026-07-06T11:00:00.000Z"; + const active = { snoozedUntil: "2026-07-06T13:00:00.000Z", snoozedAt }; + const expired = { snoozedUntil: "2026-07-06T11:30:00.000Z", snoozedAt }; + + it("keeps an un-snoozed row awake-agnostic (never reports a wake)", () => { + expect(resolveSessionWakeReason({}, { hasPendingInput: true }, NOW)).toBeNull(); + expect(resolveSessionWakeReason({ snoozedAt }, { turnCompleted: true }, NOW)).toBeNull(); + }); + + it("stays asleep with no qualifying signal", () => { + expect(resolveSessionWakeReason(active, {}, NOW)).toBeNull(); + expect(resolveSessionWakeReason(active, { errorAt: snoozedAt }, NOW)).toBeNull(); + }); + + it("reports each hand-raise ahead of plain timer expiry", () => { + expect(resolveSessionWakeReason(active, { hasPendingInput: true }, NOW)).toBe("needs_you"); + expect(resolveSessionWakeReason(active, { errorAt: "2026-07-06T11:45:00.000Z" }, NOW)).toBe("error"); + expect(resolveSessionWakeReason(active, { turnCompleted: true }, NOW)).toBe("turn_complete"); + expect(resolveSessionWakeReason(expired, { turnCompleted: true }, NOW)).toBe("turn_complete"); + }); + + it("falls back to derived timer expiry", () => { + expect(resolveSessionWakeReason(expired, {}, NOW)).toBe("timer"); + expect(resolveSessionWakeReason(expired, { errorAt: snoozedAt }, NOW)).toBe("timer"); + }); +}); + +/** + * Regression: the settle-override value crosses four boundaries (IPC args, sync + * JSON, CLI flags, a SQLite text column) and was parsed four different ways. + * The registry/sync parsers were case-sensitive and threw; the service parser + * lowercased and returned null for ANYTHING unrecognized — so a typo silently + * CLEARED a keep-active pin instead of failing, and `"Settled"` was rejected + * over IPC while being accepted underneath it. + */ +describe("parseSessionSettleOverride: a typo must not silently clear a pin", () => { + it("distinguishes unrecognized input from an explicit clear", () => { + // undefined = "I don't recognize this" — throwing callers surface an error, + // and the persistence layer can no longer mistake it for "clear". + expect(parseSessionSettleOverride("activ")).toBeUndefined(); + expect(parseSessionSettleOverride("bogus")).toBeUndefined(); + expect(parseSessionSettleOverride(42)).toBeUndefined(); + expect(parseSessionSettleOverride({})).toBeUndefined(); + + // null = an explicit, intentional clear. + expect(parseSessionSettleOverride(null)).toBeNull(); + expect(parseSessionSettleOverride(undefined)).toBeNull(); + expect(parseSessionSettleOverride("")).toBeNull(); + expect(parseSessionSettleOverride("clear")).toBeNull(); + expect(parseSessionSettleOverride("none")).toBeNull(); + }); + + it("accepts the two real values regardless of case or padding, on every boundary", () => { + expect(parseSessionSettleOverride("settled")).toBe("settled"); + expect(parseSessionSettleOverride("active")).toBe("active"); + // Previously accepted by the service but rejected over IPC/sync. + expect(parseSessionSettleOverride("Settled")).toBe("settled"); + expect(parseSessionSettleOverride("ACTIVE")).toBe("active"); + expect(parseSessionSettleOverride(" active ")).toBe("active"); + // iOS sends this string because JSON null is not representable in its + // [String: Any] argument dictionary. + expect(parseSessionSettleOverride("Clear")).toBeNull(); + }); +}); diff --git a/apps/desktop/src/shared/sessionCanonicalState.ts b/apps/desktop/src/shared/sessionCanonicalState.ts index 5026cd9a8..bdf72f720 100644 --- a/apps/desktop/src/shared/sessionCanonicalState.ts +++ b/apps/desktop/src/shared/sessionCanonicalState.ts @@ -1,4 +1,10 @@ -import type { TerminalRuntimeState, TerminalSessionStatus, TerminalToolType } from "./types/sessions"; +import type { + SessionSettleOverride, + SessionWakeReason, + TerminalRuntimeState, + TerminalSessionStatus, + TerminalToolType, +} from "./types/sessions"; /** * The ONE vocabulary for "what state is this session in", shared by the Work @@ -68,6 +74,14 @@ export type CanonicalSessionInputs = { * PTY output), so no timestamp comparison happens here. */ settledAt?: string | null; + /** + * Tri-state settle override, consulted BEFORE the derived exit-0 rule. + * "settled" behaves like a declared settle; "active" is an explicit + * keep-active pin that suppresses settle (derived AND declared) so a clean + * PTY exit is not permanently pinned to the quiet tier. Cleared on real + * activity at the same write sites that clear `settledAt`. + */ + settleOverride?: SessionSettleOverride | null; /** * Escalated ask from `ade chat ask` (chat sessions; CLI sessions ride * runtimeState "waiting-input" instead). Cleared by the next user message. @@ -101,13 +115,15 @@ function isSilentPast(lastActivityAt: string | null | undefined, nowMs: number, * 1. deterministic needs-input — pendingInputItemId, runtimeState * "waiting-input", or an `ade chat ask` escalation (never outvoted by * anything below), - * 2. settled — explicitly declared (agent/user); presence wins over failure - * because a declared quiet is a human/agent judgment call. Cleared at the - * write site on any new activity, + * 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, * 3. stopped — user/system-disposed PTY, * 4. failed — non-zero exit / killed / chat turn death, * 5. clean exit — a PTY that exited 0 IS the process declaring it's done; - * auto-settles without any declaration, + * auto-settles without any declaration, UNLESS an "active" override pins + * it (rule 2's override check runs first), * 6. stale — status running but silent ≥ SESSION_STALE_AFTER_MS, * 7. running (incl. the preview heuristic's needs_you upgrade, LAST), * 8. resting states — ready (idle chat, quiet "your move"), idle, ended. @@ -122,12 +138,20 @@ export function canonicalSessionState(args: CanonicalSessionInputs): CanonicalSe return { phase: "needs_you", badge: BADGE_BY_KIND.needs_you }; } - // 2. Declared settle. No timestamp math: activity un-settles by clearing - // the column where the activity happens (user turn start / PTY output). - // Only honored AT REST — a settled chat woken by scheduled work shows green - // while the turn streams, then re-settles when it goes idle again (the - // settledAt column survives background wakes; only user activity clears it). - if (args.settledAt && (args.status !== "running" || args.runtimeState === "idle")) { + // 2. Declared settle (or a "settled" override). No timestamp math: activity + // un-settles by clearing the column where the activity happens (user turn + // start / PTY output). Only honored AT REST — a settled chat woken by + // scheduled work shows green while the turn streams, then re-settles when it + // goes idle again (the settledAt column survives background wakes; only user + // activity clears it). + // + // The tri-state override is consulted here, i.e. BEFORE the derived exit-0 + // rule below. "active" is an explicit keep-active pin: it beats derived + // settle (a clean exit) and a stale declared settle alike, so the row keeps a + // real lifecycle action instead of being stuck in the quiet tier forever. + const pinnedActive = args.settleOverride === "active"; + const atRest = args.status !== "running" || args.runtimeState === "idle"; + if (!pinnedActive && atRest && (args.settleOverride === "settled" || args.settledAt)) { return { phase: "settled", badge: null }; } @@ -168,7 +192,8 @@ export function canonicalSessionState(args: CanonicalSessionInputs): CanonicalSe } // 5. Clean exit auto-settle: exit 0 is the one deterministic "done" // declaration a process can make. Unknown exits stay "ended" (red). - if (args.exitCode === 0) { + // An "active" override vetoes it — that is the whole point of the pin. + if (args.exitCode === 0 && !pinnedActive) { return { phase: "settled", badge: null }; } return { phase: "ended", badge: null }; @@ -212,6 +237,127 @@ export function canonicalSessionState(args: CanonicalSessionInputs): CanonicalSe */ export type CanonicalStatusBucket = "running" | "awaiting-input" | "ended" | "settled"; +// --------------------------------------------------------------------------- +// Snooze — a synced VISIBILITY OVERLAY, deliberately NOT a lifecycle state +// --------------------------------------------------------------------------- + +/** + * Snooze never alters a session's canonical phase. `canonicalSessionState()` + * does not read these fields at all: a snoozed row is still running/failed/ + * needs_you exactly as before, snooze only decides where the UI files it. + * Keeping the two orthogonal is what lets desktop, `ade code`, and iOS derive + * "is this hidden right now" from the same two columns without re-deriving the + * lifecycle. + */ +export type SessionSnoozeState = { + /** ISO deadline; expiry is DERIVED by comparing to now — there is no timer. */ + snoozedUntil?: string | null; + /** ISO instant the snooze was taken; the early-wake comparison baseline. */ + snoozedAt?: string | null; +}; + +export type SessionWakeSignals = { + /** A pending approval / input request is showing on the row. */ + hasPendingInput?: boolean; + /** ISO timestamp of the session's most recent error, if any. */ + errorAt?: string | null; + /** A running turn just completed. */ + turnCompleted?: boolean; +}; + +function parseIsoMs(value: string | null | undefined): number | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + if (!trimmed) return null; + const ms = Date.parse(trimmed); + return Number.isFinite(ms) ? ms : null; +} + +/** + * The one derivation of "this row is currently snoozed", shared by desktop, + * CLI, and iOS. Timer expiry is derived here (`snoozedUntil <= now`), which is + * why no scheduler or background watchdog exists for snooze. + */ +export function isSessionSnoozed(session: SessionSnoozeState, nowMs: number = Date.now()): boolean { + const until = parseIsoMs(session.snoozedUntil); + if (until == null) return false; + return until > nowMs; +} + +/** + * The FILING rule: "should a list hide this row in its Snoozed group?". + * + * Snooze is a visibility overlay, and an overlay must yield to a session that + * is actually blocked on the user — otherwise the "Until I'm asked" window + * (~100 years) can bury a row whose hand IS raised. Only three events ever + * wrote an early wake (`ade chat ask`, chat turn failure, chat turn complete), + * all chat-only, so a tracked CLI session that hits a permission prompt has no + * event to fire: for it, `needs_you` is DERIVED (runtimeState "waiting-input" + * or the output-preview heuristic) and nothing persists a flag. Deriving the + * filing rule from the phase covers chat and CLI identically with no event. + * + * Deliberately separate from `isSessionSnoozed`, which stays the raw two-column + * read: chips, menus, and wake labels legitimately want "is this row snoozed?" + * independent of where the list files it. And `canonicalSessionState()` still + * never reads the snooze columns — this is a predicate over its output, not a + * new phase. + */ +export function isSessionFiledAsSnoozed( + session: SessionSnoozeState, + phase: CanonicalSessionPhase | null | undefined, + nowMs: number = Date.now(), +): boolean { + if (!isSessionSnoozed(session, nowMs)) return false; + return phase !== "needs_you"; +} + +/** A snooze that was taken but whose window has already elapsed. */ +export function isSessionSnoozeExpired(session: SessionSnoozeState, nowMs: number = Date.now()): boolean { + const until = parseIsoMs(session.snoozedUntil); + if (until == null) return false; + return until <= nowMs; +} + +/** + * The load-bearing early-wake comparison. + * + * An error only raises a hand when it is STRICTLY NEWER than `snoozedAt`. + * Without this, the very error the user snoozed on top of re-wakes the row + * immediately and snooze does nothing at all. An error stamped at exactly + * `snoozedAt` is the one being snoozed, so it does not wake either. + * + * If the row carries no parseable `snoozedAt` we fail CLOSED (no wake): an + * unknown baseline must not resurrect every historical error. + */ +export function isWakingSessionError( + session: SessionSnoozeState, + errorAt: string | null | undefined, +): boolean { + const errorMs = parseIsoMs(errorAt); + if (errorMs == null) return false; + const snoozedAtMs = parseIsoMs(session.snoozedAt); + if (snoozedAtMs == null) return false; + return errorMs > snoozedAtMs; +} + +/** + * Resolve why a snoozed row should wake right now, or null to stay asleep. + * Hand-raises are reported ahead of plain timer expiry because they carry the + * more useful "woke" marker copy. A row that is not snoozed at all never wakes. + */ +export function resolveSessionWakeReason( + session: SessionSnoozeState, + signals: SessionWakeSignals = {}, + nowMs: number = Date.now(), +): SessionWakeReason | null { + if (parseIsoMs(session.snoozedUntil) == null) return null; + if (signals.hasPendingInput === true) return "needs_you"; + if (isWakingSessionError(session, signals.errorAt)) return "error"; + if (signals.turnCompleted === true) return "turn_complete"; + if (isSessionSnoozeExpired(session, nowMs)) return "timer"; + return null; +} + export function canonicalStatusBucket(phase: CanonicalSessionPhase): CanonicalStatusBucket { switch (phase) { case "starting": diff --git a/apps/desktop/src/shared/syncMobileCompatibility.ts b/apps/desktop/src/shared/syncMobileCompatibility.ts index f7708419e..06b6ad46a 100644 --- a/apps/desktop/src/shared/syncMobileCompatibility.ts +++ b/apps/desktop/src/shared/syncMobileCompatibility.ts @@ -10,6 +10,16 @@ export const MOBILE_SYNC_OPTIONAL_REMOTE_COMMAND_ACTIONS = [ "cto.completeLinearMobileOAuth", "cto.setLinearToken", "cto.clearLinearToken", + // Session lifecycle. The phone gates its settle/snooze affordances on these + // appearing in hello_ok.features.commandRouting.actions, so they must be + // advertised — but they stay OPTIONAL: shipped builds predating the feature + // would otherwise be flipped into "limited" mode against a newer host. + "session.settleSessions", + "session.unsettleSessions", + "session.setSettleOverride", + "session.snoozeSession", + "session.wakeSession", + "session.clearWokeMarker", ] as const satisfies readonly SyncRemoteCommandAction[]; export const MOBILE_SYNC_REQUIRED_REMOTE_COMMAND_ACTIONS = [ diff --git a/apps/desktop/src/shared/types/lanes.ts b/apps/desktop/src/shared/types/lanes.ts index 252d0103b..eaee19b03 100644 --- a/apps/desktop/src/shared/types/lanes.ts +++ b/apps/desktop/src/shared/types/lanes.ts @@ -25,6 +25,48 @@ export type LaneStatus = { remoteBehind: number; /** true when the worktree is stuck in an interrupted rebase (rebase-merge / rebase-apply dir exists) */ rebaseInProgress: boolean; + /** + * Branch the worktree's HEAD actually points at, read live during the status + * refresh. Absent when status was not computed; `null` on a detached HEAD. + */ + headBranchRef?: string | null; +}; + +/** + * Set when the lane worktree's live HEAD no longer matches `lanes.branch_ref` + * (someone ran `git checkout` inside the worktree). Both refs are plain branch + * names, `refs/heads/` and `origin/` stripped. + */ +export type LaneBranchDrift = { + /** What ADE recorded for the lane and still advertises. */ + expectedBranchRef: string; + /** What the worktree is actually on right now. */ + headBranchRef: string; +}; + +export type LaneBranchDriftResolution = + /** Restore the worktree to `expectedBranchRef`; refuses if the tree is dirty. */ + | "switch-back" + /** Re-point the lane at `headBranchRef` and rename it to match. */ + | "keep-head"; + +export type ResolveLaneBranchDriftArgs = { + laneId: string; + resolution: LaneBranchDriftResolution; + /** Required for `keep-head`; guards against acting on a stale drift reading. */ + expectedHeadBranchRef?: string; + /** `switch-back` only: proceed even though sessions/processes are running. */ + acknowledgeActiveWork?: boolean; +}; + +export type ResolveLaneBranchDriftResult = { + lane: LaneSummary; + resolution: LaneBranchDriftResolution; + previousBranchRef: string; + branchRef: string; + /** Set by `keep-head` when the lane display name was re-pointed too. */ + previousLaneName: string | null; + laneName: string; }; export type DeviceMarker = { @@ -49,6 +91,8 @@ export type LaneSummary = { parentStatus: LaneStatus | null; isEditProtected: boolean; status: LaneStatus; + /** Non-null when the worktree HEAD has drifted off `branchRef`. */ + branchDrift?: LaneBranchDrift | null; color: string | null; icon: LaneIcon | null; tags: string[]; diff --git a/apps/desktop/src/shared/types/sessions.ts b/apps/desktop/src/shared/types/sessions.ts index 15735286e..b4217104c 100644 --- a/apps/desktop/src/shared/types/sessions.ts +++ b/apps/desktop/src/shared/types/sessions.ts @@ -69,6 +69,64 @@ export function isTrackedAgentCliToolType( export type TerminalRuntimeState = "running" | "waiting-input" | "idle" | "exited" | "killed"; +/** + * Tri-state settle override (terminal_sessions.settle_override). It is + * consulted by `canonicalSessionState()` BEFORE the derived "exit 0 means + * done" rule: + * null — no override; the derived rules decide, + * "settled" — behaves exactly like a declared settle, + * "active" — explicit keep-active pin that beats derived settle, so a + * clean PTY exit stops being permanently pinned to the quiet + * tier with no lifecycle action available. + * Cleared on real activity at the same write sites that clear `settled_at`. + */ +export type SessionSettleOverride = "settled" | "active"; + +/** + * Why a snoozed session woke. Persisted on `terminal_sessions.woke_reason` so + * the row can show a "woke" marker explaining itself until the user visits it. + * "timer" — the snooze window elapsed (DERIVED from snoozed_until), + * "needs_you" — a pending approval / input request appeared, + * "error" — a session error strictly newer than snoozed_at, + * "turn_complete" — a running turn finished, + * "manual" — the user woke it by hand. + */ +export const SESSION_WAKE_REASONS = [ + "timer", + "needs_you", + "error", + "turn_complete", + "manual", +] as const; + +export type SessionWakeReason = typeof SESSION_WAKE_REASONS[number]; + +/** + * The one parser for a settle-override value crossing a boundary — IPC args, + * sync-command JSON, CLI flags, a SQLite text column. + * + * Callers previously hand-rolled this four times with three different + * behaviors: the registry and sync parsers were case-sensitive and threw, while + * the service lowercased and silently returned null — so `"Settled"` was + * rejected over IPC but accepted underneath it, and a typo like `"activ"` + * silently CLEARED a pin instead of failing. Returning `undefined` for + * unrecognized input lets throwing call sites keep their own error message + * while non-throwing ones can no longer mistake garbage for "clear". + * + * `"clear"` / `"none"` / `""` / null are the explicit clear sentinels — iOS + * sends the string because JSON null is not representable in its arg dict. + */ +export function parseSessionSettleOverride( + value: unknown, +): SessionSettleOverride | null | undefined { + if (value == null) return null; + if (typeof value !== "string") return undefined; + const text = value.trim().toLowerCase(); + if (text === "" || text === "clear" || text === "none") return null; + if (text === "settled" || text === "active") return text; + return undefined; +} + export type TerminalResumeProvider = "claude" | "codex" | "cursor" | "droid" | "opencode"; export type TerminalResumeTargetKind = "session" | "thread"; @@ -158,6 +216,32 @@ export type TerminalSessionSummary = { attentionRequestedAt?: string | null; attentionMessage?: string | null; lastTurnFailedAt?: string | null; + /** + * Tri-state settle override (terminal_sessions.settle_override). Optional for + * migration tolerance; null/undefined both mean "no override". Unlike + * `settledAt` this is a *lifecycle* control that outranks the derived + * exit-0 auto-settle in `canonicalSessionState()`. + */ + settleOverride?: SessionSettleOverride | null; + /** + * Snooze is a synced VISIBILITY OVERLAY, never a lifecycle phase — it does + * not touch `canonicalSessionState()`, only where the UI files the row. + * `snoozedUntil` is the derived-expiry deadline (there is no scheduler: every + * surface compares it to now via `isSessionSnoozed`). `snoozedAt` is when the + * snooze was taken and is load-bearing for early wake — an error is only a + * hand-raise when it is strictly newer than `snoozedAt`, otherwise the error + * you snoozed on top of would instantly re-wake the row. Nullable-ISO + * semantics match `settledAt` / `lastActivityAt`. + */ + snoozedUntil?: string | null; + snoozedAt?: string | null; + /** + * "Woke" marker: set when a snooze is cleared, so the UI can explain why the + * row came back. `wokeAt` is nullable-ISO; the UI clears both on visit + * (`sessionService.clearWokeMarker`). + */ + wokeAt?: string | null; + wokeReason?: SessionWakeReason | null; resumeCommand: string | null; resumeMetadata?: TerminalResumeMetadata | null; chatIdleSinceAt?: string | null; diff --git a/apps/desktop/src/shared/types/sync.ts b/apps/desktop/src/shared/types/sync.ts index ff76c13b9..d22b698e4 100644 --- a/apps/desktop/src/shared/types/sync.ts +++ b/apps/desktop/src/shared/types/sync.ts @@ -1420,6 +1420,8 @@ export type SyncRemoteCommandAction = | "lanes.createFromUnstaged" | "lanes.importBranch" | "lanes.previewBranchSwitch" + | "lanes.getBranchDrift" + | "lanes.resolveBranchDrift" | "lanes.attach" | "lanes.listUnregisteredWorktrees" | "lanes.adoptAttached" @@ -1450,6 +1452,19 @@ export type SyncRemoteCommandAction = | "work.getSessionDelta" | "work.listSessions" | "work.updateSessionMeta" + // Session-lifecycle mutations live under their own `session.*` namespace — + // the same domain name the ADE action registry uses — because mobile and the + // web client feature-detect them independently of the `work.*` read surface. + | "session.settleSession" + | "session.unsettleSession" + | "session.settleSessions" + | "session.unsettleSessions" + | "session.snoozeSession" + | "session.snoozeSessions" + | "session.wakeSession" + | "session.wakeSessions" + | "session.setSettleOverride" + | "session.clearWokeMarker" | "work.runQuickCommand" | "work.startCliSession" | "work.resumeCliSession" diff --git a/apps/ios/ADE/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift index b25fefbd6..a16ceb4be 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -3426,6 +3426,19 @@ struct TerminalSessionSummary: Codable, Identifiable, Equatable { var attentionRequestedAt: String? = nil var attentionMessage: String? = nil var lastTurnFailedAt: String? = nil + /// Tri-state settle override (`"settled"` / `"active"` / nil), consulted at + /// the declared-settle tier BEFORE the derived exit-0 rule. Mirrors the + /// desktop `SessionSettleOverride`. + var settleOverride: String? = nil + /// Snooze visibility overlay. `snoozedUntil` is the derived-expiry deadline + /// (no scheduler exists — every surface compares it to now); `snoozedAt` is + /// the load-bearing baseline for the early-wake error comparison. + var snoozedUntil: String? = nil + var snoozedAt: String? = nil + /// "Woke" marker, set when a snooze is cleared so the row can explain why. + /// Cleared once the user visits the session. + var wokeAt: String? = nil + var wokeReason: String? = nil var exitCode: Int? var transcriptPath: String var headShaStart: String? @@ -3466,6 +3479,11 @@ struct TerminalSessionSummary: Codable, Identifiable, Equatable { && lhs.attentionRequestedAt == rhs.attentionRequestedAt && lhs.attentionMessage == rhs.attentionMessage && lhs.lastTurnFailedAt == rhs.lastTurnFailedAt + && lhs.settleOverride == rhs.settleOverride + && lhs.snoozedUntil == rhs.snoozedUntil + && lhs.snoozedAt == rhs.snoozedAt + && lhs.wokeAt == rhs.wokeAt + && lhs.wokeReason == rhs.wokeReason && lhs.exitCode == rhs.exitCode && lhs.transcriptPath == rhs.transcriptPath && lhs.headShaStart == rhs.headShaStart @@ -3510,6 +3528,11 @@ extension TerminalSessionSummary { case attentionRequestedAt case attentionMessage case lastTurnFailedAt + case settleOverride + case snoozedUntil + case snoozedAt + case wokeAt + case wokeReason case exitCode case transcriptPath case headShaStart @@ -3548,6 +3571,11 @@ extension TerminalSessionSummary { attentionRequestedAt = try container.decodeIfPresent(String.self, forKey: .attentionRequestedAt) attentionMessage = try container.decodeIfPresent(String.self, forKey: .attentionMessage) lastTurnFailedAt = try container.decodeIfPresent(String.self, forKey: .lastTurnFailedAt) + settleOverride = try container.decodeIfPresent(String.self, forKey: .settleOverride) + snoozedUntil = try container.decodeIfPresent(String.self, forKey: .snoozedUntil) + snoozedAt = try container.decodeIfPresent(String.self, forKey: .snoozedAt) + wokeAt = try container.decodeIfPresent(String.self, forKey: .wokeAt) + wokeReason = try container.decodeIfPresent(String.self, forKey: .wokeReason) exitCode = try container.decodeIfPresent(Int.self, forKey: .exitCode) transcriptPath = try container.decode(String.self, forKey: .transcriptPath) headShaStart = try container.decodeIfPresent(String.self, forKey: .headShaStart) diff --git a/apps/ios/ADE/Models/RemoteRosterModels.swift b/apps/ios/ADE/Models/RemoteRosterModels.swift index 794fedb73..0689e2ab4 100644 --- a/apps/ios/ADE/Models/RemoteRosterModels.swift +++ b/apps/ios/ADE/Models/RemoteRosterModels.swift @@ -47,6 +47,13 @@ struct RemoteRosterChat: Codable, Equatable, Identifiable { var attentionMessage: String? = nil var lastTurnFailedAt: String? = nil var exitCode: Int? = nil + // Settle override + snooze visibility overlay (ADE-125 lifecycle). Same + // additive contract: hosts that predate the fields simply omit them. + var settleOverride: String? = nil + var snoozedUntil: String? = nil + var snoozedAt: String? = nil + var wokeAt: String? = nil + var wokeReason: String? = nil } struct RemoteRosterLane: Codable, Equatable, Identifiable { @@ -460,6 +467,11 @@ extension RemoteRosterChat { attentionRequestedAt: attentionRequestedAt, attentionMessage: attentionMessage, lastTurnFailedAt: lastTurnFailedAt, + settleOverride: settleOverride, + snoozedUntil: snoozedUntil, + snoozedAt: snoozedAt, + wokeAt: wokeAt, + wokeReason: wokeReason, exitCode: exitCode, transcriptPath: "", headShaStart: nil, diff --git a/apps/ios/ADE/Resources/DatabaseBootstrap.sql b/apps/ios/ADE/Resources/DatabaseBootstrap.sql index 0a6026b9d..3590dd506 100644 --- a/apps/ios/ADE/Resources/DatabaseBootstrap.sql +++ b/apps/ios/ADE/Resources/DatabaseBootstrap.sql @@ -205,6 +205,11 @@ create table if not exists terminal_sessions ( resume_command text, resume_metadata_json text, archived_at text, + settle_override text, + snoozed_until text, + snoozed_at text, + woke_at text, + woke_reason text, chat_session_id text, owner_process_started_at text, foreign key(lane_id) references lanes(id) diff --git a/apps/ios/ADE/Services/Database.swift b/apps/ios/ADE/Services/Database.swift index 09d91a931..5b50a0edb 100644 --- a/apps/ios/ADE/Services/Database.swift +++ b/apps/ios/ADE/Services/Database.swift @@ -103,6 +103,11 @@ final class DatabaseService { let attentionRequestedAt: String? let attentionMessage: String? let lastTurnFailedAt: String? + let settleOverride: String? + let snoozedUntil: String? + let snoozedAt: String? + let wokeAt: String? + let wokeReason: String? } private struct ComputerUseArtifactRow { @@ -1116,8 +1121,9 @@ final class DatabaseService { id, lane_id, lane_name, pty_id, tracked, goal, tool_type, pinned, title, started_at, ended_at, exit_code, transcript_path, head_sha_start, head_sha_end, status, last_output_preview, last_output_at, summary, runtime_state, resume_command, resume_metadata_json, manually_named, chat_idle_since_at, chat_session_id, - pending_input_item_id, archived_at, settled_at, status_note, attention_requested_at, attention_message, last_turn_failed_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + pending_input_item_id, archived_at, settled_at, status_note, attention_requested_at, attention_message, last_turn_failed_at, + settle_override, snoozed_until, snoozed_at, woke_at, woke_reason + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) on conflict(id) do update set lane_id = excluded.lane_id, lane_name = excluded.lane_name, @@ -1149,7 +1155,12 @@ final class DatabaseService { status_note = excluded.status_note, attention_requested_at = excluded.attention_requested_at, attention_message = excluded.attention_message, - last_turn_failed_at = excluded.last_turn_failed_at + last_turn_failed_at = excluded.last_turn_failed_at, + settle_override = excluded.settle_override, + snoozed_until = excluded.snoozed_until, + snoozed_at = excluded.snoozed_at, + woke_at = excluded.woke_at, + woke_reason = excluded.woke_reason """) { statement in try bindText(session.id, to: statement, index: 1) try bindText(session.laneId, to: statement, index: 2) @@ -1259,6 +1270,31 @@ final class DatabaseService { } else { sqlite3_bind_null(statement, 32) } + if let settleOverride = session.settleOverride { + try bindText(settleOverride, to: statement, index: 33) + } else { + sqlite3_bind_null(statement, 33) + } + if let snoozedUntil = session.snoozedUntil { + try bindText(snoozedUntil, to: statement, index: 34) + } else { + sqlite3_bind_null(statement, 34) + } + if let snoozedAt = session.snoozedAt { + try bindText(snoozedAt, to: statement, index: 35) + } else { + sqlite3_bind_null(statement, 35) + } + if let wokeAt = session.wokeAt { + try bindText(wokeAt, to: statement, index: 36) + } else { + sqlite3_bind_null(statement, 36) + } + if let wokeReason = session.wokeReason { + try bindText(wokeReason, to: statement, index: 37) + } else { + sqlite3_bind_null(statement, 37) + } } } @@ -1844,7 +1880,8 @@ final class DatabaseService { s.title, s.status, s.started_at, s.ended_at, s.exit_code, s.transcript_path, s.head_sha_start, s.head_sha_end, s.last_output_preview, s.summary, s.runtime_state, s.resume_command, s.resume_metadata_json, s.chat_idle_since_at, s.chat_session_id, s.pending_input_item_id, s.archived_at, - s.settled_at, s.status_note, s.attention_requested_at, s.attention_message, s.last_turn_failed_at + s.settled_at, s.status_note, s.attention_requested_at, s.attention_message, s.last_turn_failed_at, + s.settle_override, s.snoozed_until, s.snoozed_at, s.woke_at, s.woke_reason from terminal_sessions s left join lanes l on l.id = s.lane_id where l.project_id = ? @@ -1868,7 +1905,8 @@ final class DatabaseService { s.title, s.status, s.started_at, s.ended_at, s.exit_code, s.transcript_path, s.head_sha_start, s.head_sha_end, s.last_output_preview, s.summary, s.runtime_state, s.resume_command, s.resume_metadata_json, s.chat_idle_since_at, s.chat_session_id, s.pending_input_item_id, s.archived_at, - s.settled_at, s.status_note, s.attention_requested_at, s.attention_message, s.last_turn_failed_at + s.settled_at, s.status_note, s.attention_requested_at, s.attention_message, s.last_turn_failed_at, + s.settle_override, s.snoozed_until, s.snoozed_at, s.woke_at, s.woke_reason from terminal_sessions s left join lanes l on l.id = s.lane_id where s.id = ? and (l.project_id = ? or l.id is null) @@ -1914,7 +1952,12 @@ final class DatabaseService { statusNote: stringValue(statement, index: 27), attentionRequestedAt: stringValue(statement, index: 28), attentionMessage: stringValue(statement, index: 29), - lastTurnFailedAt: stringValue(statement, index: 30) + lastTurnFailedAt: stringValue(statement, index: 30), + settleOverride: stringValue(statement, index: 31), + snoozedUntil: stringValue(statement, index: 32), + snoozedAt: stringValue(statement, index: 33), + wokeAt: stringValue(statement, index: 34), + wokeReason: stringValue(statement, index: 35) ) } @@ -1939,6 +1982,11 @@ final class DatabaseService { attentionRequestedAt: row.attentionRequestedAt, attentionMessage: row.attentionMessage, lastTurnFailedAt: row.lastTurnFailedAt, + settleOverride: row.settleOverride, + snoozedUntil: row.snoozedUntil, + snoozedAt: row.snoozedAt, + wokeAt: row.wokeAt, + wokeReason: row.wokeReason, exitCode: row.exitCode, transcriptPath: row.transcriptPath, headShaStart: row.headShaStart, @@ -2022,6 +2070,81 @@ final class DatabaseService { notifyDidChange(touchedTables: ["terminal_sessions"]) } + /// Optimistic local write for the ADE-125 session lifecycle columns + /// (settle / settle override / snooze overlay / woke marker). The phone is a + /// controller and never owns these values — the host's remote command is the + /// source of truth and reconciles over sync — but writing locally first keeps + /// the row from flickering back for a round trip. + /// + /// Each parameter is a two-level optional so "leave alone" and "clear" are + /// distinguishable: `nil` skips the column, `.some(nil)` sets it to NULL, + /// `.some(value)` writes the value. + func updateSessionLifecycle( + sessionId: String, + settledAt: String?? = nil, + settleOverride: String?? = nil, + snoozedUntil: String?? = nil, + snoozedAt: String?? = nil, + wokeAt: String?? = nil, + wokeReason: String?? = nil + ) throws { + try withLock { + try updateSessionLifecycleLocked( + sessionId: sessionId, + settledAt: settledAt, + settleOverride: settleOverride, + snoozedUntil: snoozedUntil, + snoozedAt: snoozedAt, + wokeAt: wokeAt, + wokeReason: wokeReason + ) + } + } + + private func updateSessionLifecycleLocked( + sessionId: String, + settledAt: String?? = nil, + settleOverride: String?? = nil, + snoozedUntil: String?? = nil, + snoozedAt: String?? = nil, + wokeAt: String?? = nil, + wokeReason: String?? = nil + ) throws { + guard db != nil else { return } + let trimmedSessionId = sessionId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedSessionId.isEmpty else { return } + + var assignments: [String] = [] + var values: [String?] = [] + + func assign(_ column: String, _ update: String??) { + guard let update else { return } + assignments.append("\(column) = ?") + values.append(update) + } + + assign("settled_at", settledAt) + assign("settle_override", settleOverride) + assign("snoozed_until", snoozedUntil) + assign("snoozed_at", snoozedAt) + assign("woke_at", wokeAt) + assign("woke_reason", wokeReason) + + guard !assignments.isEmpty else { return } + _ = try execute("update terminal_sessions set \(assignments.joined(separator: ", ")) where id = ?") { statement in + for (offset, value) in values.enumerated() { + let index = Int32(offset + 1) + if let value { + try self.bindText(value, to: statement, index: index) + } else { + sqlite3_bind_null(statement, index) + } + } + try self.bindText(trimmedSessionId, to: statement, index: Int32(values.count + 1)) + } + notifyDidChange(touchedTables: ["terminal_sessions"]) + } + func fetchComputerUseArtifacts(ownerKind: String, ownerId: String) -> [ComputerUseArtifactSummary] { withLock { fetchComputerUseArtifactsLocked(ownerKind: ownerKind, ownerId: ownerId) } } @@ -2789,6 +2912,36 @@ final class DatabaseService { columnName: "last_turn_failed_at", definition: "text" ) + // Settle override + snooze visibility overlay. Must mirror the desktop + // schema (kvDb.ts) exactly: `terminal_sessions` replicates through + // cr-sqlite, and a column the phone does not know about surfaces as a + // changeset-apply error here rather than failing on desktop. All nullable + // with no unique index — `crsql_as_crr` rejects non-PK unique indices. + try ensureColumn( + tableName: "terminal_sessions", + columnName: "settle_override", + definition: "text" + ) + try ensureColumn( + tableName: "terminal_sessions", + columnName: "snoozed_until", + definition: "text" + ) + try ensureColumn( + tableName: "terminal_sessions", + columnName: "snoozed_at", + definition: "text" + ) + try ensureColumn( + tableName: "terminal_sessions", + columnName: "woke_at", + definition: "text" + ) + try ensureColumn( + tableName: "terminal_sessions", + columnName: "woke_reason", + definition: "text" + ) try exec(""" create table if not exists lane_list_snapshots ( lane_id text primary key, diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index ade5e17d0..9755b45d4 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -6925,6 +6925,172 @@ final class SyncService: ObservableObject { } } + // MARK: - Session lifecycle (settle / settle override / snooze / woke marker) + // + // The phone is a controller and never runs agents, so every lifecycle change + // is a host command — `session.*` in the ADE action registry — not a local + // write we then hope replicates. We still write the columns locally first so + // the row doesn't flicker for a round trip, and roll that write back if the + // host rejects the command. + + /// Whether this host advertises the ADE-125 lifecycle actions at all. Older + /// desktop builds simply do not have them; the UI hides the affordances + /// instead of offering a control that always fails. + var supportsSessionLifecycleActions: Bool { + supportsRemoteAction("session.settleSessions") + } + + var supportsSessionSnoozeActions: Bool { + supportsRemoteAction("session.snoozeSession") + } + + private func sessionLifecycleUnsupportedError(_ action: String) -> NSError { + NSError(domain: "ADE", code: 27, userInfo: [ + NSLocalizedDescriptionKey: + "This machine's ADE is too old for \(action). Update the desktop app and try again.", + ]) + } + + /// Optimistic local write + host command, with rollback on failure. + private func sendSessionLifecycleCommand( + sessionId: String, + action: String, + args: [String: Any], + settledAt: String?? = nil, + settleOverride: String?? = nil, + snoozedUntil: String?? = nil, + snoozedAt: String?? = nil, + wokeAt: String?? = nil, + wokeReason: String?? = nil + ) async throws { + let trimmed = sessionId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + guard supportsRemoteAction(action) else { + throw sessionLifecycleUnsupportedError(action) + } + + let previous = database.fetchSession(id: trimmed) + try? database.updateSessionLifecycle( + sessionId: trimmed, + settledAt: settledAt, + settleOverride: settleOverride, + snoozedUntil: snoozedUntil, + snoozedAt: snoozedAt, + wokeAt: wokeAt, + wokeReason: wokeReason + ) + + let scope = chatCommandScope(for: trimmed) + do { + _ = try await sendCommand( + action: action, + args: args, + targetProjectId: scope.projectId, + targetProjectRootPath: scope.rootPath + ) + } catch { + // Restore the exact prior columns — a half-applied lifecycle is worse + // than none, because settle and snooze both decide where a row files. + if let previous { + try? database.updateSessionLifecycle( + sessionId: trimmed, + settledAt: .some(previous.settledAt), + settleOverride: .some(previous.settleOverride), + snoozedUntil: .some(previous.snoozedUntil), + snoozedAt: .some(previous.snoozedAt), + wokeAt: .some(previous.wokeAt), + wokeReason: .some(previous.wokeReason) + ) + } + throw error + } + } + + /// Declared settle. Stamps `settled_at` locally to match what the host writes. + func settleSession(sessionId: String) async throws { + try await sendSessionLifecycleCommand( + sessionId: sessionId, + action: "session.settleSessions", + args: ["sessionIds": [sessionId]], + settledAt: .some(iso8601WithFractionalSecondsFormatter.string(from: Date())), + settleOverride: .some(nil) + ) + } + + /// Clear a declared settle. + /// + /// The host clears a `"settled"` override ONLY — see `sessionService`'s + /// `settle_override = case when settle_override = 'settled' then null else + /// settle_override end`. An `"active"` keep-alive pin deliberately SURVIVES + /// unsettle. So this must NOT write `settle_override` at all: the phone + /// cannot know which of the two branches the machine will take, and + /// `terminal_sessions` is a CRR table whose local writes are captured by the + /// update trigger and pushed upstream in `changeset_batch`. Claiming a clear + /// here would replicate a null back over the pin the host just preserved. + /// Leave the column alone and let hydration deliver the machine's answer — + /// this mirrors the web overlay's `UNSETTLE_PATCH`, which is `settledAt` only. + func unsettleSession(sessionId: String) async throws { + try await sendSessionLifecycleCommand( + sessionId: sessionId, + action: "session.unsettleSessions", + args: ["sessionIds": [sessionId]], + settledAt: .some(nil), + settleOverride: nil + ) + } + + /// Set (or clear, with `nil`) the tri-state settle override. `"active"` is the + /// "keep active" pin that suppresses settle including the exit-0 auto-settle. + func setSessionSettleOverride(sessionId: String, override: SessionSettleOverride?) async throws { + try await sendSessionLifecycleCommand( + sessionId: sessionId, + action: "session.setSettleOverride", + // The host reads "clear" as null; sending a JSON null through the + // `[String: Any]` arg dictionary is not representable here. + args: ["sessionId": sessionId, "override": override?.rawValue ?? "clear"], + settleOverride: .some(override?.rawValue) + ) + } + + /// Snooze until `deadline`. Stamps `snoozed_at` (the early-wake baseline) and + /// clears any stale woke marker, exactly like the host's `snoozeSession`. + func snoozeSession(sessionId: String, until deadline: Date) async throws { + let untilIso = iso8601WithFractionalSecondsFormatter.string(from: deadline) + try await sendSessionLifecycleCommand( + sessionId: sessionId, + action: "session.snoozeSession", + args: ["sessionId": sessionId, "untilIso": untilIso], + snoozedUntil: .some(untilIso), + snoozedAt: .some(iso8601WithFractionalSecondsFormatter.string(from: Date())), + wokeAt: .some(nil), + wokeReason: .some(nil) + ) + } + + /// Wake a snoozed session now, recording why. + func wakeSession(sessionId: String, reason: SessionWakeReason = .manual) async throws { + try await sendSessionLifecycleCommand( + sessionId: sessionId, + action: "session.wakeSession", + args: ["sessionId": sessionId, "reason": reason.rawValue], + snoozedUntil: .some(nil), + snoozedAt: .some(nil), + wokeAt: .some(iso8601WithFractionalSecondsFormatter.string(from: Date())), + wokeReason: .some(reason.rawValue) + ) + } + + /// Drop the "woke" marker once the user has visited the row. + func clearSessionWokeMarker(sessionId: String) async throws { + try await sendSessionLifecycleCommand( + sessionId: sessionId, + action: "session.clearWokeMarker", + args: ["sessionId": sessionId], + wokeAt: .some(nil), + wokeReason: .some(nil) + ) + } + func fetchPullRequests() async throws -> [PrSummary] { database.fetchPullRequests() } diff --git a/apps/ios/ADE/Views/Work/WorkRootComponents.swift b/apps/ios/ADE/Views/Work/WorkRootComponents.swift index 18286b941..9561b0883 100644 --- a/apps/ios/ADE/Views/Work/WorkRootComponents.swift +++ b/apps/ios/ADE/Views/Work/WorkRootComponents.swift @@ -521,6 +521,17 @@ struct WorkSessionListRow: View { /// Opens the row's linked PR (mapped or GitHub-by-branch) in the PRs tab. /// Defaults to a no-op so preview harnesses don't have to wire it. var onOpenPullRequest: (TerminalSessionSummary, LanePrTag) -> Void = { _, _ in } + // ADE-125 session lifecycle. Defaults are no-ops (and the affordances are + // hidden) so preview harnesses and older hosts don't have to wire them. + /// The host advertises settle / unsettle / settle-override. + var lifecycleAvailable: Bool = false + /// The host advertises snooze / wake. + var snoozeAvailable: Bool = false + var onSettle: (TerminalSessionSummary) -> Void = { _ in } + var onUnsettle: (TerminalSessionSummary) -> Void = { _ in } + var onKeepActive: (TerminalSessionSummary) -> Void = { _ in } + var onSnooze: (TerminalSessionSummary, WorkSnoozeDuration) -> Void = { _, _ in } + var onWake: (TerminalSessionSummary) -> Void = { _ in } /// Observed so the muted glyph and menu label re-render the moment a mute /// flips anywhere (this menu, the open chat's header menu, settings). @@ -532,6 +543,39 @@ struct WorkSessionListRow: View { && pushNotificationService.prefs.mutedSessionIds.contains(session.id) } + private var canonicalPhase: CanonicalSessionPhase { + workCanonicalSessionState(session: session, summary: chatSummary).phase + } + + private var isSnoozed: Bool { + session.isSnoozed() + } + + /// An escalated ask outranks settle: a row blocked on the user is not "done", + /// and settling it would bury the very thing asking for attention. Resolve + /// the ask first. Already-settled rows offer Unsettle instead. + private var canSettle: Bool { + lifecycleAvailable && canonicalPhase != .needsYou && canonicalPhase != .settled + } + + private var canUnsettle: Bool { + lifecycleAvailable && canonicalPhase == .settled + } + + /// "Keep active" only means something once a row would otherwise read settled + /// — either declared or auto-settled by a clean exit. + private var canKeepActive: Bool { + lifecycleAvailable + && session.resolvedSettleOverride != .active + && canonicalPhase == .settled + } + + private var snoozeOptions: [(duration: WorkSnoozeDuration, deadline: Date)] { + WorkSnoozeDuration.allCases.compactMap { duration in + duration.deadline().map { (duration, $0) } + } + } + var body: some View { let rowStatus = normalizedWorkChatSessionStatus(session: session, summary: chatSummary) Button { @@ -583,6 +627,43 @@ struct WorkSessionListRow: View { } .tint(ADEColor.danger) } + // The two lifecycle moves people make constantly get a swipe; everything + // else lives in the long-press menu. iOS has no hover, so there is no + // desktop-style always-visible moon button. + if canSettle { + Button { + onSettle(session) + } label: { + Label("Settle", systemImage: "checkmark.circle") + } + .tint(ADEColor.accent) + } else if canUnsettle { + Button { + onUnsettle(session) + } label: { + Label("Unsettle", systemImage: "arrow.uturn.backward.circle") + } + .tint(ADEColor.accent) + } + if snoozeAvailable { + if isSnoozed { + Button { + onWake(session) + } label: { + Label("Wake", systemImage: "sun.max") + } + .tint(ADEColor.warning) + } else { + // The swipe is the fast path — one hour. Every other window is a + // long-press away, so the swipe never opens a picker mid-gesture. + Button { + onSnooze(session, .oneHour) + } label: { + Label("Snooze 1h", systemImage: "moon.zzz") + } + .tint(ADEColor.info) + } + } } .contextMenu { Button { @@ -609,6 +690,7 @@ struct WorkSessionListRow: View { Label("Delete chat", systemImage: "trash") } } + lifecycleMenuSection Divider() Button { onGoToLane(session) @@ -667,6 +749,58 @@ struct WorkSessionListRow: View { private var shouldShowDeleteAction: Bool { isChatSession(session) } + + /// Full lifecycle set: settle / unsettle, a snooze submenu of durations, + /// wake now, and the keep-active pin. Durations use a native nested `Menu`, + /// never a popover — this is a long-press context menu on a phone. + @ViewBuilder + private var lifecycleMenuSection: some View { + if lifecycleAvailable || snoozeAvailable { + Divider() + if canSettle { + Button { + onSettle(session) + } label: { + Label("Settle", systemImage: "checkmark.circle") + } + } + if canUnsettle { + Button { + onUnsettle(session) + } label: { + Label("Unsettle", systemImage: "arrow.uturn.backward.circle") + } + } + if canKeepActive { + Button { + onKeepActive(session) + } label: { + Label("Keep active", systemImage: "pin.circle") + } + } + if snoozeAvailable { + if isSnoozed { + Button { + onWake(session) + } label: { + Label("Wake now", systemImage: "sun.max") + } + } else { + Menu { + ForEach(snoozeOptions, id: \.duration.id) { option in + Button { + onSnooze(session, option.duration) + } label: { + Label(option.duration.label, systemImage: option.duration.symbol) + } + } + } label: { + Label("Snooze", systemImage: "moon.zzz") + } + } + } + } + } } struct WorkChildShellSection: View { @@ -895,6 +1029,10 @@ private struct WorkSessionRowRenderSignature: Equatable { let attentionRequestedAt: String? let attentionMessage: String? let lastTurnFailedAt: String? + let settleOverride: String? + let snoozedUntil: String? + let wokeAt: String? + let wokeReason: String? // Deterministic inputs to the attention capsule, so a badge transition // (needs_you / failed) re-renders even when the display status is unchanged. let runtimeState: String @@ -942,6 +1080,10 @@ private struct WorkSessionRowRenderSignature: Equatable { self.attentionRequestedAt = session.attentionRequestedAt self.attentionMessage = session.attentionMessage self.lastTurnFailedAt = session.lastTurnFailedAt + self.settleOverride = session.settleOverride + self.snoozedUntil = session.snoozedUntil + self.wokeAt = session.wokeAt + self.wokeReason = session.wokeReason self.runtimeState = session.runtimeState self.pendingInputItemId = session.pendingInputItemId self.exitCode = session.exitCode @@ -1170,6 +1312,12 @@ struct WorkSessionRow: View, Equatable { Spacer(minLength: 0) + if let wakeLabel = snoozeWakeLabel { + WorkSessionLifecycleTag(symbol: "moon.zzz", text: wakeLabel, tint: ADEColor.info) + } else if let woke = session.wokeMarker() { + WorkSessionLifecycleTag(symbol: "sun.max", text: woke.wokeLabel, tint: ADEColor.warning) + } + if isPendingSyncCreation { HStack(spacing: 4) { Image(systemName: "clock.arrow.circlepath") @@ -1227,6 +1375,13 @@ struct WorkSessionRow: View, Equatable { canonicalState.phase == .settled } + /// Non-nil only while the snooze window is still open. Snooze is a visibility + /// overlay, so it changes what the row says, never its canonical phase. + var snoozeWakeLabel: String? { + guard session.isSnoozed() else { return nil } + return workSnoozeWakeLabel(session.snoozedUntil) + } + var rowPreviewSource: String? { workSessionRowPreviewSource( session: session, @@ -1256,6 +1411,11 @@ struct WorkSessionRow: View, Equatable { if isSettled { parts.append("settled") } + if let wakeLabel = snoozeWakeLabel { + parts.append("snoozed \(wakeLabel.lowercased())") + } else if let woke = session.wokeMarker() { + parts.append("woke, \(woke.wokeLabel.lowercased())") + } return parts.joined(separator: ", ") } } diff --git a/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift b/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift index dce52dd08..9412879d6 100644 --- a/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift +++ b/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift @@ -360,6 +360,73 @@ extension WorkRootScreen { } } + // MARK: - Session lifecycle (ADE-125) + // + // Every one of these is a host command: the phone is a controller and never + // runs agents, so it never owns a lifecycle column. `SyncService` writes the + // column locally first (so the row doesn't flicker), rolls that back if the + // host rejects, and `reload()` reconciles against the replicated truth. + + private func runSessionLifecycle(_ work: @escaping () async throws -> Void) { + Task { + do { + try await work() + await reload() + } catch { + ADEHaptics.error() + errorMessage = error.localizedDescription + await reload() + } + } + } + + func settleSession(_ session: TerminalSessionSummary) { + runSessionLifecycle { [syncService] in + try await syncService.settleSession(sessionId: session.id) + } + } + + func unsettleSession(_ session: TerminalSessionSummary) { + runSessionLifecycle { [syncService] in + try await syncService.unsettleSession(sessionId: session.id) + } + } + + /// The explicit keep-active pin. Suppresses settle — derived (a clean exit) + /// and declared alike — so a finished PTY keeps a real lifecycle action + /// instead of being stuck in the quiet tier forever. + func keepSessionActive(_ session: TerminalSessionSummary) { + runSessionLifecycle { [syncService] in + try await syncService.setSessionSettleOverride(sessionId: session.id, override: .active) + } + } + + func snoozeSession(_ session: TerminalSessionSummary, duration: WorkSnoozeDuration) { + guard let deadline = duration.deadline() else { return } + runSessionLifecycle { [syncService] in + try await syncService.snoozeSession(sessionId: session.id, until: deadline) + } + } + + func wakeSession(_ session: TerminalSessionSummary) { + runSessionLifecycle { [syncService] in + try await syncService.wakeSession(sessionId: session.id, reason: .manual) + } + } + + /// The woke marker exists to explain why a snoozed row came back. Visiting + /// the row is the explanation being read, so drop it then — quietly, since a + /// failure here must never block navigation. + func clearWokeMarkerOnVisit(_ session: TerminalSessionSummary) { + // Only a PERSISTED marker needs clearing. A purely derived one (the snooze + // simply lapsed, so the host never wrote a marker) has nothing to clear. + let wokeAt = session.wokeAt?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !wokeAt.isEmpty else { return } + Task { [syncService] in + try? await syncService.clearSessionWokeMarker(sessionId: session.id) + } + } + func beginRename(_ session: TerminalSessionSummary) { renameTarget = session renameText = session.title @@ -469,6 +536,7 @@ extension WorkRootScreen { guard !workIsPendingChatCreationSession(session) else { return } guard !navigationMutationPending else { return } navigationMutationPending = true + clearWokeMarkerOnVisit(session) selectedSessionTransitionId = session.id Task { @MainActor in await Task.yield() diff --git a/apps/ios/ADE/Views/Work/WorkRootScreen.swift b/apps/ios/ADE/Views/Work/WorkRootScreen.swift index fa41e2dd0..73d9e2b50 100644 --- a/apps/ios/ADE/Views/Work/WorkRootScreen.swift +++ b/apps/ios/ADE/Views/Work/WorkRootScreen.swift @@ -748,7 +748,14 @@ struct WorkRootScreen: View { onCopyId: copySessionId, onCopyDeepLink: copySessionDeepLink, onGoToLane: goToLane, - onOpenPullRequest: openPullRequest + onOpenPullRequest: openPullRequest, + lifecycleAvailable: syncService.supportsSessionLifecycleActions, + snoozeAvailable: syncService.supportsSessionSnoozeActions, + onSettle: settleSession, + onUnsettle: unsettleSession, + onKeepActive: keepSessionActive, + onSnooze: snoozeSession, + onWake: wakeSession ) .id(session.id) .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) @@ -789,7 +796,14 @@ struct WorkRootScreen: View { onCopyId: copySessionId, onCopyDeepLink: copySessionDeepLink, onGoToLane: goToLane, - onOpenPullRequest: openPullRequest + onOpenPullRequest: openPullRequest, + lifecycleAvailable: syncService.supportsSessionLifecycleActions, + snoozeAvailable: syncService.supportsSessionSnoozeActions, + onSettle: settleSession, + onUnsettle: unsettleSession, + onKeepActive: keepSessionActive, + onSnooze: snoozeSession, + onWake: wakeSession ) .id(child.id) } diff --git a/apps/ios/ADE/Views/Work/WorkSessionCanonicalState.swift b/apps/ios/ADE/Views/Work/WorkSessionCanonicalState.swift index 4d75d113a..4feaf62f9 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionCanonicalState.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionCanonicalState.swift @@ -78,16 +78,40 @@ func isWorkChatToolType(_ toolType: String?) -> Bool { return raw.hasSuffix("-chat") } +/// The tri-state settle override persisted on `terminal_sessions.settle_override`. +/// Mirrors the desktop `SessionSettleOverride`. Consulted at the declared-settle +/// tier, i.e. BEFORE the derived exit-0 rule. +enum SessionSettleOverride: String, Equatable { + /// Behaves exactly like a declared settle, without a `settled_at` stamp. + case settled + /// Explicit keep-active pin: suppresses settle, derived AND declared, so a + /// clean PTY exit is not permanently stuck in the quiet tier. + case active + + /// Tolerant parse of the persisted column — unknown/blank values mean "no + /// override" rather than crashing or inventing a state. + init?(persisted: String?) { + let raw = persisted?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" + guard !raw.isEmpty, let parsed = SessionSettleOverride(rawValue: raw) else { return nil } + self = parsed + } +} + /// Canonical precedence (highest first), identical to the desktop module: /// 1. deterministic needs-input — pending item, "waiting-input" runtime, or /// an explicit attention request (never outvoted by anything below), -/// 2. settled — explicitly declared, -/// 3. ended branch — stopped, failed, chat ready/ended, clean-exit settled, +/// 2. settled — explicitly declared, or forced by a "settled" override; an +/// "active" override suppresses this tier entirely, +/// 3. ended branch — stopped, failed, chat ready/ended, clean-exit settled +/// (which an "active" override also vetoes), /// 4. running-chat turn failure, /// 5. stale — status running but silent ≥ `sessionStaleAfterSeconds`, /// 6. idle — ready(chat)/idle, /// 7. preview heuristic's needs_you upgrade, consulted LAST, /// 8. running. +/// +/// Snooze is deliberately absent: it is a visibility overlay and never changes +/// the phase. See `isSessionSnoozed(_:now:)`. func workCanonicalSessionState( status: String, runtimeState: String? = nil, @@ -97,6 +121,7 @@ func workCanonicalSessionState( lastActivityAt: String? = nil, exitCode: Int? = nil, settledAt: String? = nil, + settleOverride: String? = nil, attentionRequestedAt: String? = nil, lastTurnFailedAt: String? = nil, now: Date = Date(), @@ -110,18 +135,27 @@ func workCanonicalSessionState( let attentionRequested = attentionRequestedAt?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let settled = settledAt?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let lastTurnFailed = lastTurnFailedAt?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let override = SessionSettleOverride(persisted: settleOverride) // 1. Deterministic attention beats everything — including the failure and // stale checks below (an agent explicitly asking is actionable regardless). + // An escalated ask outranks BOTH override values. if !pending.isEmpty || runtimeLower == "waiting-input" || !attentionRequested.isEmpty { return CanonicalSessionState(phase: .needsYou, badge: badgeByKind[.needsYou]) } - // 2. Declared settle — honored only AT REST, mirroring desktop: a settled - // chat woken by scheduled work shows green while the turn streams, then - // re-settles at idle (settledAt survives background wakes; only user - // activity clears it). - if !settled.isEmpty && (statusLower != "running" || runtimeLower == "idle") { + // 2. Declared settle (or a "settled" override) — honored only AT REST, + // mirroring desktop: a settled chat woken by scheduled work shows green while + // the turn streams, then re-settles at idle (settledAt survives background + // wakes; only user activity clears it). + // + // The tri-state override is consulted HERE, i.e. before the derived exit-0 + // rule below. "active" is an explicit keep-active pin: it beats derived + // settle (a clean exit) and a stale declared settle alike, so the row keeps a + // real lifecycle action instead of being stuck in the quiet tier forever. + let pinnedActive = override == .active + let atRest = statusLower != "running" || runtimeLower == "idle" + if !pinnedActive && atRest && (override == .settled || !settled.isEmpty) { return CanonicalSessionState(phase: .settled, badge: nil) } @@ -158,8 +192,9 @@ func workCanonicalSessionState( return CanonicalSessionState(phase: .ready, badge: nil) } - // A non-chat clean exit is the process declaring the work done. - if exitCode == 0 { + // A non-chat clean exit is the process declaring the work done. An "active" + // override vetoes it — that is the whole point of the pin. + if exitCode == 0 && !pinnedActive { return CanonicalSessionState(phase: .settled, badge: nil) } return CanonicalSessionState(phase: .ended, badge: nil) @@ -293,6 +328,7 @@ func workCanonicalSessionState( lastActivityAt: workSessionStaleActivityTimestamp(session: session, summary: summary), exitCode: session.exitCode, settledAt: session.settledAt, + settleOverride: session.settleOverride, attentionRequestedAt: session.attentionRequestedAt, lastTurnFailedAt: session.lastTurnFailedAt, now: now @@ -314,6 +350,318 @@ private func workSessionStaleActivityTimestamp( summary?.lastActivityAt ?? session.chatIdleSinceAt } +// MARK: - Snooze — a synced VISIBILITY OVERLAY, deliberately NOT a lifecycle state + +/// Why a snoozed session woke. Persisted on `terminal_sessions.woke_reason`; +/// raw values mirror the desktop `SessionWakeReason` exactly. +enum SessionWakeReason: String, Equatable { + /// The snooze window elapsed (DERIVED from `snoozed_until`, no scheduler). + case timer + /// A pending approval / input request raised a hand. + case needsYou = "needs_you" + /// A session error strictly newer than `snoozed_at`. + case error + /// A running turn finished while the row was asleep. + case turnComplete = "turn_complete" + /// The user woke it by hand. + case manual + + /// Tolerant parse of the persisted column; unknown values read as no marker. + init?(persisted: String?) { + let raw = persisted?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" + guard !raw.isEmpty, let parsed = SessionWakeReason(rawValue: raw) else { return nil } + self = parsed + } + + /// Specific, operational copy for why a snoozed row came back. Deliberately + /// not "woke up" — the user needs to know what changed. Matches the desktop + /// `wakeReasonLabel` word for word so both surfaces say the same thing. + var wokeLabel: String { + switch self { + case .needsYou: return "needs approval" + case .error: return "errored" + case .turnComplete: return "turn finished" + case .timer: return "snooze ended" + case .manual: return "woken by you" + } + } +} + +/// The two columns snooze is derived from. Snooze never alters a session's +/// canonical phase: `workCanonicalSessionState` does not read these at all — a +/// snoozed row is still running/failed/needs_you exactly as before, snooze only +/// decides where the UI files it. Keeping the two orthogonal is what lets +/// desktop, `ade code`, and iOS agree without re-deriving the lifecycle. +struct SessionSnoozeState: Equatable { + /// ISO deadline; expiry is DERIVED by comparing to now — there is no timer. + var snoozedUntil: String? + /// ISO instant the snooze was taken; the early-wake comparison baseline. + var snoozedAt: String? + + init(snoozedUntil: String? = nil, snoozedAt: String? = nil) { + self.snoozedUntil = snoozedUntil + self.snoozedAt = snoozedAt + } +} + +/// Hand-raise signals that can wake a snoozed row before its deadline. +struct SessionWakeSignals: Equatable { + /// A pending approval / input request is showing on the row. + var hasPendingInput: Bool? + /// ISO timestamp of the session's most recent error, if any. + var errorAt: String? + /// A running turn just completed. + var turnCompleted: Bool? + + init(hasPendingInput: Bool? = nil, errorAt: String? = nil, turnCompleted: Bool? = nil) { + self.hasPendingInput = hasPendingInput + self.errorAt = errorAt + self.turnCompleted = turnCompleted + } +} + +/// Strict ISO parse mirroring the desktop `parseIsoMs`: blank and unparseable +/// values are indistinguishable from absent, so every comparison fails closed. +private func sessionSnoozeParsedDate(_ value: String?) -> Date? { + guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { + return nil + } + return workParsedDate(trimmed) +} + +/// The one derivation of "this row is currently snoozed", shared with desktop +/// and the CLI. Timer expiry is derived here (`snoozedUntil <= now`), which is +/// why no scheduler or background watchdog exists for snooze on any surface. +func isSessionSnoozed(_ session: SessionSnoozeState, now: Date = Date()) -> Bool { + guard let until = sessionSnoozeParsedDate(session.snoozedUntil) else { return false } + return until > now +} + +/// The FILING rule: "should the list hide this row in its Snoozed tail?". +/// +/// Mirrors the desktop `isSessionFiledAsSnoozed`. Snooze is a visibility overlay +/// and an overlay must YIELD to a session actually blocked on the user, or the +/// "Until I'm asked" window (~100 years) buries the very row whose hand is +/// raised. Only chat paths ever wrote an early wake; a tracked CLI row's +/// needs-input state is derived (runtime "waiting-input" / preview heuristic) +/// with no event to hook, so deriving the filing rule from the phase is what +/// covers chat and CLI identically. +/// +/// Deliberately separate from `isSessionSnoozed`, which stays the raw +/// two-column read that chips, menus, and wake labels want, and the canonical +/// state machine still never reads the snooze columns. +func isSessionFiledAsSnoozed( + _ session: SessionSnoozeState, + phase: CanonicalSessionPhase?, + now: Date = Date() +) -> Bool { + guard isSessionSnoozed(session, now: now) else { return false } + return phase != .needsYou +} + +/// A snooze that was taken but whose window has already elapsed. +func isSessionSnoozeExpired(_ session: SessionSnoozeState, now: Date = Date()) -> Bool { + guard let until = sessionSnoozeParsedDate(session.snoozedUntil) else { return false } + return until <= now +} + +/// The load-bearing early-wake comparison. +/// +/// An error only raises a hand when it is STRICTLY NEWER than `snoozedAt`. +/// Without this, the very error the user snoozed on top of re-wakes the row +/// immediately and snooze does nothing at all. An error stamped at exactly +/// `snoozedAt` is the one being snoozed, so it does not wake either. +/// +/// If the row carries no parseable `snoozedAt` we fail CLOSED (no wake): an +/// unknown baseline must not resurrect every historical error. +func isWakingSessionError(_ session: SessionSnoozeState, errorAt: String?) -> Bool { + guard let errorDate = sessionSnoozeParsedDate(errorAt) else { return false } + guard let snoozedAtDate = sessionSnoozeParsedDate(session.snoozedAt) else { return false } + return errorDate > snoozedAtDate +} + +/// Resolve why a snoozed row should wake right now, or nil to stay asleep. +/// Hand-raises are reported ahead of plain timer expiry because they carry the +/// more useful "woke" marker copy. A row that is not snoozed at all never wakes. +func resolveSessionWakeReason( + _ session: SessionSnoozeState, + signals: SessionWakeSignals = SessionWakeSignals(), + now: Date = Date() +) -> SessionWakeReason? { + guard sessionSnoozeParsedDate(session.snoozedUntil) != nil else { return nil } + if signals.hasPendingInput == true { return .needsYou } + if isWakingSessionError(session, errorAt: signals.errorAt) { return .error } + if signals.turnCompleted == true { return .turnComplete } + if isSessionSnoozeExpired(session, now: now) { return .timer } + return nil +} + +/// 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. +enum WorkSnoozeDuration: String, CaseIterable, Identifiable { + case oneHour + case thisEvening + case tomorrowMorning + 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 .untilAsked: return "Until I'm asked" + } + } + + var symbol: String { + switch self { + case .oneHour: return "clock" + case .thisEvening: return "sunset" + case .tomorrowMorning: return "sunrise" + case .untilAsked: return "hand.raised" + } + } + + /// Evening starts at 18:00 local; morning at 09:00 local. + private static let eveningHour = 18 + private static let morningHour = 9 + + /// Concrete deadline for a menu choice, computed on the client — there is no + /// scheduler anywhere, every surface derives expiry by comparing to now. + func deadline(from now: Date = Date(), calendar: Calendar = .current) -> Date? { + func atLocalHour(_ hour: Int, dayOffset: Int = 0) -> Date? { + guard let day = calendar.date(byAdding: .day, value: dayOffset, to: now) else { return nil } + return calendar.date(bySettingHour: hour, minute: 0, second: 0, of: day) + } + + switch self { + case .oneHour: + return now.addingTimeInterval(60 * 60) + case .thisEvening: + guard let evening = atLocalHour(Self.eveningHour) else { return nil } + // Past 6pm already: this evening has gone, so roll to the next one. + return evening > now ? evening : atLocalHour(Self.eveningHour, dayOffset: 1) + case .tomorrowMorning: + return atLocalHour(Self.morningHour, dayOffset: 1) + case .untilAsked: + return calendar.date(byAdding: .day, value: workSnoozeIndefiniteDays, to: now) + } + } +} + +extension TerminalSessionSummary { + /// The row's snooze columns as the shared derivation input. + var snoozeState: SessionSnoozeState { + SessionSnoozeState(snoozedUntil: snoozedUntil, snoozedAt: snoozedAt) + } + + /// True while the snooze window is still open. Purely derived from the clock. + /// This is the RAW read the row chrome (chips, menus, wake labels) wants — use + /// `isFiledAsSnoozed(summary:now:)` for anything that HIDES the row. + func isSnoozed(now: Date = Date()) -> Bool { + isSessionSnoozed(snoozeState, now: now) + } + + /// Whether a list may file this row into its quiet Snoozed tail. A row whose + /// canonical phase is `needsYou` is filed normally even while snoozed: the + /// overlay yields to a raised hand, which is the only thing that makes + /// "Until I'm asked" honest for tracked CLI rows (no early-wake event exists + /// for them — their needs-input state is derived). + func isFiledAsSnoozed(summary: AgentChatSessionSummary?, now: Date = Date()) -> Bool { + guard isSnoozed(now: now) else { return false } + let phase = workCanonicalSessionState(session: self, summary: summary, now: now).phase + return isSessionFiledAsSnoozed(snoozeState, phase: phase, now: now) + } + + /// The "woke" marker a row carries until it is opened. Prefers the persisted + /// `wokeReason`; a row whose snooze merely lapsed (expiry is derived, so no + /// backend ever wrote a marker) falls back to the shared resolver so timer + /// wakes still explain themselves. Mirrors the desktop `sessionWokeMarker`. + func wokeMarker(now: Date = Date()) -> SessionWakeReason? { + if sessionSnoozeParsedDate(wokeAt) != nil { + let raw = wokeReason?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + // An empty persisted reason means "the snooze lapsed"; an unrecognized + // one is no marker at all rather than an invented state. + return raw.isEmpty ? .timer : SessionWakeReason(persisted: raw) + } + guard isSessionSnoozeExpired(snoozeState, now: now) else { return nil } + let pending = pendingInputItemId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return resolveSessionWakeReason( + snoozeState, + signals: SessionWakeSignals(hasPendingInput: !pending.isEmpty, errorAt: lastTurnFailedAt), + now: now + ) + } + + /// Current settle override, if any. + var resolvedSettleOverride: SessionSettleOverride? { + SessionSettleOverride(persisted: settleOverride) + } +} + +// MARK: - Snooze / woke row presentation + +/// "Until I'm asked" has no clock deadline, so it parks the row far enough out +/// that only a hand-raise brings it back. Mirrors the desktop `INDEFINITE_MS`. +let workSnoozeIndefiniteDays = 100 * 365 +/// Any deadline beyond this reads as open-ended rather than a countdown. +/// Mirrors the desktop `INDEFINITE_LABEL_THRESHOLD_MS`. +private let workSnoozeIndefiniteLabelThreshold: TimeInterval = 365 * 24 * 60 * 60 + +/// The per-row wake line in the Snoozed group: "wakes in 3h", "wakes tomorrow", +/// "wakes when asked". Nil when the row has no usable deadline. Word-for-word +/// the desktop `snoozeWakeLabel`, including its calendar-day rounding. +func workSnoozeWakeLabel( + _ snoozedUntil: String?, + now: Date = Date(), + calendar: Calendar = .current +) -> String? { + guard let until = sessionSnoozeParsedDate(snoozedUntil) else { return nil } + let remaining = until.timeIntervalSince(now) + if remaining <= 0 { return "wakes now" } + if remaining >= workSnoozeIndefiniteLabelThreshold { return "wakes when asked" } + if remaining < 60 { return "wakes in 1m" } + if remaining < 3600 { return "wakes in \(Int((remaining / 60).rounded()))m" } + + let dayDelta = calendar.dateComponents( + [.day], + from: calendar.startOfDay(for: now), + to: calendar.startOfDay(for: until) + ).day ?? 0 + if dayDelta == 0 { return "wakes in \(Int((remaining / 3600).rounded()))h" } + if dayDelta == 1 { + return remaining < 12 * 3600 ? "wakes in \(Int((remaining / 3600).rounded()))h" : "wakes tomorrow" + } + return "wakes in \(max(1, dayDelta))d" +} + +/// Compact glyph + text chip for the snoozed / woke row markers. Deliberately +/// quieter than `WorkSessionStatusCapsule`: neither is an attention state. +struct WorkSessionLifecycleTag: View { + let symbol: String + let text: String + let tint: Color + + var body: some View { + HStack(spacing: 3) { + Image(systemName: symbol) + .font(.system(size: 9, weight: .semibold)) + Text(text) + .font(.caption2.weight(.medium)) + .lineLimit(1) + } + .foregroundStyle(tint) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(tint.opacity(0.12), in: Capsule()) + .fixedSize() + } +} + /// Small attention capsule shown next to a Work row title. Amber for needs_you /// (matching the app's existing amber chip language), red for failed, and an /// outlined muted capsule with a clock glyph for stale. Calm states render diff --git a/apps/ios/ADE/Views/Work/WorkSessionGrouping.swift b/apps/ios/ADE/Views/Work/WorkSessionGrouping.swift index 835690a32..67e1482bc 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionGrouping.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionGrouping.swift @@ -376,6 +376,11 @@ private func workRootSessionPresentationRenderSignature( hasher.combine(session.attentionRequestedAt) hasher.combine(session.attentionMessage) hasher.combine(session.lastTurnFailedAt) + hasher.combine(session.settleOverride) + hasher.combine(session.snoozedUntil) + hasher.combine(session.snoozedAt) + hasher.combine(session.wokeAt) + hasher.combine(session.wokeReason) hasher.combine(session.endedAt) hasher.combine(session.pinned) hasher.combine(statusBySessionId[session.id]) @@ -505,6 +510,19 @@ private let workSessionISO8601FormatterNoFractional: ISO8601DateFormatter = { }() /// Group session list by the user's chosen organization. Empty groups are filtered out. +/// +/// Snooze is applied here as a visibility overlay on top of whichever +/// organization is active: snoozed rows are lifted out of every other section +/// into a single quiet "Snoozed" tail. Their canonical phase is untouched — +/// this only decides where the list files them. Expiry is derived from the +/// clock (`isSessionSnoozed`), so there is no timer or scheduler on iOS either. +/// +/// The one exception is the shared FILING rule (`isFiledAsSnoozed`): a row whose +/// canonical phase is `needsYou` stays in its normal section even while snoozed. +/// Snooze must yield to a session actually blocked on the user, otherwise an +/// "Until I'm asked" snooze (~100 years) hides a tracked CLI row that hit a +/// permission prompt forever — nothing wakes it, because its needs-input state +/// is derived and no early-wake event exists for it. func workSessionGroups( organization: WorkSessionOrganization, sessions: [TerminalSessionSummary], @@ -512,27 +530,54 @@ func workSessionGroups( statusBySessionId: [String: String] = [:], archivedSessionIds: Set, orderedLanes: [LaneSummary], - deletingLaneIds: Set = [] + deletingLaneIds: Set = [], + now: Date = Date() ) -> [WorkSessionGroup] { + var snoozed: [TerminalSessionSummary] = [] + var awake: [TerminalSessionSummary] = [] + for session in sessions { + if session.isFiledAsSnoozed(summary: chatSummaries[session.id], now: now) { + snoozed.append(session) + } else { + awake.append(session) + } + } + + var groups: [WorkSessionGroup] switch organization { case .byStatus: - return workSessionGroupsByStatus( - sessions: sessions, + groups = workSessionGroupsByStatus( + sessions: awake, chatSummaries: chatSummaries, statusBySessionId: statusBySessionId, archivedSessionIds: archivedSessionIds ) case .byLane: - return workSessionGroupsByLane( - sessions: sessions, + groups = workSessionGroupsByLane( + sessions: awake, orderedLanes: orderedLanes, deletingLaneIds: deletingLaneIds ) case .byTime: - return workSessionGroupsByTime(sessions: sessions) + groups = workSessionGroupsByTime(sessions: awake) } + + if !snoozed.isEmpty { + groups.append(WorkSessionGroup( + id: workSnoozedSectionId, + label: "Snoozed", + icon: .statusDot, + tint: ADEColor.info, + sessions: snoozed + )) + } + return groups } +/// Stable id for the snoozed tail so its collapse state persists like any other +/// section, independent of the active organization. +let workSnoozedSectionId = "status:snoozed" + func workSessionGroupsByStatus( sessions: [TerminalSessionSummary], chatSummaries: [String: AgentChatSessionSummary], diff --git a/apps/ios/ADETests/SyncRecoveryPolicyTests.swift b/apps/ios/ADETests/SyncRecoveryPolicyTests.swift index aac2f1e02..8848abf78 100644 --- a/apps/ios/ADETests/SyncRecoveryPolicyTests.swift +++ b/apps/ios/ADETests/SyncRecoveryPolicyTests.swift @@ -1342,6 +1342,175 @@ final class SyncRecoveryPolicyTests: XCTestCase { "monotonic-input" ) } + + // MARK: - Session lifecycle host compatibility (ADE-125) + // + // The six `session.*` actions are OPTIONAL in the mobile compatibility + // contract, so a new phone must keep working against a brain that predates + // them. These tests pin the two directions that can regress: a legacy host + // that omits `features.mobileCompatibility` entirely must still complete the + // handshake, and an unadvertised lifecycle action must be refused locally + // BEFORE it can be optimistically written or queued for send. + + private func lifecycleActionDescriptors() -> [[String: Any]] { + [ + "session.settleSessions", + "session.unsettleSessions", + "session.setSettleOverride", + "session.snoozeSession", + "session.wakeSession", + "session.clearWokeMarker", + ].map { action in + ["action": action, "policy": ["viewerAllowed": true]] as [String: Any] + } + } + + @MainActor + func testLegacyHostWithoutMobileCompatibilityStillConnectsInLimitedMode() throws { + let defaultsSnapshot = snapshotDefaults(keys: connectionDefaultsKeys) + let baseURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true) + let database = DatabaseService(baseURL: baseURL) + let service = SyncService(database: database) + service.configureConnectedTransportForTesting() + defer { + service.disconnect(clearCredentials: false) + restoreDefaults(defaultsSnapshot, keys: connectionDefaultsKeys) + database.close() + try? FileManager.default.removeItem(at: baseURL) + } + + // An older brain: it routes commands, but has never heard of the mobile + // compatibility block or of any `session.*` lifecycle action. + try service.applyHelloPayloadForTesting([ + "brain": ["deviceId": "legacy-host", "deviceName": "Mac Studio"], + "features": [ + "commandRouting": [ + "actions": [ + ["action": "work.listSessions", "policy": ["viewerAllowed": true]], + ], + ], + ], + ]) + + // The handshake must SUCCEED (applyHelloPayload did not throw) and simply + // degrade — a missing compatibility block is not an authentication failure. + XCTAssertEqual( + service.hostCompatibilityMode, + .limited, + "A host that omits mobileCompatibility must degrade to limited, not fail the handshake." + ) + XCTAssertEqual( + service.hostCompatibilityMissingActions, + ["mobileCompatibility"], + "The phone should attribute the degrade to the absent block, not invent missing actions." + ) + XCTAssertTrue( + service.supportsRemoteAction("work.listSessions"), + "Command routing from a legacy host must still be honored." + ) + XCTAssertFalse( + service.supportsSessionLifecycleActions, + "A legacy host advertises no session.* actions, so settle/unsettle must stay hidden." + ) + XCTAssertFalse( + service.supportsSessionSnoozeActions, + "Snooze must stay hidden on a host that never advertised session.snoozeSession." + ) + } + + @MainActor + func testNewHostAdvertisingLifecycleActionsUnlocksTheAffordances() throws { + let defaultsSnapshot = snapshotDefaults(keys: connectionDefaultsKeys) + let baseURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true) + let database = DatabaseService(baseURL: baseURL) + let service = SyncService(database: database) + service.configureConnectedTransportForTesting() + defer { + service.disconnect(clearCredentials: false) + restoreDefaults(defaultsSnapshot, keys: connectionDefaultsKeys) + database.close() + try? FileManager.default.removeItem(at: baseURL) + } + + try service.applyHelloPayloadForTesting([ + "brain": ["deviceId": "new-host", "deviceName": "Mac Studio"], + "features": [ + "mobileCompatibility": ["mode": "full", "missingActions": [String]()], + "commandRouting": ["actions": lifecycleActionDescriptors()], + ], + ]) + + XCTAssertEqual(service.hostCompatibilityMode, .full) + XCTAssertTrue(service.supportsSessionLifecycleActions) + XCTAssertTrue(service.supportsSessionSnoozeActions) + } + + @MainActor + func testUnsupportedLifecycleActionIsRefusedBeforeAnyLocalWriteOrSend() async throws { + let defaultsSnapshot = snapshotDefaults(keys: connectionDefaultsKeys) + let baseURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true) + let database = DatabaseService(baseURL: baseURL) + let service = SyncService(database: database) + service.beginOutboundEnvelopeCaptureForTesting() + service.configureConnectedTransportForTesting() + defer { + service.endOutboundEnvelopeCaptureForTesting() + service.disconnect(clearCredentials: false) + restoreDefaults(defaultsSnapshot, keys: connectionDefaultsKeys) + database.close() + try? FileManager.default.removeItem(at: baseURL) + } + + // A host that advertises settle but NOT snooze — the partial-rollout case. + try service.applyHelloPayloadForTesting([ + "brain": ["deviceId": "partial-host", "deviceName": "Mac Studio"], + "features": [ + "mobileCompatibility": ["mode": "full", "missingActions": [String]()], + "commandRouting": [ + "actions": [ + ["action": "session.settleSessions", "policy": ["viewerAllowed": true]], + ], + ], + ], + ]) + XCTAssertFalse(service.supportsSessionSnoozeActions) + + service.resetOutboundEnvelopeCaptureForTesting() + // `terminal_sessions` is a CRR table: any optimistic write here would be + // captured by the update trigger and pushed upstream. Pinning the local + // db_version proves the guard runs BEFORE the write, not after it. + let dbVersionBefore = database.currentDbVersion() + + do { + try await service.snoozeSession( + sessionId: "session-1", + until: Date().addingTimeInterval(3_600) + ) + XCTFail("Snoozing against a host that never advertised the action must throw.") + } catch { + XCTAssertTrue( + error.localizedDescription.contains("session.snoozeSession"), + "The refusal should name the unsupported action so the user can act on it." + ) + } + + XCTAssertEqual( + database.currentDbVersion(), + dbVersionBefore, + "An unsupported lifecycle action must not leave an optimistic local write behind." + ) + XCTAssertEqual( + service.capturedOutboundEnvelopeCountForTesting(type: "command"), + 0, + "An unsupported lifecycle action must never reach the wire or the durable queue." + ) + } } @MainActor diff --git a/apps/ios/ADETests/WorkSessionCanonicalStateTests.swift b/apps/ios/ADETests/WorkSessionCanonicalStateTests.swift index 80542ec11..9445452cd 100644 --- a/apps/ios/ADETests/WorkSessionCanonicalStateTests.swift +++ b/apps/ios/ADETests/WorkSessionCanonicalStateTests.swift @@ -659,6 +659,504 @@ final class WorkSessionCanonicalStateTests: XCTestCase { XCTAssertNil(resultsTotal) } + // MARK: - Settle override tri-state (desktop sessionCanonicalState.ts parity) + // + // Mirrors `describe("settle override tri-state")`. The bug the override + // exists to fix: exit 0 auto-settles WITHOUT stamping settled_at, so the row + // had no lifecycle action at all and was pinned to the quiet tier forever. + + func testNullOverrideLeavesDerivedExitZeroAutoSettleIntact() { + XCTAssertEqual( + cleanExitState(settleOverride: nil).phase, .settled, + "no override: a clean exit still auto-settles" + ) + XCTAssertEqual(cleanExitState(settleOverride: "").phase, .settled, "blank override reads as none") + XCTAssertEqual( + cleanExitState(settleOverride: "nonsense").phase, .settled, + "unknown override values must not invent a state" + ) + } + + func testActiveOverrideBeatsDerivedExitZeroRule() { + let result = cleanExitState(settleOverride: "active") + XCTAssertEqual(result.phase, .ended) + XCTAssertNil(result.badge) + } + + func testActiveOverrideAlsoSuppressesDeclaredSettle() { + XCTAssertEqual( + cleanExitState(settledAt: "2026-07-06T11:00:00.000Z", settleOverride: "active").phase, + .ended + ) + } + + func testSettledOverrideBehavesLikeDeclaredSettleWithoutSettledAt() { + XCTAssertEqual( + workCanonicalSessionState( + status: "detached", runtimeState: nil, toolType: "codex", + exitCode: 2, settleOverride: "settled", now: now + ).phase, + .settled, + "a 'settled' override outranks the non-clean exit failure" + ) + XCTAssertEqual( + workCanonicalSessionState( + status: "running", runtimeState: "idle", toolType: "claude-chat", + settleOverride: "settled", now: now + ).phase, + .settled + ) + } + + func testSettledOverrideIsStillOnlyHonoredAtRest() { + XCTAssertEqual( + workCanonicalSessionState( + status: "running", runtimeState: "running", toolType: "claude-chat", + lastActivityAt: iso(now), settleOverride: "settled", now: now + ).phase, + .running + ) + } + + func testDeterministicAttentionStillOutranksEveryOverride() { + XCTAssertEqual( + workCanonicalSessionState( + status: "running", runtimeState: "idle", toolType: "codex", + pendingInputItemId: "i-1", settleOverride: "settled", now: now + ).phase, + .needsYou, + "an escalated ask outranks a 'settled' override" + ) + XCTAssertEqual( + workCanonicalSessionState( + status: "running", runtimeState: "idle", toolType: "codex", + settleOverride: "active", attentionRequestedAt: iso(now), now: now + ).phase, + .needsYou, + "an escalated ask outranks an 'active' override too" + ) + } + + /// The desktop `cleanExit` fixture: a detached PTY that exited 0. + private func cleanExitState( + settledAt: String? = nil, + settleOverride: String? = nil + ) -> CanonicalSessionState { + workCanonicalSessionState( + status: "detached", + runtimeState: "exited", + toolType: "codex", + exitCode: 0, + settledAt: settledAt, + settleOverride: settleOverride, + now: now + ) + } + + // MARK: - Snooze is a visibility overlay, not a phase + + func testSnoozeNeverChangesTheCanonicalPhase() { + // Snooze columns are deliberately absent from the canonical inputs; this + // asserts the contract holds for the row a snoozed session represents. + let session = snoozedSession( + untilOffset: 60, + atOffset: -60, + status: "running", + runtimeState: "running", + lastOutputPreview: "compiling..." + ) + XCTAssertEqual( + workCanonicalSessionState(session: session, summary: nil, now: now).phase, + .running + ) + XCTAssertTrue(session.isSnoozed(now: now)) + } + + func testSnoozeExpiryIsDerivedFromSnoozedUntilWithNoScheduler() { + let until = now.addingTimeInterval(60) + let state = SessionSnoozeState(snoozedUntil: iso(until), snoozedAt: iso(now.addingTimeInterval(-60))) + XCTAssertTrue(isSessionSnoozed(state, now: now)) + XCTAssertFalse(isSessionSnoozeExpired(state, now: now)) + + // The deadline itself, and one millisecond past it, flip both — purely + // from the clock, with no timer anywhere. + XCTAssertFalse(isSessionSnoozed(state, now: until)) + XCTAssertTrue(isSessionSnoozeExpired(state, now: until)) + XCTAssertFalse(isSessionSnoozed(state, now: until.addingTimeInterval(0.001))) + XCTAssertTrue(isSessionSnoozeExpired(state, now: until.addingTimeInterval(0.001))) + } + + func testMissingOrUnparseableDeadlineIsNotSnoozed() { + XCTAssertFalse(isSessionSnoozed(SessionSnoozeState(), now: now)) + XCTAssertFalse(isSessionSnoozed(SessionSnoozeState(snoozedUntil: nil), now: now)) + XCTAssertFalse(isSessionSnoozed(SessionSnoozeState(snoozedUntil: " "), now: now)) + XCTAssertFalse(isSessionSnoozed(SessionSnoozeState(snoozedUntil: "not-a-date"), now: now)) + XCTAssertFalse(isSessionSnoozeExpired(SessionSnoozeState(snoozedUntil: "not-a-date"), now: now)) + } + + // MARK: - Snooze filing yields to a raised hand + // + // Regression: an "Until I'm asked" snooze (~100 years) hid a needs-you row + // forever. Every early-wake trigger was chat-only, and a tracked CLI row's + // needs-input state is DERIVED (runtime "waiting-input" / preview heuristic) + // with no event to hook — so the FILING rule, not an event, is what has to + // bring the row back. + + /// Snoozed "until I'm asked": the deadline that used to bury a blocked row. + private var indefiniteSnooze: SessionSnoozeState { + SessionSnoozeState( + snoozedUntil: iso(now.addingTimeInterval(TimeInterval(workSnoozeIndefiniteDays) * 86_400)), + snoozedAt: iso(now.addingTimeInterval(-60)) + ) + } + + func testSnoozedNeedsYouRowIsNotFiledAsSnoozed() { + XCTAssertFalse(isSessionFiledAsSnoozed(indefiniteSnooze, phase: .needsYou, now: now)) + + // A tracked CLI row blocked at a permission prompt: the phase is derived + // from the runtime, and no early-wake event exists for it at all. + let blocked = snoozedSession( + untilOffset: TimeInterval(workSnoozeIndefiniteDays) * 86_400, + atOffset: -60, + status: "running", + runtimeState: "waiting-input" + ) + XCTAssertEqual( + workCanonicalSessionState(session: blocked, summary: nil, now: now).phase, + .needsYou + ) + XCTAssertFalse(blocked.isFiledAsSnoozed(summary: nil, now: now)) + + // The RAW column read is unchanged — chips, menus, and the wake label still + // see a snoozed row, independent of where the list files it. + XCTAssertTrue(blocked.isSnoozed(now: now)) + XCTAssertTrue(isSessionSnoozed(indefiniteSnooze, now: now)) + } + + func testEveryCalmPhaseIsStillFiledAsSnoozed() { + for phase in [ + CanonicalSessionPhase.starting, .running, .stale, .ready, .idle, + .failed, .stopped, .ended, .settled + ] { + XCTAssertTrue( + isSessionFiledAsSnoozed(indefiniteSnooze, phase: phase, now: now), + "phase \(phase) must still be hidden by the overlay" + ) + } + // No phase known (callers that only hold the columns) files as snoozed too. + XCTAssertTrue(isSessionFiledAsSnoozed(indefiniteSnooze, phase: nil, now: now)) + + let calm = snoozedSession(untilOffset: 3_600, atOffset: -60) + XCTAssertTrue(calm.isFiledAsSnoozed(summary: nil, now: now)) + } + + func testARowThatIsNotSnoozedIsNeverFiledAsSnoozed() { + XCTAssertFalse(isSessionFiledAsSnoozed(SessionSnoozeState(), phase: .running, now: now)) + XCTAssertFalse(isSessionFiledAsSnoozed(SessionSnoozeState(), phase: .needsYou, now: now)) + // A lapsed deadline: expiry is derived, so the row is simply awake. + let lapsed = SessionSnoozeState( + snoozedUntil: iso(now.addingTimeInterval(-1)), + snoozedAt: iso(now.addingTimeInterval(-3_600)) + ) + XCTAssertFalse(isSessionFiledAsSnoozed(lapsed, phase: .running, now: now)) + } + + func testWorkSessionGroupsKeepASnoozedNeedsYouRowInYourMove() { + var blocked = snoozedSession( + untilOffset: TimeInterval(workSnoozeIndefiniteDays) * 86_400, + atOffset: -60, + status: "running", + runtimeState: "waiting-input" + ) + blocked.id = "s-blocked" + var calm = snoozedSession(untilOffset: 3_600, atOffset: -60) + calm.id = "s-calm" + + let groups = workSessionGroups( + organization: .byStatus, + sessions: [blocked, calm], + chatSummaries: [:], + archivedSessionIds: [], + orderedLanes: [], + now: now + ) + + XCTAssertEqual(groups.map(\.id), ["status:awaiting", workSnoozedSectionId]) + XCTAssertEqual(groups.first?.sessions.map(\.id), ["s-blocked"]) + XCTAssertEqual(groups.last?.sessions.map(\.id), ["s-calm"]) + } + + // MARK: - Early wake: the newer-than-snoozed_at error comparison + + func testDoesNotWakeOnTheErrorTheSnoozeWasTakenOnTopOf() { + // This is the whole point: an older/equal error must not resurrect the + // row, otherwise snooze does nothing at all. + let state = earlyWakeState + XCTAssertFalse(isWakingSessionError(state, errorAt: "2026-07-06T10:59:59.999Z")) + XCTAssertFalse(isWakingSessionError(state, errorAt: earlyWakeSnoozedAt)) + } + + func testWakesOnAnErrorStrictlyNewerThanSnoozedAt() { + XCTAssertTrue(isWakingSessionError(earlyWakeState, errorAt: "2026-07-06T11:00:00.001Z")) + XCTAssertTrue(isWakingSessionError(earlyWakeState, errorAt: "2026-07-06T12:00:00.000Z")) + } + + func testEarlyWakeFailsClosedWithoutAUsableTimestampOnEitherSide() { + XCTAssertFalse(isWakingSessionError(earlyWakeState, errorAt: nil)) + XCTAssertFalse(isWakingSessionError(earlyWakeState, errorAt: "not-a-date")) + XCTAssertFalse( + isWakingSessionError( + SessionSnoozeState(snoozedUntil: earlyWakeSnoozedUntil), + errorAt: "2026-07-06T12:00:00.000Z" + ), + "an unknown baseline must not resurrect every historical error" + ) + XCTAssertFalse( + isWakingSessionError( + SessionSnoozeState(snoozedUntil: earlyWakeSnoozedUntil, snoozedAt: "garbage"), + errorAt: "2026-07-06T12:00:00.000Z" + ) + ) + } + + // MARK: - resolveSessionWakeReason + + func testUnsnoozedRowNeverReportsAWake() { + XCTAssertNil( + resolveSessionWakeReason( + SessionSnoozeState(), + signals: SessionWakeSignals(hasPendingInput: true), + now: wakeReasonNow + ) + ) + XCTAssertNil( + resolveSessionWakeReason( + SessionSnoozeState(snoozedAt: earlyWakeSnoozedAt), + signals: SessionWakeSignals(turnCompleted: true), + now: wakeReasonNow + ) + ) + } + + func testStaysAsleepWithNoQualifyingSignal() { + XCTAssertNil(resolveSessionWakeReason(activeSnooze, now: wakeReasonNow)) + XCTAssertNil( + resolveSessionWakeReason( + activeSnooze, + signals: SessionWakeSignals(errorAt: earlyWakeSnoozedAt), + now: wakeReasonNow + ) + ) + } + + func testReportsEachHandRaiseAheadOfPlainTimerExpiry() { + XCTAssertEqual( + resolveSessionWakeReason(activeSnooze, signals: SessionWakeSignals(hasPendingInput: true), now: wakeReasonNow), + .needsYou + ) + XCTAssertEqual( + resolveSessionWakeReason( + activeSnooze, + signals: SessionWakeSignals(errorAt: "2026-07-06T11:45:00.000Z"), + now: wakeReasonNow + ), + .error + ) + XCTAssertEqual( + resolveSessionWakeReason(activeSnooze, signals: SessionWakeSignals(turnCompleted: true), now: wakeReasonNow), + .turnComplete + ) + XCTAssertEqual( + resolveSessionWakeReason(expiredSnooze, signals: SessionWakeSignals(turnCompleted: true), now: wakeReasonNow), + .turnComplete, + "a hand-raise outranks plain expiry even after the deadline" + ) + } + + func testFallsBackToDerivedTimerExpiry() { + XCTAssertEqual(resolveSessionWakeReason(expiredSnooze, now: wakeReasonNow), .timer) + XCTAssertEqual( + resolveSessionWakeReason( + expiredSnooze, + signals: SessionWakeSignals(errorAt: earlyWakeSnoozedAt), + now: wakeReasonNow + ), + .timer, + "the snoozed-on error is still not a hand-raise; the timer is the reason" + ) + } + + // Fixtures mirroring the TS suite's fixed instants exactly. + private let earlyWakeSnoozedAt = "2026-07-06T11:00:00.000Z" + private let earlyWakeSnoozedUntil = "2026-07-06T13:00:00.000Z" + private var earlyWakeState: SessionSnoozeState { + SessionSnoozeState(snoozedUntil: earlyWakeSnoozedUntil, snoozedAt: earlyWakeSnoozedAt) + } + private var activeSnooze: SessionSnoozeState { + SessionSnoozeState(snoozedUntil: "2026-07-06T13:00:00.000Z", snoozedAt: earlyWakeSnoozedAt) + } + private var expiredSnooze: SessionSnoozeState { + SessionSnoozeState(snoozedUntil: "2026-07-06T11:30:00.000Z", snoozedAt: earlyWakeSnoozedAt) + } + /// The TS suite's NOW — 2026-07-06T12:00:00.000Z. + private var wakeReasonNow: Date { + ISO8601DateFormatter().date(from: "2026-07-06T12:00:00Z")! + } + + // MARK: - Woke marker + settle override projection on the row model + + func testWokeMarkerPrefersThePersistedReason() { + var session = snoozedSession(untilOffset: nil, atOffset: nil) + session.wokeAt = iso(now) + session.wokeReason = "turn_complete" + XCTAssertEqual(session.wokeMarker(now: now), .turnComplete) + + session.wokeReason = "TIMER" + XCTAssertEqual(session.wokeMarker(now: now), .timer, "persisted reason parse is case-insensitive") + + session.wokeReason = nil + XCTAssertEqual(session.wokeMarker(now: now), .timer, "a stamped wake with no reason is a lapsed snooze") + + session.wokeReason = "who-knows" + XCTAssertNil(session.wokeMarker(now: now), "an unknown reason is no marker, not an invented state") + + session.wokeAt = nil + session.wokeReason = "manual" + XCTAssertNil(session.wokeMarker(now: now), "a reason without a timestamp is not a marker") + } + + /// Expiry is derived, so nothing ever writes a marker for a snooze that just + /// lapsed — the row still has to explain itself. + func testWokeMarkerFallsBackToTheDerivedTimerWake() { + let lapsed = snoozedSession(untilOffset: -60, atOffset: -3600) + XCTAssertEqual(lapsed.wokeMarker(now: now), .timer) + + let stillAsleep = snoozedSession(untilOffset: 3600, atOffset: -60) + XCTAssertNil(stillAsleep.wokeMarker(now: now), "an open snooze window has not woken") + + var lapsedWithAsk = snoozedSession(untilOffset: -60, atOffset: -3600) + lapsedWithAsk.pendingInputItemId = "item-1" + XCTAssertEqual( + lapsedWithAsk.wokeMarker(now: now), .needsYou, + "a hand-raise outranks plain expiry in the marker copy too" + ) + } + + func testResolvedSettleOverrideParsesTolerantly() { + var session = snoozedSession(untilOffset: nil, atOffset: nil) + XCTAssertNil(session.resolvedSettleOverride) + session.settleOverride = " Active " + XCTAssertEqual(session.resolvedSettleOverride, .active) + session.settleOverride = "settled" + XCTAssertEqual(session.resolvedSettleOverride, .settled) + session.settleOverride = "paused" + XCTAssertNil(session.resolvedSettleOverride) + } + + // MARK: - Snooze duration presets + + func testSnoozeDurationDeadlinesResolveAgainstTheUsersCalendar() { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "America/Los_Angeles")! + let morning = calendar.date(from: DateComponents(year: 2026, month: 7, day: 6, hour: 8))! + + XCTAssertEqual( + WorkSnoozeDuration.oneHour.deadline(from: morning, calendar: calendar), + morning.addingTimeInterval(3600) + ) + + let evening = XCTUnwrap2(WorkSnoozeDuration.thisEvening.deadline(from: morning, calendar: calendar)) + XCTAssertEqual(calendar.component(.hour, from: evening), 18) + XCTAssertTrue(calendar.isDate(evening, inSameDayAs: morning)) + + let tomorrow = XCTUnwrap2(WorkSnoozeDuration.tomorrowMorning.deadline(from: morning, calendar: calendar)) + XCTAssertEqual(calendar.component(.hour, from: tomorrow), 9) + XCTAssertEqual( + calendar.dateComponents([.day], from: calendar.startOfDay(for: morning), to: calendar.startOfDay(for: tomorrow)).day, + 1, + "tomorrow 9am is the next calendar day in the user's own time zone" + ) + } + + /// Past 18:00 "this evening" has gone, so it rolls to the next one rather + /// than resolving to a deadline that has already elapsed. Mirrors the desktop + /// `snoozeDeadlineIso("evening")`. + func testThisEveningRollsToTheNextEveningOncePassed() { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "America/Los_Angeles")! + let night = calendar.date(from: DateComponents(year: 2026, month: 7, day: 6, hour: 21))! + let evening = XCTUnwrap2(WorkSnoozeDuration.thisEvening.deadline(from: night, calendar: calendar)) + XCTAssertTrue(evening > night) + XCTAssertEqual(calendar.component(.hour, from: evening), 18) + XCTAssertEqual( + calendar.dateComponents([.day], from: calendar.startOfDay(for: night), to: calendar.startOfDay(for: evening)).day, + 1 + ) + } + + /// "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() { + let deadline = XCTUnwrap2(WorkSnoozeDuration.untilAsked.deadline(from: now)) + XCTAssertEqual(workSnoozeWakeLabel(iso(deadline), now: now), "wakes when asked") + } + + /// The wake line mirrors the desktop `snoozeWakeLabel` exactly — same + /// thresholds, same calendar-day rounding, same words. + func testSnoozeWakeLabelMatchesDesktopCopy() { + XCTAssertNil(workSnoozeWakeLabel(nil, now: now)) + XCTAssertNil(workSnoozeWakeLabel("not-a-date", now: now)) + XCTAssertEqual(workSnoozeWakeLabel(iso(now.addingTimeInterval(-1)), now: now), "wakes now") + XCTAssertEqual(workSnoozeWakeLabel(iso(now.addingTimeInterval(30)), now: now), "wakes in 1m") + XCTAssertEqual(workSnoozeWakeLabel(iso(now.addingTimeInterval(25 * 60)), now: now), "wakes in 25m") + XCTAssertEqual(workSnoozeWakeLabel(iso(now.addingTimeInterval(3 * 3600)), now: now), "wakes in 3h") + } + + private func snoozedSession( + untilOffset: TimeInterval?, + atOffset: TimeInterval?, + status: String = "running", + runtimeState: String = "running", + lastOutputPreview: String? = nil + ) -> TerminalSessionSummary { + TerminalSessionSummary( + id: "s-1", + laneId: "lane-1", + laneName: "lane", + ptyId: nil, + tracked: true, + pinned: false, + manuallyNamed: nil, + goal: nil, + toolType: "codex", + title: "Session", + status: status, + startedAt: iso(now), + endedAt: nil, + snoozedUntil: untilOffset.map { iso(now.addingTimeInterval($0)) }, + snoozedAt: atOffset.map { iso(now.addingTimeInterval($0)) }, + exitCode: nil, + transcriptPath: "", + headShaStart: nil, + headShaEnd: nil, + lastOutputPreview: lastOutputPreview, + summary: nil, + runtimeState: runtimeState, + resumeCommand: nil, + resumeMetadata: nil, + chatIdleSinceAt: nil + ) + } + + /// Force-unwrap helper so the duration assertions read as one line each. + private func XCTUnwrap2(_ value: Date?, file: StaticString = #filePath, line: UInt = #line) -> Date { + guard let value else { + XCTFail("expected a non-nil date", file: file, line: line) + return Date() + } + return value + } + // MARK: - buildWorkToolCards web_search preservation (/quality regression) /// Regression pin for the /quality Medium finding: a later same-itemId diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 88a579627..fbd27a465 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -164,10 +164,12 @@ 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. -**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`, and `unsettleSelfSession`. 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` / `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). 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. + 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. **Proof subcommands** — `ade proof capture` (alias of `screenshot`), `ade proof attach `, `ade proof record`, `ade proof launch`, `ade proof interact`, `ade proof list/status/environment/ingest`. `attach` infers the artifact kind from the file extension and routes through `ingest_computer_use_artifacts` with `backendStyle: "manual"`. Capture-style commands set `preferHeadless: true` on the plan so the connection layer drops to headless mode unless `--socket` is explicitly requested. All proof subcommands accept `--owner-kind` / `--owner-id` (with `chat` and `pr` aliases) to layer an explicit owner on top of the inferred session identity. @@ -352,7 +354,7 @@ Schema bootstrap in `kvDb.ts` creates ~104 tables. Anchor tables for agents read | `projects` | One row per opened repo. Keyed by `root_path`. | | `lanes` | Worktree-backed units of work. Types: `primary`, `worktree`, `attached`. Supports parent/child stacks, color/icon/tags. | | `local_worktree_residual_cleanups` | Machine-local lane-delete cleanup debt for residual managed worktree directories. Stores absolute paths and is excluded from CRR replication because only the runtime on that machine can safely retry removal. | -| `terminal_sessions` | Tracked PTY sessions per lane with transcript path and head SHAs. The `chat_session_id` column (indexed) marks terminals owned by a chat (chat terminal drawer, App Control launch terminal); `ptyService` exposes them through the `ade.terminal.*` IPC and the `terminal` ADE action domain. The `owner_pid` column (indexed) identifies the ADE OS process that owns the live runtime for the row — cross-process reconcile/dispose paths check it before sweeping so concurrent surfaces don't mark each other's live sessions dead. See §3.5. | +| `terminal_sessions` | Tracked PTY sessions per lane with transcript path and head SHAs. The `chat_session_id` column (indexed) marks terminals owned by a chat (chat terminal drawer, App Control launch terminal); `ptyService` exposes them through the `ade.terminal.*` IPC and the `terminal` ADE action domain. The `owner_pid` column (indexed) identifies the ADE OS process that owns the live runtime for the row — cross-process reconcile/dispose paths check it before sweeping so concurrent surfaces don't mark each other's live sessions dead. See §3.5. Lifecycle lives in five nullable text columns: `settle_override` (tri-state `settled` / `active` / null, consulted before the derived exit-0 settle) and the snooze visibility overlay `snoozed_until` / `snoozed_at` with its `woke_at` / `woke_reason` marker. None of them carry a unique index — the table replicates to iOS through cr-sqlite, and `crsql_as_crr` rejects any non-primary-key unique index — and all five are mirrored in both iOS schema halves (`DatabaseBootstrap.sql` and `Database.swift`'s `ensureColumn` migrations). | | `runtime_processes` | Machine-local process-liveness registry. Every ADE process (desktop main, brain process, TUI runtime) inserts a row on boot keyed by the process incarnation (`pid`, `started_at`) and refreshes `last_seen` on a 5 s heartbeat. The table is excluded from CRR replication because PIDs are only meaningful on the current OS; reconcile / dispose paths cross-reference `terminal_sessions.owner_pid` and `owner_process_started_at` against locally known and live rows to tell "row whose local owner crashed" from "row a sibling process is actively managing" without detaching sessions owned by another synced machine. See §3.5. | | `session_deltas` | Post-session diff stats + touched files + failure lines. Input to pack generation. | | `operations` | Audit log of every significant mutation (git, pack updates). Pre/post HEAD SHAs enable undo. | @@ -547,6 +549,16 @@ Related feature docs: [Chat](./features/chat/README.md), [Agents](./features/age `settleTerminalSession` transaction for local fallback and runtime-bound projects. Pending-input dismissal completes before `settled_at` is written; it is not a renderer-side pair of mutations. +- `window.ade.sessions.snooze` / `.snoozeMany` / `.wake` / `.wakeMany` / + `.setSettleOverride` / `.clearWokeMarker` are the desktop half of the session + lifecycle surface. `setSettleOverride` takes the tri-state + `"settled" | "active" | null` pin consulted before the derived exit-0 settle; + snooze/wake write the `snoozed_until` / `snoozed_at` visibility overlay and + its `woke_at` / `woke_reason` marker. The overlay is deliberately outside + `canonicalSessionState()` — it changes only where a surface files a row. The + same operations are exposed to every other client as the `session` ADE action + domain and the `session.*` sync remote commands. See + [Terminals and sessions](./features/terminals-and-sessions/README.md#session-lifecycle). - `window.ade.project.getDroppedPath(file)` wraps Electron's `webUtils.getPathForFile()` so renderer drag-drop handlers can resolve the absolute path of a `File` payload without the renderer needing Node APIs. Used by the Command Palette project browser to accept dropped folders. ### 5.2 Channel design @@ -568,6 +580,10 @@ ade.onboarding.* ade.lanes.* # lane list/create/delete/stack/template/env/port/proxy/rebase # delete pipeline: ade.lanes.delete + ade.lanes.delete.cancel # + ade.lanes.delete.risk preflight + ade.lanes.delete.event push + # branch drift (worktree HEAD off lanes.branch_ref): + # ade.lanes.getBranchDrift (fresh symbolic-ref read) + # + ade.lanes.resolveBranchDrift (switch-back | + # keep-head); see features/lanes/README.md#branch-drift # one-shot create/archive/delete notifications: # ade.lanes.lifecycle.event push, mirrored from # runtime event type lane_lifecycle_event @@ -664,6 +680,7 @@ ade.updates.* - Every handler is wrapped with a timeout — 30 seconds by default, with explicit longer budgets for known long operations such as direct lane delete, iOS Simulator launch/control, App Control, and built-in browser actions. Runtime-dispatched actions use the runtime-call channel budget; the timeout wrapper no longer inspects the action payload to give `lane.delete` a special runtime-dispatch override. - Every handler emits structured tracing: `ipc.invoke.begin`, `ipc.invoke.done`, `ipc.invoke.failed` with call ID, channel, window ID, duration, and summarized args/results. - `AppContext` indirection: handlers close over a context pointer that swaps atomically on project switch, so IPC channels remain registered across project transitions. +- Lane branch-drift handlers (`ade.lanes.getBranchDrift`, `ade.lanes.resolveBranchDrift`) are registered here with the rest of `ade.lanes.*` and delegate to `laneService`. Preload prefers the `lane` runtime action of the same name, so a remote-bound window resolves drift on the machine that owns the worktree and falls back to these handlers only when no runtime is bound. - **Multi-window shell** — the app can host multiple `BrowserWindow` instances (for example when opening another project in a dedicated window). Handler tracing already carries **window ID** so logs and diagnostics distinguish which renderer surface invoked a channel; `main.ts` ties each window to its **set** of open project roots before routing into services. Two maps in `main.ts` drive this: `windowProjectRoots` tracks the active foreground project per window, and `windowProjectTabRoots` tracks every project root that window currently has open as a tab. Project-scoped event broadcasts (`emitToProjectWindows`) deliver to any window whose active **or** open-tab set contains the project, so background tabs keep receiving live updates. `ade.app.getWindowSession` returns `{ project, binding, openProjectTabs }` for the requesting window; the renderer mirrors its open-tab list back to main with `ade.app.setWindowProjectTabs({ rootPaths })` so the main process can keep those project contexts warm and clean up on window close. Renderer tab switches use cached project/lane snapshots for warm activation, retain caches for every open tab root even if a project is absent from recents, keep Work and Lanes mounted after first visit, and cover cold switches with a project-transition veil. - **Project context retention.** `MAX_WARM_IDLE_PROJECT_CONTEXTS = 100` is a soft cap for project contexts with no user work. `hasActiveProjectWorkloads(ctx)` protects any context that has live chat sessions (via `agentChatService.hasRetainableSessions()` — any session the user hasn't explicitly closed or deleted, not just mid-turn ones), live PTYs (`ptyService.hasLiveSessions()`), or queued tests. Eviction is best-effort and never tears down a context with work; the cap exists only as a safety valve against opening hundreds of empty projects in a long session. @@ -749,6 +766,8 @@ Project-init step timing goes through `measureProjectInitStep(step, task)` — a Shutdown pipeline: `main.ts` owns a single `requestAppShutdown({ reason, exitCode, fastKillFirst?, forceAfterMs? })` path driving a central state machine (`shutdownRequested` → `shutdownPromise` → `shutdownFinalized`). Hooks into `before-quit`, `window close`, `SIGINT`, `SIGTERM`, `process.exit`, `will-quit`, and `uncaughtException` all funnel through it. Before browser webContents are disposed, shutdown awaits the tab-state/permission write chains, `cookies.flushStore()`, and `session.flushStorageData()` for `persist:ade-browser`; failures are logged without credential values and cleanup continues. `runImmediateProcessCleanup()` disposes automations, tests, PTYs, agent chat runtimes, DB flush, and then calls `shutdownOpenCodeServers()`. A `forceAfterMs` timer (default 8 s, 5 s for signals/uncaught) hard-exits if cleanup hangs. User-initiated quit (main window close or `before-quit`) routes through `confirmQuitWarning()` — a modal dialog that explains that quitting will end agents and background processes owned by the desktop session, including OpenCode servers, terminal sessions, and test runs. +Crash-resistance at the process boundary: `main.ts` installs an `error` listener on `process.stdout` and `process.stderr` before any other module loads, and adds `EPIPE` / `ERR_STREAM_DESTROYED` to the set of `uncaughtException` codes that are swallowed (alongside the `EMFILE` / `ENFILE` file-limit codes). When ADE is launched from a terminal and that terminal goes away, the next write to stdout or stderr raises `EPIPE`; without a listener Node surfaces it as an `uncaughtException`, which funnels into `requestAppShutdown` and tears the whole app down. A dead logging pipe must never kill the app. Any other stream error is still re-thrown. + On startup the main process also invokes `recoverManagedOpenCodeOrphans({ force: true })` (see `services/opencode/openCodeServerManager.ts`) to reap previous-run OpenCode processes left behind after a crash. Orphan detection matches processes by the managed marker env (`ADE_OPENCODE_MANAGED=1`) and/or the shared XDG config root, and confirms orphaning either by dead owner PID (`ADE_OPENCODE_OWNER_PID`) or reparent-to-init. Each acquire of a shared OpenCode server also invokes `pruneIdleSharedEntries()` which compacts idle entries from older configs (`pool_compaction` reason). --- @@ -996,7 +1015,7 @@ Worktree lifecycle: create (60s timeout), archive (DB status only, worktree rema - Lanes have `parent_lane_id` (self-FK on `lanes`). Stacks are parent/child chains. - Stack operations: rebase propagation, base-ref resolution (`shared/laneBaseResolution.ts`). - `autoRebaseService.ts` + `rebaseSuggestionService.ts` — automatic rebase proposals when parent moves; user can accept/defer/dismiss. -- `computeLaneStatus()` returns `{ dirty, ahead, behind }` on demand, no caching. Status derivation uses `git status --porcelain=v1` and `git rev-list --left-right --count`. +- `computeLaneStatus()` returns `{ dirty, ahead, behind }` on demand, no caching. Status derivation uses `git status --porcelain=v2 --branch` and `git rev-list --left-right --count`. The `--branch` header (`# branch.head`) carries the branch HEAD is actually on, so the same call that computes dirty state also yields `headBranchRef` — which is what makes HEAD-vs-`lanes.branch_ref` drift detection (`services/lanes/laneBranchDrift.ts`) cost no extra process spawn and need no timer of its own. Ignored files are still not listed (no `--ignored`), so dirty semantics are identical to the porcelain v1 form this replaced. See [Lanes › Branch drift](./features/lanes/README.md#branch-drift). ### 9.4 Queue + conflict simulation @@ -1016,6 +1035,14 @@ Worktree lifecycle: create (60s timeout), archive (DB status only, worktree rema - Branch-protection support on primary lane. - Destructive ops (discard, hard reset) require UI confirmation. +### 9.6 Open-PR lookup for a lane branch + +`gh pr list --head ` matches on branch **name only**, across every fork of the repository. A PR opened from somebody else's fork that happens to use the same branch name is returned by that query and, unfiltered, attaches itself to the lane. Filtering the result by head repository is therefore a correctness invariant, not an optimization. + +- `services/git/ghOpenPrLookup.ts` is the single lookup: `lookupOpenPrForBranch({ worktreePath, branch })` resolves the `origin` owner from `git remote get-url origin` + `parseGithubRemoteUrl`, spawns `gh pr list --head --state open --json --limit 10` with an 8 s timeout, and picks the row whose head-repo owner matches. Because `--head` matches across forks the wanted row is not necessarily first, hence a small page rather than `--limit 1`. Both `gitOperationsService.ts` (`getOpenPrForBranch`, behind `ade.git.getOpenPrForBranch`) and the ADE action registry call it instead of open-coding the `gh` invocation. +- `services/git/ghPrHeadRepo.ts` holds the pure parsing/selection: `GH_PR_LIST_JSON_FIELDS`, `parseGhPrListEntry`, `ghPrHeadRepoMatchesLane`, `selectOwnRepoOpenPr`, `EMPTY_GH_OPEN_PR_SUMMARY`. +- **Decode leniently, and fall back for old `gh`.** `headRepositoryOwner` / `headRepository` are only emitted by `gh >= 2.47`, and `gh` renders them as objects (`{"login":"acme"}` / `{"name":"widgets"}`) though a bare string is also accepted. An **absent** field means "cannot verify — accept", never "reject"; a strict decode would silently drop every PR for anyone on an older CLI. `gh` also rejects an unknown `--json` field with a non-zero exit rather than omitting it, so requesting the newer fields against an old CLI fails the *entire* lookup — hence `GH_PR_LIST_LEGACY_JSON_FIELDS` (`url,number,title,headRefName`, present for as long as `pr list --json` has existed) as a one-shot retry. The runner distinguishes three outcomes so the caller knows whether that retry can help: JSON on success, `""` when `gh` ran and exited non-zero (bad flag, not authenticated, not a repo), and `null` when `gh` could not be run at all or timed out. + Related Git docs: [Lanes](./features/lanes/README.md), [Lane runtime isolation](./features/lanes/runtime.md), and [Pull requests](./features/pull-requests/README.md). --- diff --git a/docs/features/ade-code/README.md b/docs/features/ade-code/README.md index b8b5ddf04..00f62b2fa 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. 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. 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; 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. 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/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. | | `apps/ade-cli/src/tuiClient/productAnalytics.ts` | Pure TUI screen normalization plus runtime `analytics.capture` calls. `app.tsx` records a deduplicated open and normalized screen changes; it never owns a PostHog client, reads terminal/chat content, or emits per-render/poll events. Accepted events share the machine runtime's consent and 200-event daily budget. See [logging and product analytics](../../logging.md). | | `apps/ade-cli/src/tuiClient/externalSessionBrowser.ts` | Pure state/actions for the provider-native session browser. Filters and clamps rows, consumes the shared Continue/Copy policy, puts `Open existing ADE session` first for imported rows, and exposes only Copy actions after it so Enter never re-imports the original session. | @@ -37,7 +37,7 @@ Point Cursor’s browser inspector at the served page for layout debugging. The | `apps/ade-cli/src/tuiClient/pairedRemoteConnector.ts` | Canonical paired route policy for ADE Code remote: resolves credentials, orders or filters LAN/Tailscale/Relay candidates, obtains Relay account proof, opens the bounded runtime channel, verifies the account did not change mid-connect, records endpoint health, and returns structured path failures. | | `apps/ade-cli/src/tuiClient/remoteLaunchBudget.ts` | Shared total-deadline and per-attempt cancellation utilities used by paired and SSH remote connection setup. | | `apps/ade-cli/src/tuiClient/remoteBridge.ts` | Remote transport shim used by `remoteLauncher.ts`: connects to the existing paired sync-runtime bridge or starts `ade rpc --stdio` over SSH, performs JSON-RPC for selection/listing, then exposes a local one-connection bridge socket (Unix socket on POSIX, loopback TCP on Windows) to the regular TUI. Owns bounded frames, transport diagnostics, child-process cleanup, and bridge-socket teardown. | -| `apps/ade-cli/src/tuiClient/commands.ts` / `linearCommands.ts` | Slash command catalog and routing. `commands.ts` ships the active-session lifecycle commands (`/chat ask`, `/chat note`, `/chat settle`, `/chat unsettle`), `/lane delete` (right-pane confirmation form that destroys the active lane), `/effort` (reasoning-effort-only picker, a narrower companion to `/model`), provider-agnostic `/skills` for Agent Skill discovery, and provider-agnostic `/secrets` for masked project-secret listing/copying. `linearCommands.ts` requires a sub-command — bare `/linear` returns the usage hint instead of silently picking `workflows`. It also routes the session/lane attachment verbs (`attach` / `detach` / `issues` → `lane` domain session-scoped or lane-scoped actions) and the issue write-bridge verbs (`comment` / `set-state` / `assign` / `label` → `linear_issue_tracker` domain), reusing `--issue-id` / `--linear-issue-json` / attachment flags (`source`, `includeInPr`, `closeOnMerge`, `role`) parsing shared with the typed `ade linear` CLI commands in `cli.ts`. | +| `apps/ade-cli/src/tuiClient/commands.ts` / `linearCommands.ts` | Slash command catalog and routing. `commands.ts` ships the active-session lifecycle commands (`/chat ask`, `/chat note`, `/chat settle`, `/chat unsettle`) plus the target-addressable `/session` family (`/session snooze`, `/session wake`, `/session settle`, `/session unsettle`, `/session keep-active`, parsed and dispatched through `sessionLifecycle.ts`; `/chat settle` and `/chat unsettle` keep their own active-only dispatch in `app.tsx`), `/lane delete` (right-pane confirmation form that destroys the active lane), `/effort` (reasoning-effort-only picker, a narrower companion to `/model`), provider-agnostic `/skills` for Agent Skill discovery, and provider-agnostic `/secrets` for masked project-secret listing/copying. `linearCommands.ts` requires a sub-command — bare `/linear` returns the usage hint instead of silently picking `workflows`. It also routes the session/lane attachment verbs (`attach` / `detach` / `issues` → `lane` domain session-scoped or lane-scoped actions) and the issue write-bridge verbs (`comment` / `set-state` / `assign` / `label` → `linear_issue_tracker` domain), reusing `--issue-id` / `--linear-issue-json` / attachment flags (`source`, `includeInPr`, `closeOnMerge`, `role`) parsing shared with the typed `ade linear` CLI commands in `cli.ts`. | | `apps/ade-cli/src/tuiClient/providerMetadata.ts` | Provider labels, family labels, token normalization, and provider lookup helpers shared by setup rows and the model picker. Keeps Anthropic/OpenAI/Factory aliases mapped onto the TUI's provider ids and decides which providers support runtime catalog refresh. | | `apps/ade-cli/src/tuiClient/modelState.ts` | Pure model/setup state for draft chats and `/model`: GPT-5.6 Sol default plus Sol/Terra/Luna ordering, Chat vs CLI interface mode, Cursor chat-vs-CLI availability reconciliation, Codex preset/approval/sandbox mapping, provider-specific permission summaries, host-aware reasoning defaults/visible tiers, Fast Mode support, and the `SetupPaneRow` list rendered in setup panes. GPT-5.6 labels `low` as Light and `xhigh` as Extra High, exposes Max on all three models, and adds Ultra after Max on Sol/Terra. | | `apps/ade-cli/src/tuiClient/modelPickerController.ts` | Small adapter between right-pane model-picker state and `modelPickerLayout.ts`: supplies active model/reasoning/interface, favorites/recents, AI status, footer focus, lane label, and provider refresh routing. | @@ -47,6 +47,9 @@ 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/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. | | `apps/ade-cli/src/tuiClient/drawerSelection.ts` | Pure selectors for the lane / chat drawer (active row, expanded groups, keyboard navigation). | | `apps/ade-cli/src/tuiClient/drawerLayout.ts` | Single source of truth for drawer row layout: `computeDrawerLayout` (expanded chat block, closed-CLI group rows, and compact per-lane chat previews under a height budget) and `drawerMouseHitForLayout`, shared by the Drawer renderer and the app's mouse hit-testing so the two cannot drift. | | `apps/ade-cli/src/tuiClient/newLaneForm.ts` | Pure model for the `/new lane` form: start-from modes (primary / child / import), Linear issue + setup-template fields, per-mode field lists, and `buildNewLaneSubmission` mapping form values onto `lane.create` / `lane.createChild` / `lane.importBranch` payloads. | @@ -147,7 +150,7 @@ For the embedded runtime there is no `projects.add` step — the in-process runt `apps/ade-cli/src/tuiClient/app.tsx` is the Ink root. Layout: - **Header** — project name, active lane, branch, the terminal client frame, and the shared machine account state. ADE Code reads account status once while the TUI surface is active; it does not add a poll loop. `ade login` remains the canonical sign-in command. -- **Drawer** (toggled with the configured shortcut) — two modes, **lanes** (default) and **chats**, switched with `Tab` while the drawer is focused. Lane cards show name + status (no branch ref — that lives in lane details). Every lane shows its chats: the selected lane expands the full chat block (the same tight single-row chats every lane shows, distinguished only by a violet border plus a trailing `+ new chat` row — there is no `CHATS` header), while every other lane renders a compact always-visible preview (the lane's chats as single rows, plus a `+N more` tail only when the row budget can't fit them all) whose rows are clickable and select lane + chat in one step. The TUI enriches both chat and tracked-CLI rows from `session.list`: explicit asks render the blocking question in amber, status notes render inline (`done: …` when settled), and a sanitized last-output preview is the first fallback before summary or goal. Settled rows dim into the quiet glyph tier, and last-turn failures render as failures. Ended tracked CLI sessions are hidden behind a `closed (N)` row in the expanded lane; expanding it shows dim one-line rows with provider glyph, title, and relative end time, and `↵` resumes a resumable closed CLI session through the same terminal resume path as desktop. Continuation forwards the stored model, reasoning, Fast Mode, permission mode, and exact Codex approval/sandbox/config controls through the shared launch-field mapper. Row layout and mouse hit-testing share one pure model (`drawerLayout.ts: computeDrawerLayout` / `drawerMouseHitForLayout`) so open chats, closed toggles, closed sessions, and `+ new chat` cannot drift. In **lanes** mode, `↑`/`↓` move lane cards; `↓` on an available lane enters **chats** mode for that lane; `↵` opens lane details or resumes the lane's last chat. In **chats** mode, `↑`/`↓` move within the lane's chat rows, closed group, and `+ new chat`; highlighting a chat previews it in the centre pane via `resolveTuiChatRefreshTarget` before `↵` commits the session. `Esc` returns from the chat list to **lanes**. Lane and chat selection drive the right pane's context. +- **Drawer** (toggled with the configured shortcut) — two modes, **lanes** (default) and **chats**, switched with `Tab` while the drawer is focused. Lane cards show name + status (no branch ref — that lives in lane details). Every lane shows its chats: the selected lane expands the full chat block (the same tight single-row chats every lane shows, distinguished only by a violet border plus a trailing `+ new chat` row — there is no `CHATS` header), while every other lane renders a compact always-visible preview (the lane's chats as single rows, plus a `+N more` tail only when the row budget can't fit them all) whose rows are clickable and select lane + chat in one step. The TUI enriches both chat and tracked-CLI rows from `session.list`: explicit asks render the blocking question in amber, status notes render inline (`done: …` when settled), and a sanitized last-output preview is the first fallback before summary or goal. Settled rows dim into the quiet glyph tier, and last-turn failures render as failures. Snooze is a second, independent quiet tier: a snoozed row carries a text-only `z` marker plus its wake label ("wakes in 3h" / "wakes tomorrow" / "wakes when asked" / "wakes now"), and a row that woke early carries a `*` marker naming the reason ("needs approval" / "errored" / "turn finished") until it is visited, at which point the marker is cleared. Because snooze is a visibility overlay rather than a phase, a snoozed row that is blocking on the user stays in its normal place — `isSessionFiledAsSnoozed` yields to a `needs_you` phase — while `isSessionSnoozed` remains the raw column read used for row chrome. Ended tracked CLI sessions are hidden behind a `closed (N)` row in the expanded lane; expanding it shows dim one-line rows with provider glyph, title, and relative end time, and `↵` resumes a resumable closed CLI session through the same terminal resume path as desktop. Continuation forwards the stored model, reasoning, Fast Mode, permission mode, and exact Codex approval/sandbox/config controls through the shared launch-field mapper. Row layout and mouse hit-testing share one pure model (`drawerLayout.ts: computeDrawerLayout` / `drawerMouseHitForLayout`) so open chats, closed toggles, closed sessions, and `+ new chat` cannot drift. In **lanes** mode, `↑`/`↓` move lane cards; `↓` on an available lane enters **chats** mode for that lane; `↵` opens lane details or resumes the lane's last chat. In **chats** mode, `↑`/`↓` move within the lane's chat rows, closed group, and `+ new chat`; highlighting a chat previews it in the centre pane via `resolveTuiChatRefreshTarget` before `↵` commits the session. `Esc` returns from the chat list to **lanes**. Lane and chat selection drive the right pane's context. - **ChatView** — the main transcript. Renders user, assistant, tool, and system events from `chat/event` notifications. For the non-Claude runtimes the transcript mirrors the desktop work log: each tool call is one stacked line (`✓ read apps/x.ts`) with the desktop slug + target-arg derivation (pure helpers imported from `apps/desktop/.../chatTranscriptRows` and `toolPresentation`), MCP events prefer the app/plugin/server name plus action instead of a generic `mcp` label, `web_search` events group with tool calls and include the first provider action query/title/URL when available (plus, for Codex 0.145 structured `results`, up to three `title — domain` preview lines with a `+N more` tail derived by `webSearchResultPreviewLines`), generated/viewed-image lifecycle updates collapse to one concise notice per item, reasoning renders as a collapsed `Thinking…`/`Thought` row with a one-line preview, file-change groups collapse to one summary row and expand to typed file rows whose `diff` action opens the turn diff in the right pane, and every row truncates to the pane width (rows never wrap — the scroll math assumes 1 row = 1 line). Codex app-server runtime events (`codex_safety_buffering`, `codex_moderation_metadata`, `codex_sleep`, `codex_thread_deleted`, `codex_turn_stalled`) render as concise transcript notices instead of disappearing into generic activity. A valid ` ```mosaic ` fence (the interactive card the desktop transcript renders — see [chat composer-and-ui.md](../chat/composer-and-ui.md)) collapses to a single dim summary line here via `summarizeMosaicCard` from `apps/desktop/src/shared/chatMosaic.ts`, since the TUI cannot render the interactive form; a fence that fails to parse falls back to the plain code block. The most recent expandable failure id is tracked so `Enter` can drill into it. Mouse selection is ADE-owned so it can follow virtual transcript rows: drag selects, edge-drag scrolls, wheel scrolling preserves the highlighted range, Shift-click extends the current anchor, and `Ctrl+C` / delivered `Cmd+C` copy selected chat text. - **Composer** — multi-line input with mention completion (`@…`) sourced from `MentionPalette` and slash command completion from `SlashPalette`. Both triggers are detected cursor-relatively through the shared `apps/desktop/src/shared/composerTriggers.ts` module (`detectComposerTrigger`), so a `/command` or `@file` token is recognized anywhere in the draft — not just at position 0 (`fix @src/foo.ts then run /test`). Both palettes stay visible with a no-match row while the user is actively typing. Selecting a suggestion splices exactly the trigger span (`replaceComposerTriggerSpan`) rather than replacing the whole prompt; a lone leading `/command` keeps the legacy fill-the-prompt behavior. `Tab` completes the highlighted slash command, and for a **mid-sentence** slash trigger `Enter` completes into the draft (instead of submitting/running), mirroring the desktop command menu — a leading-only command still runs on `Enter`. Confirmed tokens render as colored chips in the prompt rows via `findConfirmedComposerTokens` + `segmentPromptLineText`: inserted `@file` mentions and `/command` names matching the built-in or runtime catalog paint cyan (files) or violet (commands) and bold, while unmatched `@`/`/` text stays plain. URLs detected by shared `smartLinks.ts` also paint violet and add a compact `links [provider label]` row above the raw prompt; GitHub, Linear, ADE, and generic web labels are deterministic and do not require metadata fetching in the terminal. Character Backspace/Delete removes the whole intersected URL, while the canonical URL remains the submitted prompt text. Mention completion publishes local lane/chat hits immediately, then debounces remote file/git/PR RPCs; file results are cached per lane+query and git/PR results are cached per lane for the open TUI session. Pending tool approvals surface as `ApprovalPrompt`. AskUserQuestion-style answer requests (one or more questions, each with options) render every question inline with its option list and an `N of M answered` header. While such a request is pending and the composer is empty, keyboard input drives the picker instead of the prompt: `↑`/`↓` move the selected option (or move between questions when the active question has no options), `←`/`→` switch the active question, `1`-`9` within the option count highlights that option without submitting, and `Enter` submits the active question's current selection (advancing to the next unanswered question, or finalizing the whole request once every question is answered). If the next printable input after a digit quick-select is text, that digit becomes the start of a free-text answer and the previous option highlight is restored; digits above the option count type directly into the composer. Clicking an option still submits it immediately. The deny chip still declines the whole request. Selection lives in `pendingInput.ts`'s `PendingQuestionSelectionState`. - **RightPane** — context-sensitive drawer for slash command output. The "right" placement commands (see below) render their results here as forms, lists, diffs, help text, or rendered objects. `/secrets` opens a masked project-secret list and copies the selected secret value to the local system clipboard with `Enter` or `c`; it never reveals values inline and only uses the read actions behind the existing project-secret RPC path. When a chat is active the default content is the **Chat Info** view (`kind: "chat-info"`): provider/model header, lane label, streaming/idle indicator with context-percent + token summary, plan steps for the current turn (plus the provider's plan explanation / streaming text when present), Codex `/goal` block when present, a roster of subagents (running first, then teammates and background), and — below the roster, like the Droid Missions block — **TASKS** (latest `todo_update` snapshot, desktop ChatTasksPanel parity), **SCHEDULE** (Claude wakeups/cron/`/loop` from `scheduled_work_update` via `deriveScheduleItems`, desktop Chat Info parity, plus `⏰ next wake ` from the active session summary), **BACKGROUND** (`background_task` work from `scheduled_work_update` via `deriveBackgroundItems`, each rendered as a `$