From 5dfc78e85e5ac8ff42f8737e065147c01dbc7a24 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 14 Jul 2026 00:42:15 -0700 Subject: [PATCH 01/14] Add sidebar v2 beta: flat adaptive-density thread list with settled lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new thread sidebar behind a Settings → Beta toggle (sidebarV2Enabled, default off). One flat recency-sorted list across projects: active threads render as two-line cards (favicon, title, time, status word · branch · provider glyph + model · machine), settled threads collapse to slim one-liners. Approval-blocked threads pin to the top with wait time; failed sessions (session.lastError) finally get a visible state. Settled is an event-sourced lifecycle state mirroring the archived pattern: - settledOverride ("settled" | "active" | null) + settledAt on the thread, thread.settle/thread.unsettle commands, thread.settled/thread.unsettled events, projection columns via migration 033. - effectiveSettled() computes the visible state: manual override beats auto rules (PR merged/closed, N days of inactivity — client setting, default 3, nullable). Idempotency is by event re-emission since the engine rejects zero-event commands. - Activity auto-unsettles server-side: turn start, live session start, approval/user-input requests. Session stop/error status writes do not. Manually settling a thread whose worktree is orphaned offers a one-click worktree removal (re-validated at click time, never forced). The v2 component is a sibling swapped at the single AppSidebarLayout mount point; shared chrome moved to sidebar/SidebarChrome.tsx. The data model is flag-independent and ships dark for v1 users, so toggling is a pure view swap. Plan: .plans/21-sidebar-v2-beta.md Co-Authored-By: Claude Fable 5 --- .plans/21-sidebar-v2-beta.html | 510 +++++++++ .plans/21-sidebar-v2-beta.md | 285 +++++ .../Layers/OrchestrationEngine.test.ts | 2 + .../Layers/ProjectionPipeline.ts | 34 + .../Layers/ProjectionSnapshotQuery.test.ts | 4 + .../Layers/ProjectionSnapshotQuery.ts | 20 + apps/server/src/orchestration/Schemas.ts | 4 + .../orchestration/commandInvariants.test.ts | 4 + .../src/orchestration/decider.settled.test.ts | 276 +++++ apps/server/src/orchestration/decider.ts | 128 ++- .../orchestration/projector.settled.test.ts | 88 ++ .../src/orchestration/projector.test.ts | 2 + apps/server/src/orchestration/projector.ts | 28 + .../Layers/ProjectionRepositories.test.ts | 2 + .../persistence/Layers/ProjectionThreads.ts | 10 + apps/server/src/persistence/Migrations.ts | 2 + .../033_ProjectionThreadsSettled.ts | 23 + .../persistence/Services/ProjectionThreads.ts | 2 + .../Layers/ProviderSessionReaper.test.ts | 2 + .../src/relay/AgentAwarenessRelay.test.ts | 6 + apps/server/src/server.test.ts | 6 + apps/web/src/components/AppSidebarLayout.tsx | 12 +- .../web/src/components/ChatView.logic.test.ts | 2 + apps/web/src/components/ChatView.logic.ts | 2 + .../components/CommandPalette.logic.test.ts | 2 + apps/web/src/components/Sidebar.logic.test.ts | 89 ++ apps/web/src/components/Sidebar.logic.ts | 44 + apps/web/src/components/Sidebar.tsx | 107 +- apps/web/src/components/SidebarV2.tsx | 978 ++++++++++++++++++ .../components/settings/BetaSettingsPanel.tsx | 105 ++ .../settings/SettingsSidebarNav.tsx | 3 + .../src/components/sidebar/SidebarChrome.tsx | 113 ++ apps/web/src/hooks/useThreadActions.ts | 127 ++- apps/web/src/lib/threadSort.test.ts | 2 + apps/web/src/routeTree.gen.ts | 21 + apps/web/src/routes/settings.beta.tsx | 11 + apps/web/src/worktreeCleanup.test.ts | 2 + packages/client-runtime/package.json | 4 + .../src/operations/commands.test.ts | 39 +- .../client-runtime/src/operations/commands.ts | 22 + .../client-runtime/src/state/entities.test.ts | 2 + .../src/state/shellReducer.test.ts | 2 + .../src/state/threadCommands.ts | 18 + .../client-runtime/src/state/threadDetail.ts | 2 + .../src/state/threadReducer.test.ts | 58 ++ .../client-runtime/src/state/threadReducer.ts | 24 + .../src/state/threadSettled.test.ts | 178 ++++ .../client-runtime/src/state/threadSettled.ts | 51 + .../src/state/threads-sync.test.ts | 2 + packages/contracts/src/orchestration.test.ts | 105 ++ packages/contracts/src/orchestration.ts | 49 + packages/contracts/src/settings.test.ts | 14 + packages/contracts/src/settings.ts | 6 + 53 files changed, 3521 insertions(+), 113 deletions(-) create mode 100644 .plans/21-sidebar-v2-beta.html create mode 100644 .plans/21-sidebar-v2-beta.md create mode 100644 apps/server/src/orchestration/decider.settled.test.ts create mode 100644 apps/server/src/orchestration/projector.settled.test.ts create mode 100644 apps/server/src/persistence/Migrations/033_ProjectionThreadsSettled.ts create mode 100644 apps/web/src/components/SidebarV2.tsx create mode 100644 apps/web/src/components/settings/BetaSettingsPanel.tsx create mode 100644 apps/web/src/components/sidebar/SidebarChrome.tsx create mode 100644 apps/web/src/routes/settings.beta.tsx create mode 100644 packages/client-runtime/src/state/threadSettled.test.ts create mode 100644 packages/client-runtime/src/state/threadSettled.ts diff --git a/.plans/21-sidebar-v2-beta.html b/.plans/21-sidebar-v2-beta.html new file mode 100644 index 00000000000..146c8c0c521 --- /dev/null +++ b/.plans/21-sidebar-v2-beta.html @@ -0,0 +1,510 @@ + + + + + +T3 Code — Sidebar v2 Beta Plan + + + +
+ +

Sidebar v2 — beta plan

+

One flat, recency-sorted thread list where row size is earned by state — and “settled” becomes a real lifecycle stage users control. Familiar mechanics only: nothing here requires learning a new gesture.

+
+ beta · toggle in settings + plan: .plans/21-sidebar-v2-beta.md + original 5 concepts ↗ +
+ +

01What we're building

+

The hybrid the concepts page predicted, filtered through “meet users where they are”: concept 4's adaptive density as the skeleton, concept 1's card layout (trimmed to two structured lines — no generated text) for active rows, and concept 3 reduced to a sort rule (approval-blocked threads pin above the recency flow — no tiers, no inline approve).

+
+
+

Changes

+
    +
  • Flat recency list — project group headers gone; project becomes a chip. This is how Claude Code, Codex, and Cursor already present sessions.
  • +
  • Settled lifecycle state — explicit, user- or rule-triggered. Settled rows collapse to slim one-liners (≈ today's density).
  • +
  • Four states, three colors — Needs approval (amber, pinned with wait time), Working (sky, pulsing), Ready (no color, no label — the normal resting state; bold title until visited), Failed (red).
  • +
  • Structured metadata only — the card's second line is status word · branch · harness + model · machine. No summaries or free text to generate; every field is data the shell already carries.
  • +
  • Project identity = favicon — the existing ProjectFavicon per-row, not a text chip. Harness (Claude Code / Codex / other) is a tinted glyph on the model; machine shows only when the thread lives on another computer.
  • +
  • Failed sessions visiblesession.lastError finally gets a red state; today a dead session shows nothing.
  • +
  • Settle → worktree cleanup — settling an orphaned-worktree thread offers a one-click remove.
  • +
+
+
+

Deliberately not

+
    +
  • Inline approve/reject in the sidebar — needs a safety story first.
  • +
  • Message snippets (concept 2) — streaming churn unsolved.
  • +
  • Ops-grid density mode (concept 5).
  • +
  • Auto-archive of long-settled threads (future: settled ≥ 30d).
  • +
  • Removing v1 — it stays the default; v2 is opt-in.
  • +
+
+
+ +

02The mock

+

Hover any card and hit “Settle” — it collapses into the slim tail. That height change is the whole design: it happens only at lifecycle transitions, never from streaming updates, which is what keeps the list calm.

+ +
+ + +
+

Card anatomy (active threads, ~52px)

+ Line 1 — project favicon · title (bold = unread) · diff stats when present · time + Line 2 — structured metadata only: status word (only when colored) · branch · harness glyph + model · machine (only when not this one). No generated text — everything on the card is data we already have. +

Four states, three colors

+ APPROVAL — blocked mid-run, pinned to top with “waiting Xm” + WORKING — pulsing rail, live elapsed timer + Ready — the default; needs no label. Stopped, waiting on you. Bold title until visited. + FAILED — session error, invisible today +

Harness · model · machine

+ Claude Code · Codex · a neutral glyph for “other” — one tinted glyph before the model name, deliberately quiet. + ⏻ machine appears only on threads running somewhere other than the current computer; local threads show nothing. +

Slim row (settled, ~28px)

+ favicon · title · PR when notable · time. Visually ≈ today's v1 row, so density for history is preserved. +

Sort

+ Approval first (by wait time) → then pure recency. No sections, no headers, no “show more” — the tail just scrolls, virtualized. +
+
+ +

03The settled model

+

Concept 4 derived “settled” passively. This version makes it an acknowledgment with an act attached — Gmail's archive, GitHub notifications' “Done” — which is what makes row heights stable and the list trustworthy.

+ + + + + + + + + Active + rich card · in rollups + + Settled + slim row · out of rollups + + Archived + existing flow, unchanged + + user settles · PR merged/closed · inactive ≥ 3d + + any real activity · user un-settles + + manual (future: 30d auto) + + +

Storage is one override, everything else is computed. No background jobs, no sweeper:

+
effectiveSettled(thread) =
+  override === "settled"true   // manual settle
+  override === "active"false  // manual keep-active (beats auto rules)
+  pr.state ∈ {merged, closed}                      → true   // auto — strongest signal
+  lastActivityAt < now − inactivityThreshold       → true   // auto — backstop, default 3d, configurable
+  otherwise                                        → false
+
    +
  • Stored: settledOverride: "settled" | "active" | null + settledAt, server-side next to archivedAt — it must sync across desktop/remote, and worktree cleanup needs the server to know. Mirrors the thread.archived event pattern exactly.
  • +
  • Activity clears the override (new user message, session start, approval request) — a settled thread that wakes up expands and floats back up. Settled is never a hole a thread disappears into.
  • +
  • Viewing ≠ settling. Visiting clears unread (existing lastVisitedAt); settling asserts “this work is concluded.” Conflating them would make auto-unsettle fight the user.
  • +
+
Inbox-zero hedge: manual settling must feel like optional satisfaction, not homework. The auto rules guarantee a user who never touches the affordance still gets a tidy list. Beta telemetry decides how prominent the affordance stays.
+ +

04Swap boundary

+

Whole-component swap, one seam. Today's Sidebar.tsx (~3,750 lines) takes zero props and mounts at exactly one place. Everything that must behave identically in both sidebars already lives outside the component in shared hooks and stores — and what lives inside (project grouping, drag-and-drop, show-more) is precisely what v2 deletes.

+ + + + + + + + + AppSidebarLayout.tsx:93 + {sidebarV2Enabled ? <ThreadSidebarV2/> : <ThreadSidebar/>} + + + + Sidebar.tsx (v1 — untouched, default) + project groups · dnd · show-more · collapse + ignores new settled fields entirely + + SidebarV2.tsx (new, beta) + flat virtualized list · card/slim rows · settle + no imports from Sidebar.tsx, ever + + + + Shared — both components call the same modules + Sidebar.logic.ts · ThreadStatusIndicators · useThreadActions · threadSelectionStore · uiStateStore · threadSort.ts · keybindings + anything both need moves here first — never cross-imported + + +
    +
  • The settled data model ships dark and is flag-independent — v1 simply ignores the new fields. The toggle is a pure view swap: flipping it never migrates data, so switching back and forth is always safe.
  • +
  • Toggle lives in a new “Beta features” settings panel: sidebarV2Enabled (default false) + sidebarAutoSettleAfterDays (default 3, nullable = never) in ClientSettingsSchema.
  • +
  • Inherited for free via shared hooks: click-to-open, multi-select, context menu, inline rename, thread-jump ⌘1–9, archive, PR link, port/terminal/remote indicators.
  • +
+ +

05Phases

+ +
+
P1Settled data modelships dark · no UI change
+
+
    +
  • contracts/settings.ts — add sidebarV2Enabled, sidebarAutoSettleAfterDays.
  • +
  • contracts/orchestration.tssettledOverride + settledAt on the thread (decoding defaults → old data decodes unchanged); thread.settled / thread.unsettled events; thread.settle / thread.unsettle commands.
  • +
  • server/orchestration/decider.ts — handle both commands (settle is idempotent for bulk); activity paths (user message, session start, approval request) emit thread.unsettled when an override is set.
  • +
  • client-runtime/threadReducer.ts — reduce both events (archive cases at :87–101 are the template).
  • +
  • Pure effectiveSettled(shell, {now, autoSettleAfterDays}) in client-runtime; unit-test the full truth table (override × PR state × inactivity).
  • +
+

Verify: typecheck · reducer + predicate tests · zero visible change.

+
+
+ +
+
P2SidebarV2 component + beta togglethe visible feature
+
+
    +
  • “Beta features” settings panel + route; swap seam in AppSidebarLayout.tsx.
  • +
  • SidebarV2.tsx: flat virtualized list, sort = needs-approval (by wait) → recency; single SidebarV2Row with card / slim variants from the same shell data. Rows lead with the existing ProjectFavicon component (environmentId + cwd are already on the shell).
  • +
  • Consolidate status derivation in Sidebar.logic.ts to four visual states: Needs approval, Working (incl. connecting), Ready (merges today's Awaiting Input / Plan Ready / Completed-unseen — same user action, so one unlabeled state), and new Failed (session.lastError, non-running) — kept shared so v1 can adopt Failed later.
  • +
  • Meta line: harness glyph + model from the shell's provider/model selection; machine label from the thread's environment vs. the current one (render only when they differ). Both are display-only lookups, no new data plumbing.
  • +
  • Unread = bold-until-visited from existing uiStateStore.threadLastVisitedAtById.
  • +
  • Height changes only on settle/unsettle transitions (auto-animate); streaming never resizes rows.
  • +
  • useThreadActions: settleThread / unsettleThread mirroring archiveThread, minus navigation — settling never navigates away. Affordances: row hover button, context menu, bulk multi-select. No keyboard shortcut yet.
  • +
+

Verify: toggle on → v2; toggle off → v1 pixel-identical to before · settle round-trips across a desktop+remote pair · killed session → Failed card.

+
+
+ +
+
P3Worktree cleanup hooksettle = reclaim disk
+
+
    +
  • On manual settle where the worktree is orphaned (getOrphanedWorktreePathForThread): non-blocking “Worktree kept · Remove?” prompt → vcsEnvironment.removeWorktree. Never automatic, never blocks the settle.
  • +
  • Skip the prompt when the worktree has uncommitted changes or unpushed commits (from vcs status the sidebar already has).
  • +
  • Auto-settle never touches worktrees. Follow-up (tracked, out of scope): settings “Storage” view listing orphaned worktrees of settled threads with sizes + bulk remove.
  • +
+
+
+ +
+
P4Telemetry & beta exitfind the balance
+
+
    +
  • Count settles by source: manual / auto-PR / auto-inactivity / bulk. Count un-settles: manual vs. activity-driven.
  • +
  • Activity-driven un-settles of manual settles = the model fighting users → retune threshold or triggers.
  • +
  • Manual settling ≈ 0% → shrink the affordance, lean on auto rules. High → a habit worth building on (keyboard shortcut, etc.).
  • +
  • Track toggle-off rate after trying v2.
  • +
+
+
+ +

06Open questions

+ + + + + + +
QuestionCurrent thinking
Server-side activity observationIf decider activity paths are too scattered for unsettle-on-activity, fall back to client-side: compute with activity timestamps, override only stores manual state (activity newer than settledAt wins). Decide in P1.
Shell projectionsettledOverride/settledAt must be in the thread shell stream — the sidebar never loads details. Verify first thing in P1.
Diff stats on cardsRequires checkpoint data in the shell; if absent, defer diff stats rather than loading details per row.
Project grouping in v2Not planned; v1 stays available for users who want groups. Revisit only if beta feedback demands it.
+ +
T3 Code · Sidebar v2 beta · plan doc pair: .plans/21-sidebar-v2-beta.md (implementation detail) + this page (design + rationale). Mock data mirrors the original concepts page (from live state.sqlite, Jul 13).
+
+ + + + diff --git a/.plans/21-sidebar-v2-beta.md b/.plans/21-sidebar-v2-beta.md new file mode 100644 index 00000000000..8cda132e80a --- /dev/null +++ b/.plans/21-sidebar-v2-beta.md @@ -0,0 +1,285 @@ +# Sidebar v2 (beta): flat adaptive-density list with settled threads + +Status: planned +Mocks: https://hsyscdqldmk5.postplan.dev/ (concept 4 base + concept 1 card layout + concept 3 needs-you pinning) + +## Summary + +A new thread sidebar behind a client-settings beta toggle. Core changes vs. the +current sidebar: + +- **Flat recency list.** Project group headers are gone; project identity moves + onto the row as the existing `ProjectFavicon` (environmentId + cwd). This + matches the session lists users know from Claude Code, Codex, and Cursor. +- **Adaptive density via a "settled" lifecycle state.** Active threads render + as two-line cards: favicon + title + time, then a structured meta line + (status word · branch · harness glyph + model · machine). Settled threads + collapse to slim one-liners, roughly today's row height. No generated or + free-text summaries anywhere — every field on a card is data the shell + already carries. +- **Settled is an explicit state**, not a derived one: users settle threads + manually, or threads auto-settle (PR merged/closed, inactivity). Any real + activity auto-unsettles. Settled is a lifecycle stage between active and + archived — it stays in the list, just quiet. +- **Four visual states, three colors.** Needs approval (amber, pinned above + the recency flow with "waiting Xm"), Working (sky, pulsing, elapsed timer), + **Ready** (uncolored and unlabeled — the normal resting state: the agent + stopped and is waiting on you, whether it finished, asked a question, or + proposed a plan; bold title until visited is the only signal), Failed (red, + `session.lastError` — a dead session shows nothing today). Today's Awaiting + Input / Plan Ready / Completed-unseen pills all collapse into Ready: they + differ in detail, not in what the user should do (look at the thread). + Color is reserved for "act now" (amber), "in motion" (sky), "broken" (red). +- **Harness / model / machine as quiet metadata.** A tinted glyph before the + model name distinguishes Claude Code / Codex / other; a machine label + renders only when the thread lives on a different computer than the one + you're looking at. Display-only lookups from shell data, no new plumbing. +- **Settled feeds cleanup.** Settling a thread offers/permits worktree cleanup; + long-settled threads are candidates for future auto-archive. + +The v2 component is a sibling of the current sidebar, swapped at a single mount +point. The current sidebar is untouched except for the swap seam. + +## Rationale + +Full design discussion lives in the session that produced the mocks; the short +version: + +- We aren't in a position to teach users a new interaction model. Every v2 + mechanic maps to an existing habit: flat recency list (Claude Code / Codex / + Cursor), settle (Gmail archive / GitHub notifications "Done"), unread-bold + (email). Zero new gestures. +- "Settled" makes the adaptive-density row-height rule *stable*: height changes + only at real lifecycle transitions, never from ambient re-rendering. This + kills concept 4's "jumpy list" risk. +- Viewing a thread does **not** settle it. Visiting clears unseen (existing + `lastVisitedAt` mechanics); settling asserts "this work is concluded." If + viewing settled, auto-unsettle would fight the user and the state would stop + meaning anything. +- Settling is optional by design. The auto rules are the hedge against + inbox-zero fatigue: a user who never touches the affordance still gets a + naturally tidy list. Beta telemetry question: what fraction of settles are + manual vs. auto? + +## The settled model + +``` +Active ──(user settles | PR merged/closed | inactivity ≥ threshold)──▶ Settled +Settled ──(new user message | session starts | approval requested | + PR reopened | user un-settles)──▶ Active +Settled ──(existing archive flow, or future auto-archive)──▶ Archived +``` + +Settled is a **computed property with one stored override**: + +``` +effectiveSettled(thread) = + override === "settled" → true (manual settle) + override === "active" → false (manual keep-active) + pr.state ∈ {merged, closed} → true (auto) + lastActivityAt < now − inactivityThreshold → true (auto) + otherwise → false +``` + +- The stored field is a tri-state: `settledOverride: "settled" | "active" | null` + plus `settledAt: IsoDateTime | null` (set when override becomes "settled", + used for display and future auto-archive aging). +- The auto cases need no background job and no events — they fall out of data + the sidebar already streams (`vcs.status` change request state, + `latestUserMessageAt` / turn timestamps). Auto-unsettle for the time-based + case is free: activity moves the timestamp, the predicate flips. +- **Activity clears the override.** Any thread activity event (new user + message, session start, approval request) resets `settledOverride` to null + server-side, so a manually settled thread that wakes up becomes active again + and later auto-rules apply fresh. Likewise, manually un-settling a merged-PR + thread sets `settledOverride: "active"`, which beats the PR auto-rule. +- Inactivity threshold: default 3 days, client setting, `null` = never + auto-settle by time. + +Server-side storage (next to `archivedAt` on the thread) rather than +client-local: settled must sync across desktop/remote environments, and the +worktree-cleanup hook needs the server to know. This mirrors the +`archivedAt` / `thread.archived` / `thread.unarchived` pattern exactly. + +Consequences of being settled: + +- Row collapses to the slim one-liner. +- Excluded from status rollups (any badge counts, aggregate status dots). +- Eligible for worktree cleanup prompting (see below). +- Future: settled ≥ 30 days → auto-archive candidate (not in this phase). + +## Swap strategy: component swap at the mount point + +Decision: **swap the whole sidebar component**, not internals. + +The current `Sidebar.tsx` (~3750 lines) takes no props — it reads everything +from hooks/atoms/stores — and `AppSidebarLayout.tsx:93` is its single mount +point. The truly shared behavior (thread-jump keybindings, selection store, +uiStateStore, `useThreadActions`, context-menu via `readLocalApi()`) already +lives *outside* the component in hooks and stores, so a sibling component +inherits all of it by calling the same hooks. What lives inside Sidebar.tsx +(project grouping, drag-and-drop, show-more, per-project collapse) is exactly +the stuff v2 deletes — threading a variant flag through it would mean touching +hundreds of conditional branches for no benefit. + +```tsx +// AppSidebarLayout.tsx — the entire seam +const sidebarV2 = useClientSettings((s) => s.sidebarV2Enabled); +... +{sidebarV2 ? : } +``` + +Rules for the seam: + +- `SidebarV2.tsx` imports freely from `Sidebar.logic.ts`, + `ThreadStatusIndicators.tsx`, `useThreadActions`, `threadSelectionStore`, + `uiStateStore` — shared logic stays shared. +- `SidebarV2.tsx` must not import from `Sidebar.tsx`, and vice versa. Anything + both need moves into `Sidebar.logic.ts` (or a new shared module) first. +- The settled *data model* (contracts, server, reducer) is flag-independent — + it ships dark and is simply unused by the v1 component. Only the *UI* is + gated. This keeps the toggle a pure view swap: flipping it never migrates + data, so switching back and forth is always safe. +- v1 remains the default. Deleting v1 is a separate future decision once beta + feedback lands. + +## Implementation phases + +### Phase 1 — Settled data model (ships dark, no UI) + +Mirror the archived pattern end-to-end: + +1. `packages/contracts/src/settings.ts`: add to `ClientSettingsSchema`: + - `sidebarV2Enabled: boolean` (default false) + - `sidebarAutoSettleAfterDays: number | null` (default 3) +2. `packages/contracts/src/orchestration.ts`: + - Thread shell: `settledOverride: "settled" | "active" | null`, + `settledAt: IsoDateTime | null` (both with decoding defaults of null — + old persisted threads decode unchanged). + - Events: `thread.settled` (threadId, settledAt, updatedAt), + `thread.unsettled` (threadId, updatedAt). Payload schemas alongside + `ThreadArchivedPayload` / `ThreadUnarchivedPayload`. + - Commands: `thread.settle`, `thread.unsettle`. +3. `apps/server/src/orchestration/decider.ts`: handle both commands, mirroring + archive/unarchive. Invariants in `commandInvariants.ts` + (`requireThreadNotArchived` applies; settle of an already-settled thread is + a no-op rather than an error — idempotent for bulk operations). + In the decider paths that record thread activity (user message appended, + session started, approval requested): if `settledOverride !== null`, also + emit `thread.unsettled` to clear it. +4. `packages/client-runtime/src/state/threadReducer.ts`: reduce both events + (`threadReducer.ts:87-101` is the archive template). Tests alongside the + archive reducer tests. +5. `packages/client-runtime` (new `threadSettled.ts` or in `threadSort.ts`): + pure `effectiveSettled(shell, { now, autoSettleAfterDays })` implementing + the predicate above, plus `lastActivityAt(shell)` (max of + latestUserMessageAt, latest turn completion, session start). Unit-test the + truth table: each override state × PR state × inactivity. + +Verification: `bun run typecheck`, reducer + predicate unit tests. No visible +change anywhere. + +### Phase 2 — SidebarV2 component + beta toggle + +1. Settings UI: "Beta features" section (new panel in + `apps/web/src/components/settings/SettingsPanels.tsx` + route, matching the + existing settings-page pattern) with the v2 toggle and, indented under it, + the auto-settle threshold control. +2. Swap seam in `AppSidebarLayout.tsx` as above. +3. `apps/web/src/components/SidebarV2.tsx`, reusing shared modules. Structure: + - **One flat virtualized list** of all non-archived threads across + projects. Sort key: (needs-approval first, by wait time) → then recency + (`latestUserMessageAt`, reusing `threadSort.ts`). No show-more, no + project collapse, no dnd. + - **Row variants** (single `SidebarV2Row` with a `variant` prop, both + variants rendered from the same shell data): + - `card` (~52px, threads where `effectiveSettled` is false): line 1 + `ProjectFavicon` + title (+ diff stats when present) + time ("waiting + Xm" in amber when approval-blocked, live elapsed for working); line 2 + structured meta — status word (only for the colored states) · branch · + harness glyph + model · machine (only when the thread's environment is + not the current machine). Left rail color only for approval (amber), + working (sky, pulsing), failed (red) — Ready rows carry no color or + label, just a bold title while unread. No free-text/generated content. + - `slim` (~28px, settled): `ProjectFavicon` · title · PR badge when + notable · time. Visually close to today's v1 row. + - **Status derivation** consolidates `Sidebar.logic.ts`'s pill logic into + four visual states: Needs Approval, Working (incl. connecting), Ready + (today's Awaiting Input + Plan Ready + Completed-unseen — same user + action, different payload), and new Failed (`session.lastError` on a + non-running session). Keep it in `Sidebar.logic.ts` so v1 can adopt + Failed later too. + - **Unread**: bold title until visited, from existing + `uiStateStore.threadLastVisitedAtById` — same mechanics as today's + Completed-unseen pill. + - **Row height changes only on lifecycle transitions** (settle/unsettle), + animated with the existing auto-animate pattern. Streaming/status + updates within a variant never change height. + - Preserved v1 behaviors, via the shared hooks: click-to-open, multi-select + (threadSelectionStore), context menu (add Settle/Un-settle entries), + inline rename, thread-jump shortcut labels, archive affordance, PR icon + link, port/terminal/remote indicators (moved into line 3 / slim row + trailing icons). +4. Settle affordances: + - Hover action on the row (next to today's archive hover button) + + context-menu entry + bulk via multi-select. + - `useThreadActions`: add `settleThread` / `unsettleThread` mirroring + `archiveThread` (`useThreadActions.ts:91-134`), minus the navigation + dance — settling never navigates away. + - No keyboard shortcut in the first cut; add via keybindings once the + affordance proves out. + +Verification: typecheck/lint; toggle on → v2 renders, toggle off → v1 +identical to before; settle/unsettle round-trips including across a +desktop+remote pair; kill a session mid-run → Failed card appears. + +### Phase 3 — Worktree cleanup hook + +Settling is the natural moment to reclaim disk: + +1. On **manual settle** of a thread whose worktree is orphaned + (`getOrphanedWorktreePathForThread`, `worktreeCleanup.ts:11-33`): show a + non-blocking inline prompt/toast — "Worktree kept · Remove?" — that calls + `vcsEnvironment.removeWorktree`. Never remove without an explicit click; + never block the settle on the answer. Skip the prompt entirely when the + worktree has uncommitted changes or unpushed commits (check via the + existing vcs status the sidebar already has). +2. **Auto-settle does not touch worktrees** in this phase. A settings-page + "Storage" affordance listing orphaned worktrees of settled threads (with + sizes, bulk-remove) is the follow-up; tracked but not in scope. + +### Phase 4 — Beta telemetry & exit criteria + +Instrument (whatever the existing analytics path is — if none, a lightweight +local counter surfaced in diagnostics is enough for the beta): + +- settle events by source: manual / auto-PR / auto-inactivity / bulk +- un-settle events: manual vs. activity-driven (activity-driven un-settles of + *manual* settles = the model fighting users) +- toggle-off rate after trying v2 + +Decision inputs for exiting beta: if manual settling is ~0%, shrink the +affordance and lean on auto rules; if activity-unsettle-of-manual-settle is +high, the inactivity threshold or unsettle triggers need retuning. + +## Explicitly out of scope + +- Inline approve/reject in the sidebar (concept 3) — needs a safety story. +- Message snippets in rows (concept 2) — streaming churn unsolved. +- Ops-grid density mode (concept 5). +- Auto-archive of long-settled threads. +- Removing v1 / making v2 the default. +- Project grouping inside v2 (v1 remains available for users who want groups). + +## Open questions + +- Where thread "activity" is centrally observable server-side for the + unsettle-on-activity rule — if the decider paths are too scattered, an + acceptable fallback is client-side: compute `effectiveSettled` with the + activity timestamps and only use the override for manual state (activity + newer than `settledAt` wins). Decide during Phase 1 once in the decider. +- Whether `settledAt` belongs in the thread *shell* stream (sidebar reads + shells only) — it must, or v2 can't sort/collapse without detail loads. + Verify shell projection includes it. +- Diff stats on cards require checkpoint data in the shell; if absent, defer + diff stats rather than loading details for every row. diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 405103450e8..7d44d7c9969 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -146,6 +146,8 @@ describe("OrchestrationEngine", () => { createdAt: "2026-03-03T00:00:02.000Z", updatedAt: "2026-03-03T00:00:03.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, messages: [], proposedPlans: [], diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index f12df850941..3ceae0ea43b 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -607,6 +607,8 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti createdAt: event.payload.createdAt, updatedAt: event.payload.updatedAt, archivedAt: null, + settledOverride: null, + settledAt: null, latestUserMessageAt: null, pendingApprovalCount: 0, pendingUserInputCount: 0, @@ -645,6 +647,38 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti return; } + case "thread.settled": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + settledOverride: "settled", + settledAt: event.payload.settledAt, + updatedAt: event.payload.updatedAt, + }); + return; + } + + case "thread.unsettled": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + settledOverride: event.payload.reason === "user" ? "active" : null, + settledAt: null, + updatedAt: event.payload.updatedAt, + }); + return; + } + case "thread.meta-updated": { const existingRow = yield* projectionThreadRepository.getById({ threadId: event.payload.threadId, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 9a136b06872..220665c557c 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -308,6 +308,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { createdAt: "2026-02-24T00:00:02.000Z", updatedAt: "2026-02-24T00:00:03.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, messages: [ { @@ -418,6 +420,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { createdAt: "2026-02-24T00:00:02.000Z", updatedAt: "2026-02-24T00:00:03.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, session: { threadId: ThreadId.make("thread-1"), status: "running", diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 32210436e67..155e9ab0013 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -334,6 +334,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", + settled_override AS "settledOverride", + settled_at AS "settledAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -362,6 +364,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", + settled_override AS "settledOverride", + settled_at AS "settledAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -392,6 +396,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", + settled_override AS "settledOverride", + settled_at AS "settledAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -754,6 +760,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", + settled_override AS "settledOverride", + settled_at AS "settledAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -1186,6 +1194,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { createdAt: row.createdAt, updatedAt: row.updatedAt, archivedAt: row.archivedAt, + settledOverride: row.settledOverride, + settledAt: row.settledAt, deletedAt: row.deletedAt, messages: messagesByThread.get(row.threadId) ?? [], proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], @@ -1384,6 +1394,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { createdAt: row.createdAt, updatedAt: row.updatedAt, archivedAt: row.archivedAt, + settledOverride: row.settledOverride, + settledAt: row.settledAt, deletedAt: row.deletedAt, messages: [], proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], @@ -1513,6 +1525,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { createdAt: row.createdAt, updatedAt: row.updatedAt, archivedAt: row.archivedAt, + settledOverride: row.settledOverride, + settledAt: row.settledAt, session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, hasPendingApprovals: row.pendingApprovalCount > 0, @@ -1647,6 +1661,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { createdAt: row.createdAt, updatedAt: row.updatedAt, archivedAt: row.archivedAt, + settledOverride: row.settledOverride, + settledAt: row.settledAt, session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, hasPendingApprovals: row.pendingApprovalCount > 0, @@ -1887,6 +1903,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { createdAt: threadRow.value.createdAt, updatedAt: threadRow.value.updatedAt, archivedAt: threadRow.value.archivedAt, + settledOverride: threadRow.value.settledOverride, + settledAt: threadRow.value.settledAt, session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null, latestUserMessageAt: threadRow.value.latestUserMessageAt, hasPendingApprovals: threadRow.value.pendingApprovalCount > 0, @@ -1981,6 +1999,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { createdAt: threadRow.value.createdAt, updatedAt: threadRow.value.updatedAt, archivedAt: threadRow.value.archivedAt, + settledOverride: threadRow.value.settledOverride, + settledAt: threadRow.value.settledAt, deletedAt: null, messages: messageRows.map((row) => { const message = { diff --git a/apps/server/src/orchestration/Schemas.ts b/apps/server/src/orchestration/Schemas.ts index f7ebf693440..0d0d7bdd5e4 100644 --- a/apps/server/src/orchestration/Schemas.ts +++ b/apps/server/src/orchestration/Schemas.ts @@ -4,11 +4,13 @@ import { ProjectDeletedPayload as ContractsProjectDeletedPayloadSchema, ThreadCreatedPayload as ContractsThreadCreatedPayloadSchema, ThreadArchivedPayload as ContractsThreadArchivedPayloadSchema, + ThreadSettledPayload as ContractsThreadSettledPayloadSchema, ThreadMetaUpdatedPayload as ContractsThreadMetaUpdatedPayloadSchema, ThreadRuntimeModeSetPayload as ContractsThreadRuntimeModeSetPayloadSchema, ThreadInteractionModeSetPayload as ContractsThreadInteractionModeSetPayloadSchema, ThreadDeletedPayload as ContractsThreadDeletedPayloadSchema, ThreadUnarchivedPayload as ContractsThreadUnarchivedPayloadSchema, + ThreadUnsettledPayload as ContractsThreadUnsettledPayloadSchema, ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema, ThreadProposedPlanUpsertedPayload as ContractsThreadProposedPlanUpsertedPayloadSchema, ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema, @@ -29,11 +31,13 @@ export const ProjectDeletedPayload = ContractsProjectDeletedPayloadSchema; export const ThreadCreatedPayload = ContractsThreadCreatedPayloadSchema; export const ThreadArchivedPayload = ContractsThreadArchivedPayloadSchema; +export const ThreadSettledPayload = ContractsThreadSettledPayloadSchema; export const ThreadMetaUpdatedPayload = ContractsThreadMetaUpdatedPayloadSchema; export const ThreadRuntimeModeSetPayload = ContractsThreadRuntimeModeSetPayloadSchema; export const ThreadInteractionModeSetPayload = ContractsThreadInteractionModeSetPayloadSchema; export const ThreadDeletedPayload = ContractsThreadDeletedPayloadSchema; export const ThreadUnarchivedPayload = ContractsThreadUnarchivedPayloadSchema; +export const ThreadUnsettledPayload = ContractsThreadUnsettledPayloadSchema; export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema; export const ThreadProposedPlanUpsertedPayload = ContractsThreadProposedPlanUpsertedPayloadSchema; diff --git a/apps/server/src/orchestration/commandInvariants.test.ts b/apps/server/src/orchestration/commandInvariants.test.ts index 9c6c8bd2a18..9531cd5c3af 100644 --- a/apps/server/src/orchestration/commandInvariants.test.ts +++ b/apps/server/src/orchestration/commandInvariants.test.ts @@ -68,6 +68,8 @@ const readModel: OrchestrationReadModel = { createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, latestTurn: null, messages: [], session: null, @@ -91,6 +93,8 @@ const readModel: OrchestrationReadModel = { createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, latestTurn: null, messages: [], session: null, diff --git a/apps/server/src/orchestration/decider.settled.test.ts b/apps/server/src/orchestration/decider.settled.test.ts new file mode 100644 index 00000000000..dca1100e286 --- /dev/null +++ b/apps/server/src/orchestration/decider.settled.test.ts @@ -0,0 +1,276 @@ +import { + CommandId, + EventId, + MessageId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationReadModel, + type OrchestrationSession, + type OrchestrationThread, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { decideOrchestrationCommand } from "./decider.ts"; + +const NOW = "2026-01-01T00:00:00.000Z"; +const SETTLED_AT = "2025-12-30T00:00:00.000Z"; + +function makeReadModel( + settledOverride: OrchestrationThread["settledOverride"], + archivedAt: string | null = null, +): OrchestrationReadModel { + return { + snapshotSequence: 0, + projects: [], + threads: [ + { + id: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: NOW, + updatedAt: NOW, + archivedAt, + settledOverride, + settledAt: settledOverride === "settled" ? SETTLED_AT : null, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, + }, + ], + updatedAt: NOW, + }; +} + +function makeSession(status: OrchestrationSession["status"]): OrchestrationSession { + return { + threadId: ThreadId.make("thread-1"), + status, + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: NOW, + }; +} + +it.layer(NodeServices.layer)("settled thread decider", (it) => { + it.effect("settles active threads and re-emits idempotently for settled ones", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-settle"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(null), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events).toHaveLength(1); + expect(events[0]?.type).toBe("thread.settled"); + if (events[0]?.type === "thread.settled") { + expect(events[0].payload.settledAt).toBe(events[0].payload.updatedAt); + } + + // Already settled: the engine rejects zero-event commands, so idempotency + // is by re-emission — preserving the original settledAt. + const reEmit = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-settle-again"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel("settled"), + }); + const reEmitEvents = Array.isArray(reEmit) ? reEmit : [reEmit]; + expect(reEmitEvents).toHaveLength(1); + expect(reEmitEvents[0]?.type).toBe("thread.settled"); + if (reEmitEvents[0]?.type === "thread.settled") { + expect(reEmitEvents[0].payload.settledAt).toBe(SETTLED_AT); + } + }), + ); + + it.effect("rejects settling and unsettling archived threads", () => + Effect.gen(function* () { + const settleError = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-settle-archived"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(null, NOW), + }).pipe(Effect.flip); + expect(settleError._tag).toBe("OrchestrationCommandInvariantError"); + + const unsettleError = yield* decideOrchestrationCommand({ + command: { + type: "thread.unsettle", + commandId: CommandId.make("cmd-unsettle-archived"), + threadId: ThreadId.make("thread-1"), + reason: "user", + }, + readModel: makeReadModel("settled", NOW), + }).pipe(Effect.flip); + expect(unsettleError._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); + + it.effect("maps unsettle reasons to overrides and re-emits idempotently", () => + Effect.gen(function* () { + const userEvent = yield* decideOrchestrationCommand({ + command: { + type: "thread.unsettle", + commandId: CommandId.make("cmd-unsettle-user"), + threadId: ThreadId.make("thread-1"), + reason: "user", + }, + readModel: makeReadModel("settled"), + }); + const userEvents = Array.isArray(userEvent) ? userEvent : [userEvent]; + expect(userEvents).toHaveLength(1); + expect(userEvents[0]?.type).toBe("thread.unsettled"); + if (userEvents[0]?.type === "thread.unsettled") { + expect(userEvents[0].payload.reason).toBe("user"); + } + + // Re-dispatching against the already-reached state re-emits rather than + // producing zero events (the engine rejects empty commands). + const userAgain = yield* decideOrchestrationCommand({ + command: { + type: "thread.unsettle", + commandId: CommandId.make("cmd-unsettle-user-again"), + threadId: ThreadId.make("thread-1"), + reason: "user", + }, + readModel: makeReadModel("active"), + }); + const userAgainEvents = Array.isArray(userAgain) ? userAgain : [userAgain]; + expect(userAgainEvents).toHaveLength(1); + expect(userAgainEvents[0]?.type).toBe("thread.unsettled"); + }), + ); + + it.effect("prepends activity unsets for turn starts and live session updates", () => + Effect.gen(function* () { + const turnResult = yield* decideOrchestrationCommand({ + command: { + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: MessageId.make("message-1"), + role: "user", + text: "Continue", + attachments: [], + }, + runtimeMode: "full-access", + interactionMode: "default", + createdAt: NOW, + }, + readModel: makeReadModel("settled"), + }); + const turnEvents = Array.isArray(turnResult) ? turnResult : [turnResult]; + expect(turnEvents.map((event) => event.type)).toEqual([ + "thread.unsettled", + "thread.message-sent", + "thread.turn-start-requested", + ]); + + const sessionResult = yield* decideOrchestrationCommand({ + command: { + type: "thread.session.set", + commandId: CommandId.make("cmd-session-set"), + threadId: ThreadId.make("thread-1"), + session: makeSession("running"), + createdAt: NOW, + }, + readModel: makeReadModel("active"), + }); + const sessionEvents = Array.isArray(sessionResult) ? sessionResult : [sessionResult]; + expect(sessionEvents.map((event) => event.type)).toEqual([ + "thread.unsettled", + "thread.session-set", + ]); + }), + ); + + it.effect("does not unsettle for session stop/error status writes", () => + Effect.gen(function* () { + for (const status of ["stopped", "error", "ready", "idle"] as const) { + const result = yield* decideOrchestrationCommand({ + command: { + type: "thread.session.set", + commandId: CommandId.make(`cmd-session-${status}`), + threadId: ThreadId.make("thread-1"), + session: makeSession(status), + createdAt: NOW, + }, + readModel: makeReadModel("settled"), + }); + const events = Array.isArray(result) ? result : [result]; + expect(events.map((event) => event.type)).toEqual(["thread.session-set"]); + } + }), + ); + + it.effect("unsettles for approval and user-input activities but not others", () => + Effect.gen(function* () { + const approvalResult = yield* decideOrchestrationCommand({ + command: { + type: "thread.activity.append", + commandId: CommandId.make("cmd-activity-approval"), + threadId: ThreadId.make("thread-1"), + activity: { + id: EventId.make("activity-1"), + tone: "approval", + kind: "approval.requested", + summary: "Command approval requested", + payload: null, + turnId: null, + createdAt: NOW, + }, + createdAt: NOW, + }, + readModel: makeReadModel("settled"), + }); + const approvalEvents = Array.isArray(approvalResult) ? approvalResult : [approvalResult]; + expect(approvalEvents.map((event) => event.type)).toEqual([ + "thread.unsettled", + "thread.activity-appended", + ]); + + const routineResult = yield* decideOrchestrationCommand({ + command: { + type: "thread.activity.append", + commandId: CommandId.make("cmd-activity-routine"), + threadId: ThreadId.make("thread-1"), + activity: { + id: EventId.make("activity-2"), + tone: "info", + kind: "tool.completed", + summary: "Tool completed", + payload: null, + turnId: null, + createdAt: NOW, + }, + createdAt: NOW, + }, + readModel: makeReadModel("settled"), + }); + const routineEvents = Array.isArray(routineResult) ? routineResult : [routineResult]; + expect(routineEvents.map((event) => event.type)).toEqual(["thread.activity-appended"]); + }), + ); +}); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 9c95b42269d..ad84090da79 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -312,6 +312,61 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.settle": { + const thread = yield* requireThreadNotArchived({ + readModel, + command, + threadId: command.threadId, + }); + const occurredAt = yield* nowIso; + // Settling an already-settled thread re-emits with the original + // settledAt: the engine rejects zero-event commands, and bulk-settle / + // double-click must stay silent no-ops rather than surface errors. + const settledAt = + thread.settledOverride === "settled" && thread.settledAt !== null + ? thread.settledAt + : occurredAt; + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.settled", + payload: { + threadId: command.threadId, + settledAt, + updatedAt: occurredAt, + }, + }; + } + + case "thread.unsettle": { + yield* requireThreadNotArchived({ + readModel, + command, + threadId: command.threadId, + }); + // Idempotent by re-emission (see thread.settle): reducing the event a + // second time lands on the same override state. + const occurredAt = yield* nowIso; + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.unsettled", + payload: { + threadId: command.threadId, + reason: command.reason, + updatedAt: occurredAt, + }, + }; + } + case "thread.meta.update": { const thread = yield* requireThread({ readModel, @@ -464,7 +519,24 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" createdAt: command.createdAt, }, }; - return [userMessageEvent, turnStartRequestedEvent]; + if (targetThread.settledOverride === null) { + return [userMessageEvent, turnStartRequestedEvent]; + } + const unsettledEvent: Omit = { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.unsettled", + payload: { + threadId: command.threadId, + reason: "activity", + updatedAt: command.createdAt, + }, + }; + return [unsettledEvent, userMessageEvent, turnStartRequestedEvent]; } case "thread.turn.interrupt": { @@ -585,12 +657,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" } case "thread.session.set": { - yield* requireThread({ + const thread = yield* requireThread({ readModel, command, threadId: command.threadId, }); - return { + const sessionSetEvent: Omit = { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, @@ -604,6 +676,29 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" session: command.session, }, }; + // Only a session coming alive is activity worth waking a settled thread + // for — status writes like ready/stopped/error arrive after the fact and + // must not fight a user's explicit settle. + const isSessionActivity = + command.session.status === "starting" || command.session.status === "running"; + if (thread.settledOverride === null || !isSessionActivity) { + return sessionSetEvent; + } + const unsettledEvent: Omit = { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.unsettled", + payload: { + threadId: command.threadId, + reason: "activity", + updatedAt: command.createdAt, + }, + }; + return [unsettledEvent, sessionSetEvent]; } case "thread.message.assistant.delta": { @@ -730,7 +825,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" } case "thread.activity.append": { - yield* requireThread({ + const thread = yield* requireThread({ readModel, command, threadId: command.threadId, @@ -743,7 +838,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ? ((command.activity.payload as { requestId: string }) .requestId as OrchestrationEvent["metadata"]["requestId"]) : undefined; - return { + const activityAppendedEvent: Omit = { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, @@ -757,6 +852,29 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" activity: command.activity, }, }; + // An approval or user-input request is blocked-on-you work — it must + // never stay hidden inside a settled slim row. + const wakesSettledThread = + command.activity.kind === "approval.requested" || + command.activity.kind === "user-input.requested"; + if (thread.settledOverride === null || !wakesSettledThread) { + return activityAppendedEvent; + } + const unsettledEvent: Omit = { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.unsettled", + payload: { + threadId: command.threadId, + reason: "activity", + updatedAt: command.createdAt, + }, + }; + return [unsettledEvent, activityAppendedEvent]; } default: { diff --git a/apps/server/src/orchestration/projector.settled.test.ts b/apps/server/src/orchestration/projector.settled.test.ts new file mode 100644 index 00000000000..2070c44418a --- /dev/null +++ b/apps/server/src/orchestration/projector.settled.test.ts @@ -0,0 +1,88 @@ +import { + CommandId, + EventId, + ProjectId, + ThreadId, + type OrchestrationEvent, +} from "@t3tools/contracts"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { createEmptyReadModel, projectEvent } from "./projector.ts"; + +function makeEvent(input: { + readonly sequence: number; + readonly type: OrchestrationEvent["type"]; + readonly payload: unknown; +}): OrchestrationEvent { + return { + sequence: input.sequence, + eventId: EventId.make(`event-${input.sequence}`), + type: input.type, + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + occurredAt: "2026-01-01T00:00:00.000Z", + commandId: CommandId.make(`command-${input.sequence}`), + causationEventId: null, + correlationId: null, + metadata: {}, + payload: input.payload as never, + } as OrchestrationEvent; +} + +it.effect("projects settled lifecycle events", () => + Effect.gen(function* () { + const now = "2026-01-01T00:00:00.000Z"; + const created = yield* projectEvent( + createEmptyReadModel(now), + makeEvent({ + sequence: 1, + type: "thread.created", + payload: { + threadId: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { provider: "codex", model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }), + ); + const settled = yield* projectEvent( + created, + makeEvent({ + sequence: 2, + type: "thread.settled", + payload: { threadId: ThreadId.make("thread-1"), settledAt: now, updatedAt: now }, + }), + ); + expect(settled.threads[0]?.settledOverride).toBe("settled"); + expect(settled.threads[0]?.settledAt).toBe(now); + + const userUnsettled = yield* projectEvent( + settled, + makeEvent({ + sequence: 3, + type: "thread.unsettled", + payload: { threadId: ThreadId.make("thread-1"), reason: "user", updatedAt: now }, + }), + ); + expect(userUnsettled.threads[0]?.settledOverride).toBe("active"); + expect(userUnsettled.threads[0]?.settledAt).toBeNull(); + + const activityUnsettled = yield* projectEvent( + userUnsettled, + makeEvent({ + sequence: 4, + type: "thread.unsettled", + payload: { threadId: ThreadId.make("thread-1"), reason: "activity", updatedAt: now }, + }), + ); + expect(activityUnsettled.threads[0]?.settledOverride).toBeNull(); + expect(activityUnsettled.threads[0]?.settledAt).toBeNull(); + }), +); diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index fadd5078026..e9a55c0796b 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -89,6 +89,8 @@ describe("orchestration projector", () => { createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, messages: [], proposedPlans: [], diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index fc6ab8f6fcf..c8f47dcebbf 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -22,7 +22,9 @@ import { ThreadMetaUpdatedPayload, ThreadProposedPlanUpsertedPayload, ThreadRuntimeModeSetPayload, + ThreadSettledPayload, ThreadUnarchivedPayload, + ThreadUnsettledPayload, ThreadRevertedPayload, ThreadSessionSetPayload, ThreadTurnDiffCompletedPayload, @@ -286,6 +288,8 @@ export function projectEvent( createdAt: payload.createdAt, updatedAt: payload.updatedAt, archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, messages: [], activities: [], @@ -337,6 +341,30 @@ export function projectEvent( })), ); + case "thread.settled": + return decodeForEvent(ThreadSettledPayload, event.payload, event.type, "payload").pipe( + Effect.map((payload) => ({ + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + settledOverride: "settled", + settledAt: payload.settledAt, + updatedAt: payload.updatedAt, + }), + })), + ); + + case "thread.unsettled": + return decodeForEvent(ThreadUnsettledPayload, event.payload, event.type, "payload").pipe( + Effect.map((payload) => ({ + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + settledOverride: payload.reason === "user" ? "active" : null, + settledAt: null, + updatedAt: payload.updatedAt, + }), + })), + ); + case "thread.meta-updated": return decodeForEvent(ThreadMetaUpdatedPayload, event.payload, event.type, "payload").pipe( Effect.map((payload) => ({ diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index a2069e62a14..0403e55d9ee 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -91,6 +91,8 @@ projectionRepositoriesLayer("Projection repositories", (it) => { createdAt: "2026-03-24T00:00:00.000Z", updatedAt: "2026-03-24T00:00:00.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, latestUserMessageAt: null, pendingApprovalCount: 0, pendingUserInputCount: 0, diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 1baeb375c15..e0c85e91494 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -43,6 +43,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { created_at, updated_at, archived_at, + settled_override, + settled_at, latest_user_message_at, pending_approval_count, pending_user_input_count, @@ -62,6 +64,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.createdAt}, ${row.updatedAt}, ${row.archivedAt}, + ${row.settledOverride}, + ${row.settledAt}, ${row.latestUserMessageAt}, ${row.pendingApprovalCount}, ${row.pendingUserInputCount}, @@ -81,6 +85,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { created_at = excluded.created_at, updated_at = excluded.updated_at, archived_at = excluded.archived_at, + settled_override = excluded.settled_override, + settled_at = excluded.settled_at, latest_user_message_at = excluded.latest_user_message_at, pending_approval_count = excluded.pending_approval_count, pending_user_input_count = excluded.pending_user_input_count, @@ -107,6 +113,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", + settled_override AS "settledOverride", + settled_at AS "settledAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -135,6 +143,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", + settled_override AS "settledOverride", + settled_at AS "settledAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index ba1131ee259..f0183fd26c3 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -45,6 +45,7 @@ import Migration0029 from "./Migrations/029_ProjectionThreadDetailOrderingIndexe import Migration0030 from "./Migrations/030_ProjectionThreadShellArchiveIndexes.ts"; import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts"; import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts"; +import Migration0033 from "./Migrations/033_ProjectionThreadsSettled.ts"; /** * Migration loader with all migrations defined inline. @@ -89,6 +90,7 @@ export const migrationEntries = [ [30, "ProjectionThreadShellArchiveIndexes", Migration0030], [31, "AuthAuthorizationScopes", Migration0031], [32, "AuthPairingProofKeyThumbprint", Migration0032], + [33, "ProjectionThreadsSettled", Migration0033], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/033_ProjectionThreadsSettled.ts b/apps/server/src/persistence/Migrations/033_ProjectionThreadsSettled.ts new file mode 100644 index 00000000000..e93407defe2 --- /dev/null +++ b/apps/server/src/persistence/Migrations/033_ProjectionThreadsSettled.ts @@ -0,0 +1,23 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + + if (!columns.some((column) => column.name === "settled_override")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN settled_override TEXT + `; + } + + if (!columns.some((column) => column.name === "settled_at")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN settled_at TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index 44fdc147a4a..8057d434950 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -36,6 +36,8 @@ export const ProjectionThread = Schema.Struct({ createdAt: IsoDateTime, updatedAt: IsoDateTime, archivedAt: Schema.NullOr(IsoDateTime), + settledOverride: Schema.NullOr(Schema.Literals(["settled", "active"])), + settledAt: Schema.NullOr(IsoDateTime), latestUserMessageAt: Schema.NullOr(IsoDateTime), pendingApprovalCount: NonNegativeInt, pendingUserInputCount: NonNegativeInt, diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index 166a88ef17b..3843c8acbcd 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -100,6 +100,8 @@ function makeReadModel( createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, latestUserMessageAt: null, hasPendingApprovals: false, hasPendingUserInput: false, diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 9ca17dbc2dd..3ec6f4d0435 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -305,6 +305,8 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, session: null, latestUserMessageAt: null, hasPendingApprovals: false, @@ -451,6 +453,8 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, session: { threadId, status: "running", @@ -606,6 +610,8 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, session: { threadId, status: "running", diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 347c2920792..29bd0d440ed 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -163,6 +163,8 @@ const makeDefaultOrchestrationReadModel = () => { createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, latestTurn: null, messages: [], session: null, @@ -192,6 +194,8 @@ const makeDefaultOrchestrationThreadShell = ( createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, session: null, latestUserMessageAt: null, hasPendingApprovals: false, @@ -5495,6 +5499,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, latestTurn: null, messages: [], session: null, diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 0f1a8f9d429..104340cb963 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -1,12 +1,14 @@ import { useAtomValue } from "@effect/atom-react"; import { useEffect, type CSSProperties, type ReactNode } from "react"; -import { useNavigate } from "@tanstack/react-router"; +import { useLocation, useNavigate } from "@tanstack/react-router"; import { isElectron } from "../env"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import { isMacPlatform } from "../lib/utils"; import { primaryServerKeybindingsAtom } from "../state/server"; +import { useClientSettings } from "../hooks/useSettings"; import ThreadSidebar from "./Sidebar"; +import ThreadSidebarV2 from "./SidebarV2"; import { Sidebar, SidebarProvider, SidebarRail, SidebarTrigger, useSidebar } from "./ui/sidebar"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; @@ -55,6 +57,12 @@ function SidebarControl() { export function AppSidebarLayout({ children }: { children: ReactNode }) { const navigate = useNavigate(); + const sidebarV2Enabled = useClientSettings((settings) => settings.sidebarV2Enabled); + // Settings routes render the settings nav, which lives in the v1 component + // and is identical for both sidebars — so v1 stays mounted there. + const pathname = useLocation({ select: (location) => location.pathname }); + const isOnSettings = pathname === "/settings" || pathname.startsWith("/settings/"); + const useSidebarV2 = sidebarV2Enabled && !isOnSettings; const macosWindowControlsStyle = isElectron && isMacPlatform(navigator.platform) ? ({ "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET } as CSSProperties) @@ -90,7 +98,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { storageKey: THREAD_SIDEBAR_WIDTH_STORAGE_KEY, }} > - + {useSidebarV2 ? : } {children} diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 0a0103df183..bf0664bc4aa 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -42,6 +42,8 @@ function makeThread(overrides: Partial = {}): Thread { createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, latestTurn: null, branch: null, diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 705793ec77e..b629e9f9c15 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -45,6 +45,8 @@ export function buildLocalDraftThread( createdAt: draftThread.createdAt, updatedAt: draftThread.createdAt, archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, latestTurn: null, branch: draftThread.branch, diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 651fe34e4b4..6609017f91c 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -24,6 +24,8 @@ function makeThread(overrides: Partial = {}): Thread { proposedPlans: [], createdAt: "2026-03-01T00:00:00.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, updatedAt: "2026-03-01T00:00:00.000Z", latestTurn: null, diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 574e33d4dab..5348aa67cb3 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -16,8 +16,10 @@ import { resolveSidebarNewThreadEnvMode, resolveSidebarStageBadgeLabel, resolveThreadRowClassName, + resolveSidebarV2Status, resolveThreadStatusPill, shouldClearThreadSelectionOnMouseDown, + sortThreadsForSidebarV2, sortProjectsForSidebar, THREAD_JUMP_HINT_SHOW_DELAY_MS, } from "./Sidebar.logic"; @@ -564,6 +566,91 @@ describe("isContextMenuPointerDown", () => { }); }); +describe("resolveSidebarV2Status", () => { + const session = { + threadId: ThreadId.make("thread-1"), + status: "running" as const, + providerName: "Codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: DEFAULT_RUNTIME_MODE, + activeTurnId: "turn-1" as never, + lastError: null, + updatedAt: "2026-03-09T10:00:00.000Z", + }; + + it("prioritizes approval over a running session", () => { + expect(resolveSidebarV2Status({ hasPendingApprovals: true, session })).toBe("approval"); + }); + + it("reports working for running and starting sessions", () => { + expect(resolveSidebarV2Status({ hasPendingApprovals: false, session })).toBe("working"); + expect( + resolveSidebarV2Status({ + hasPendingApprovals: false, + session: { ...session, status: "starting" as const }, + }), + ).toBe("working"); + }); + + it("reports failed only for stopped sessions with an error", () => { + expect( + resolveSidebarV2Status({ + hasPendingApprovals: false, + session: { ...session, status: "stopped" as const, lastError: "boom" }, + }), + ).toBe("failed"); + expect( + resolveSidebarV2Status({ + hasPendingApprovals: false, + session: { ...session, status: "stopped" as const }, + }), + ).toBe("ready"); + }); + + it("defaults to ready with no session", () => { + expect(resolveSidebarV2Status({ hasPendingApprovals: false, session: null })).toBe("ready"); + }); +}); + +describe("sortThreadsForSidebarV2", () => { + const sortable = (input: { + id: string; + hasPendingApprovals?: boolean; + latestUserMessageAt: string; + }) => ({ + id: input.id, + hasPendingApprovals: input.hasPendingApprovals ?? false, + session: null, + createdAt: "2026-03-09T10:00:00.000Z", + updatedAt: input.latestUserMessageAt, + latestUserMessageAt: input.latestUserMessageAt, + }); + + it("pins approval-blocked threads above the recency flow, longest wait first", () => { + const sorted = sortThreadsForSidebarV2([ + sortable({ id: "fresh", latestUserMessageAt: "2026-03-09T12:00:00.000Z" }), + sortable({ + id: "approval-new", + hasPendingApprovals: true, + latestUserMessageAt: "2026-03-09T11:00:00.000Z", + }), + sortable({ + id: "approval-old", + hasPendingApprovals: true, + latestUserMessageAt: "2026-03-09T09:00:00.000Z", + }), + sortable({ id: "stale", latestUserMessageAt: "2026-03-09T08:00:00.000Z" }), + ]); + + expect(sorted.map((thread) => thread.id)).toEqual([ + "approval-old", + "approval-new", + "fresh", + "stale", + ]); + }); +}); + describe("resolveThreadStatusPill", () => { const baseThread = { hasActionableProposedPlan: false, @@ -830,6 +917,8 @@ function makeThread(overrides: Partial = {}): Thread { proposedPlans: [], createdAt: "2026-03-09T10:00:00.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, updatedAt: "2026-03-09T10:00:00.000Z", latestTurn: null, diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 4e7614ed551..40e9e89fbfb 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -357,6 +357,50 @@ export function resolveThreadRowClassName(input: { return cn(baseClassName, "text-muted-foreground hover:bg-accent hover:text-foreground"); } +// ── Sidebar v2 status model ───────────────────────────────────────── +// Four visual states, three colors: color is reserved for "act now" +// (approval), "in motion" (working), and "broken" (failed). Ready is the +// unlabeled resting state — the agent stopped and is waiting on the user, +// whether it finished, asked a question, or proposed a plan. +export type SidebarV2Status = "approval" | "working" | "failed" | "ready"; + +type SidebarV2StatusInput = Pick; + +export function resolveSidebarV2Status(thread: SidebarV2StatusInput): SidebarV2Status { + if (thread.hasPendingApprovals) { + return "approval"; + } + if (thread.session?.status === "running" || thread.session?.status === "starting") { + return "working"; + } + if (thread.session != null && thread.session.lastError !== null) { + return "failed"; + } + return "ready"; +} + +// v2 sort: approval-blocked threads pin above the recency flow, longest wait +// first, so nothing actionable can scroll away. Everything else is recency. +export function sortThreadsForSidebarV2< + T extends SidebarV2StatusInput & ThreadSortInput & { readonly id: string }, +>(threads: readonly T[]): T[] { + return [...threads].toSorted((left, right) => { + const leftApproval = resolveSidebarV2Status(left) === "approval"; + const rightApproval = resolveSidebarV2Status(right) === "approval"; + if (leftApproval !== rightApproval) { + return leftApproval ? -1 : 1; + } + const byRecency = + getThreadSortTimestamp(right, "updated_at") - getThreadSortTimestamp(left, "updated_at"); + // Approval wait time is "how long you've been the bottleneck", which is + // oldest-activity-first — the inverse of recency. + if (leftApproval && rightApproval) { + return -byRecency || left.id.localeCompare(right.id); + } + return byRecency || left.id.localeCompare(right.id); + }); +} + export function resolveThreadStatusPill(input: { thread: ThreadStatusInput; }): ThreadStatusPill | null { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index bb6b2752a17..b02cc23ad1d 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -8,7 +8,6 @@ import { Globe2Icon, LoaderIcon, SearchIcon, - SettingsIcon, SquarePenIcon, TerminalIcon, TriangleAlertIcon, @@ -63,7 +62,7 @@ import { settlePromise, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import { Link, useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router"; +import { useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router"; import { MAX_SIDEBAR_THREAD_PREVIEW_COUNT, MIN_SIDEBAR_THREAD_PREVIEW_COUNT, @@ -74,7 +73,6 @@ import { import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; import { isElectron } from "../env"; -import { APP_STAGE_LABEL } from "../branding"; import { useOpenPrLink } from "../lib/openPullRequestLink"; import { isTerminalFocused } from "../lib/terminalFocus"; import { isMacPlatform } from "../lib/utils"; @@ -168,9 +166,7 @@ import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./u import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { SidebarContent, - SidebarFooter, SidebarGroup, - SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem, @@ -178,7 +174,6 @@ import { SidebarMenuSubButton, SidebarMenuSubItem, SidebarSeparator, - SidebarTrigger, useSidebar, } from "./ui/sidebar"; import { useThreadSelectionStore } from "../threadSelectionStore"; @@ -191,7 +186,6 @@ import { resolveProjectStatusIndicator, resolveSidebarNewThreadSeedContext, resolveSidebarNewThreadEnvMode, - resolveSidebarStageBadgeLabel, resolveThreadRowClassName, resolveThreadStatusPill, orderItemsByPreferredIds, @@ -201,12 +195,12 @@ import { ThreadStatusPill, } from "./Sidebar.logic"; import { sortThreads } from "../lib/threadSort"; -import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; +import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useIsMobile } from "~/hooks/useMediaQuery"; import { CommandDialogTrigger } from "./ui/command"; import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; -import { primaryServerConfigAtom, primaryServerKeybindingsAtom } from "../state/server"; +import { primaryServerKeybindingsAtom } from "../state/server"; import { derivePhysicalProjectKey, deriveProjectGroupingOverrideKey, @@ -220,7 +214,6 @@ import { type SidebarProjectGroupMember, type SidebarProjectSnapshot, } from "../sidebarProjectGrouping"; -import { SidebarProviderUpdatePill } from "./sidebar/SidebarProviderUpdatePill"; const SIDEBAR_SORT_LABELS: Record = { updated_at: "Last user message", created_at: "Created at", @@ -2739,100 +2732,6 @@ function SortableProjectItem({ ); } -const SidebarChromeHeader = memo(function SidebarChromeHeader({ - isElectron, -}: { - isElectron: boolean; -}) { - return isElectron ? ( - - - - - ) : ( - - - - - ); -}); - -function SidebarBrand() { - const stageLabel = useSidebarStageLabel(); - - return ( - - - - Code - - - {stageLabel} - - - ); -} - -function useSidebarStageLabel() { - const primaryServerVersion = - useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null; - - return resolveSidebarStageBadgeLabel({ - primaryServerVersion, - fallbackStageLabel: APP_STAGE_LABEL, - }); -} - -function T3Wordmark() { - return ( - - - - ); -} - -const SidebarChromeFooter = memo(function SidebarChromeFooter() { - const navigate = useNavigate(); - const { isMobile, setOpenMobile } = useSidebar(); - const handleSettingsClick = useCallback(() => { - if (isMobile) { - setOpenMobile(false); - } - void navigate({ to: "/settings" }); - }, [isMobile, navigate, setOpenMobile]); - - return ( - - - - - - - - Settings - - - - - ); -}); - interface SidebarProjectsContentProps { showArm64IntelBuildWarning: boolean; arm64IntelBuildWarningDescription: string | null; diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx new file mode 100644 index 00000000000..15ee72a2f19 --- /dev/null +++ b/apps/web/src/components/SidebarV2.tsx @@ -0,0 +1,978 @@ +import { autoAnimate } from "@formkit/auto-animate"; +import { useAtomValue } from "@effect/atom-react"; +import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; +import { + scopeProjectRef, + scopeThreadRef, + scopedThreadKey, +} from "@t3tools/client-runtime/environment"; +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { CheckIcon, CloudIcon, PlusIcon, SearchIcon, Undo2Icon } from "lucide-react"; +import { + memo, + useCallback, + useEffect, + useMemo, + useRef, + useState, + type MouseEvent as ReactMouseEvent, +} from "react"; +import { useParams, useRouter } from "@tanstack/react-router"; + +import { + isAtomCommandInterrupted, + settlePromise, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { isElectron } from "../env"; +import { + resolveShortcutCommand, + shortcutLabelForCommand, + threadJumpCommandForIndex, + threadJumpIndexFromCommand, + threadTraversalDirectionFromCommand, +} from "../keybindings"; +import { isTerminalFocused } from "../lib/terminalFocus"; +import { isModelPickerOpen } from "../modelPickerVisibility"; +import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; +import { isMacPlatform } from "~/lib/utils"; +import { useOpenPrLink } from "../lib/openPullRequestLink"; +import { readLocalApi } from "../localApi"; +import { useUiStateStore } from "../uiStateStore"; +import { useThreadSelectionStore } from "../threadSelectionStore"; +import { useThreadActions } from "../hooks/useThreadActions"; +import { useNewThreadHandler } from "../hooks/useHandleNewThread"; +import { useClientSettings } from "../hooks/useSettings"; +import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; +import { useProjects, useThreadShells } from "../state/entities"; +import { primaryServerKeybindingsAtom } from "../state/server"; +import { vcsEnvironment } from "../state/vcs"; +import { useEnvironmentQuery } from "../state/query"; +import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; +import { formatElapsedDurationLabel, formatRelativeTimeLabel } from "../timestampFormat"; +import type { SidebarThreadSummary } from "../types"; +import { cn } from "~/lib/utils"; +import { + isTrailingDoubleClick, + resolveAdjacentThreadId, + resolveSidebarV2Status, + sortThreadsForSidebarV2, + type SidebarV2Status, +} from "./Sidebar.logic"; +import { prStatusIndicator, resolveThreadPr } from "./ThreadStatusIndicators"; +import { ProjectFavicon } from "./ProjectFavicon"; +import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; +import { deriveProviderInstanceEntries, type ProviderInstanceEntry } from "../providerInstances"; +import { primaryServerProvidersAtom } from "../state/server"; +import { stackedThreadToast, toastManager } from "./ui/toast"; +import { CommandDialogTrigger } from "./ui/command"; +import { Kbd } from "./ui/kbd"; +import { + SidebarContent, + SidebarGroup, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarSeparator, + useSidebar, +} from "./ui/sidebar"; +import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; + +// Row heights are fixed per variant so the list only changes shape at +// lifecycle transitions (settle/unsettle), never from streaming updates. +const RAIL_CLASS_BY_STATUS: Partial> = { + approval: "bg-amber-500 dark:bg-amber-300/90", + working: "bg-sky-500 dark:bg-sky-300/80 animate-pulse", + failed: "bg-red-500 dark:bg-red-400/90", +}; + +const STATUS_WORD_BY_STATUS: Partial< + Record +> = { + approval: { label: "Approval", className: "text-amber-600 dark:text-amber-300/90" }, + working: { label: "Working", className: "text-sky-600 dark:text-sky-300/80" }, + failed: { label: "Failed", className: "text-red-600 dark:text-red-400/90" }, +}; + +// The working timer re-renders once per second only for rows that show it. +function useTickWhile(active: boolean): number { + const [, setTick] = useState(0); + useEffect(() => { + if (!active) return; + const id = window.setInterval(() => setTick((value) => value + 1), 1_000); + return () => window.clearInterval(id); + }, [active]); + return active ? Date.now() : 0; +} + +function threadTimeLabel(thread: SidebarThreadSummary, status: SidebarV2Status): string { + if (status === "working" && thread.latestTurn?.startedAt) { + return formatElapsedDurationLabel(thread.latestTurn.startedAt); + } + if (status === "approval") { + const waitingSince = thread.latestTurn?.startedAt ?? thread.updatedAt; + return `waiting ${formatElapsedDurationLabel(waitingSince)}`; + } + const timestamp = thread.latestUserMessageAt ?? thread.updatedAt; + return formatRelativeTimeLabel(timestamp); +} + +const SidebarV2Row = memo(function SidebarV2Row(props: { + thread: SidebarThreadSummary; + variant: "card" | "slim"; + isActive: boolean; + jumpLabel: string | null; + currentEnvironmentId: string | null; + environmentLabel: string | null; + projectCwd: string | null; + providerEntryByInstanceId: ReadonlyMap; + onThreadClick: (event: ReactMouseEvent, threadRef: ScopedThreadRef) => void; + onContextMenu: (threadRef: ScopedThreadRef, position: { x: number; y: number }) => void; + onSettle: (threadRef: ScopedThreadRef) => void; + onUnsettle: (threadRef: ScopedThreadRef) => void; + onChangeRequestState: (threadKey: string, state: "open" | "closed" | "merged" | null) => void; +}) { + const { + onChangeRequestState, + onContextMenu, + onSettle, + onThreadClick, + onUnsettle, + thread, + variant, + } = props; + const threadRef = useMemo( + () => scopeThreadRef(thread.environmentId, thread.id), + [thread.environmentId, thread.id], + ); + const threadKey = scopedThreadKey(threadRef); + const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); + const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); + const openPrLink = useOpenPrLink(); + + const status = resolveSidebarV2Status(thread); + useTickWhile(variant === "card" && (status === "working" || status === "approval")); + + const gitCwd = thread.worktreePath ?? props.projectCwd; + const gitStatus = useEnvironmentQuery( + thread.branch != null && gitCwd !== null + ? vcsEnvironment.status({ + environmentId: thread.environmentId, + input: { cwd: gitCwd }, + }) + : null, + ); + const pr = resolveThreadPr(thread.branch, gitStatus.data); + const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); + // Report the PR state up: the parent partitions rows with effectiveSettled, + // and a merged/closed PR auto-settles a thread — data only rows have. + const prState = pr?.state ?? null; + useEffect(() => { + onChangeRequestState(threadKey, prState); + }, [onChangeRequestState, prState, threadKey]); + + const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; + const driverKind = props.providerEntryByInstanceId.get(modelInstanceId)?.driverKind ?? null; + + const isUnread = + thread.latestTurn?.completedAt != null && + (lastVisitedAt == null || + Date.parse(thread.latestTurn.completedAt) > Date.parse(lastVisitedAt)); + + const isRemote = + props.currentEnvironmentId !== null && thread.environmentId !== props.currentEnvironmentId; + + const handleClick = useCallback( + (event: ReactMouseEvent) => { + onThreadClick(event, threadRef); + }, + [onThreadClick, threadRef], + ); + const handleContextMenu = useCallback( + (event: ReactMouseEvent) => { + event.preventDefault(); + onContextMenu(threadRef, { x: event.clientX, y: event.clientY }); + }, + [onContextMenu, threadRef], + ); + const handleSettleClick = useCallback( + (event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + onSettle(threadRef); + }, + [onSettle, threadRef], + ); + const handleUnsettleClick = useCallback( + (event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + onUnsettle(threadRef); + }, + [onUnsettle, threadRef], + ); + const handlePrClick = useCallback( + (event: ReactMouseEvent) => { + if (pr?.url) openPrLink(event, pr.url); + }, + [openPrLink, pr], + ); + + const rowClassName = cn( + "group/v2-row relative w-full cursor-pointer select-none rounded-md text-left", + props.isActive + ? "bg-accent/85 dark:bg-accent/55" + : isSelected + ? "bg-primary/15 dark:bg-primary/22" + : "hover:bg-accent/60 dark:hover:bg-accent/40", + ); + + const favicon = ( + + ); + + const prBadge = + prStatus && pr ? ( + + ) : null; + + if (variant === "slim") { + return ( +
  • +
    + {favicon} + + {thread.title} + + {prBadge} + + + {props.jumpLabel ?? + formatRelativeTimeLabel(thread.latestUserMessageAt ?? thread.updatedAt)} + +
    +
  • + ); + } + + const rail = RAIL_CLASS_BY_STATUS[status]; + const statusWord = STATUS_WORD_BY_STATUS[status]; + const diff = latestTurnDiff(thread); + + return ( +
  • +
    + {rail ? ( + + ) : null} +
    + {favicon} + + {thread.title} + + {diff ? ( + + +{diff.insertions}{" "} + −{diff.deletions} + + ) : null} + + + {props.jumpLabel ?? threadTimeLabel(thread, status)} + +
    +
    + {statusWord ? ( + {statusWord.label} + ) : null} + {status === "failed" && thread.session?.lastError ? ( + + {thread.session.lastError} + + ) : null} + {thread.branch ? ( + {thread.branch} + ) : null} + {prBadge} + + {driverKind ? ( + + ) : null} + {thread.modelSelection.model} + + {isRemote ? ( + + + } + > + + {props.environmentLabel ?? "Remote"} + + + Running on {props.environmentLabel ?? "a remote environment"} + + + ) : null} +
    +
    +
  • + ); +}); + +function latestTurnDiff( + thread: SidebarThreadSummary, +): { insertions: number; deletions: number } | null { + // Shells don't carry checkpoint summaries; diff stats render only when the + // shell projection grows them. Kept as a seam so the row layout is ready. + void thread; + return null; +} + +export default function SidebarV2() { + const projects = useProjects(); + const threads = useThreadShells(); + const router = useRouter(); + const { isMobile, setOpenMobile } = useSidebar(); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); + const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); + const { settleThread, unsettleThread, archiveThread, deleteThread } = useThreadActions(); + const handleNewThread = useNewThreadHandler(); + const { environments } = useEnvironments(); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const clearSelection = useThreadSelectionStore((s) => s.clearSelection); + const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); + const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread); + const rangeSelectTo = useThreadSelectionStore((s) => s.rangeSelectTo); + const markThreadUnread = useUiStateStore((s) => s.markThreadUnread); + const routeThreadRef = useParams({ + strict: false, + select: (params) => resolveThreadRouteRef(params), + }); + const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; + + const environmentLabelById = useMemo( + () => + new Map( + environments.map((environment) => [environment.environmentId, environment.label] as const), + ), + [environments], + ); + const serverProviders = useAtomValue(primaryServerProvidersAtom); + const providerEntryByInstanceId = useMemo( + () => + new Map( + deriveProviderInstanceEntries(serverProviders).map( + (entry) => [entry.instanceId as string, entry] as const, + ), + ), + [serverProviders], + ); + const projectCwdByKey = useMemo( + () => + new Map( + projects.map((project) => [ + `${project.environmentId}:${project.id}`, + project.workspaceRoot, + ]), + ), + [projects], + ); + + // now is quantized to the minute so effectiveSettled memoization doesn't + // churn on every render; auto-settle thresholds are day-granular anyway. + const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16)); + useEffect(() => { + const id = window.setInterval( + () => setNowMinute(new Date().toISOString().slice(0, 16)), + 60_000, + ); + return () => window.clearInterval(id); + }, []); + + // PR states stream in per-row (rows own the VCS subscriptions); a merged or + // closed PR auto-settles its thread on the next partition. + const [changeRequestStateByKey, setChangeRequestStateByKey] = useState< + ReadonlyMap + >(() => new Map()); + const handleChangeRequestState = useCallback( + (threadKey: string, state: "open" | "closed" | "merged" | null) => { + setChangeRequestStateByKey((current) => { + if ((current.get(threadKey) ?? null) === state) return current; + const next = new Map(current); + if (state === null) { + next.delete(threadKey); + } else { + next.set(threadKey, state); + } + return next; + }); + }, + [], + ); + + const { activeThreads, settledThreads } = useMemo(() => { + const now = `${nowMinute}:00.000Z`; + const visible = threads.filter((thread) => thread.archivedAt === null); + const active: EnvironmentThreadShell[] = []; + const settled: EnvironmentThreadShell[] = []; + for (const thread of visible) { + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + const changeRequestState = changeRequestStateByKey.get(threadKey) ?? null; + if (effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState })) { + settled.push(thread); + } else { + active.push(thread); + } + } + return { + activeThreads: sortThreadsForSidebarV2(active), + settledThreads: settled.toSorted( + (left, right) => + Date.parse(right.latestUserMessageAt ?? right.updatedAt) - + Date.parse(left.latestUserMessageAt ?? left.updatedAt), + ), + }; + }, [autoSettleAfterDays, changeRequestStateByKey, nowMinute, threads]); + + const orderedThreads = useMemo( + () => [...activeThreads, ...settledThreads], + [activeThreads, settledThreads], + ); + const orderedThreadKeys = useMemo( + () => + orderedThreads.map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ), + [orderedThreads], + ); + // Rows call back into the click handler without carrying the ordered list as + // a prop — a fresh array identity per shell update would defeat every row's + // memoization. The ref keeps shift-range-select working against the list as + // rendered at click time. + const orderedThreadKeysRef = useRef(orderedThreadKeys); + orderedThreadKeysRef.current = orderedThreadKeys; + const threadByKey = useMemo( + () => + new Map( + orderedThreads.map( + (thread) => + [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const, + ), + ), + [orderedThreads], + ); + // Handlers read these through refs: depending on per-update Map/Set + // identities would give every row a fresh callback prop on each shell + // event and defeat row memoization during streaming. + const threadByKeyRef = useRef(threadByKey); + threadByKeyRef.current = threadByKey; + const settledThreadKeys = useMemo( + () => + new Set( + settledThreads.map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ), + ), + [settledThreads], + ); + const settledThreadKeysRef = useRef(settledThreadKeys); + settledThreadKeysRef.current = settledThreadKeys; + + const jumpLabelByKey = useMemo(() => { + const mapping = new Map(); + for (const [index, threadKey] of orderedThreadKeys.entries()) { + const jumpCommand = threadJumpCommandForIndex(index); + if (!jumpCommand) break; + const label = shortcutLabelForCommand(keybindings, jumpCommand); + if (label) mapping.set(threadKey, label); + } + return mapping; + }, [keybindings, orderedThreadKeys]); + const [showJumpHints, setShowJumpHints] = useState(false); + + const navigateToThread = useCallback( + (threadRef: ScopedThreadRef) => { + if (useThreadSelectionStore.getState().selectedThreadKeys.size > 0) { + clearSelection(); + } + setSelectionAnchor(scopedThreadKey(threadRef)); + if (isMobile) { + setOpenMobile(false); + } + void router.navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(threadRef), + }); + }, + [clearSelection, isMobile, router, setOpenMobile, setSelectionAnchor], + ); + + const handleThreadClick = useCallback( + (event: ReactMouseEvent, threadRef: ScopedThreadRef) => { + const isMac = isMacPlatform(navigator.platform); + const isModClick = isMac ? event.metaKey : event.ctrlKey; + const threadKey = scopedThreadKey(threadRef); + if (isModClick) { + event.preventDefault(); + toggleThreadSelection(threadKey); + return; + } + if (event.shiftKey) { + event.preventDefault(); + rangeSelectTo(threadKey, orderedThreadKeysRef.current); + return; + } + if (isTrailingDoubleClick(event.detail)) { + return; + } + navigateToThread(threadRef); + }, + [navigateToThread, rangeSelectTo, toggleThreadSelection], + ); + + const attemptSettle = useCallback( + (threadRef: ScopedThreadRef) => { + void (async () => { + const result = await settleThread(threadRef); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to settle thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + })(); + }, + [settleThread], + ); + const attemptUnsettle = useCallback( + (threadRef: ScopedThreadRef) => { + void (async () => { + const result = await unsettleThread(threadRef); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to un-settle thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + })(); + }, + [unsettleThread], + ); + + const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection); + const handleMultiSelectContextMenu = useCallback( + async (position: { x: number; y: number }) => { + const api = readLocalApi(); + if (!api) return; + const threadKeys = [...useThreadSelectionStore.getState().selectedThreadKeys]; + if (threadKeys.length === 0) return; + const count = threadKeys.length; + const clicked = await settlePromise(() => + api.contextMenu.show( + [ + { id: "settle", label: `Settle (${count})` }, + { id: "mark-unread", label: `Mark unread (${count})` }, + { id: "delete", label: `Delete (${count})`, destructive: true }, + ], + position, + ), + ); + if (clicked._tag === "Failure") return; + if (clicked.value === "settle") { + for (const threadKey of threadKeys) { + const thread = threadByKeyRef.current.get(threadKey); + if (!thread) continue; + attemptSettle(scopeThreadRef(thread.environmentId, thread.id)); + } + clearSelection(); + return; + } + if (clicked.value === "mark-unread") { + for (const threadKey of threadKeys) { + const thread = threadByKeyRef.current.get(threadKey); + markThreadUnread(threadKey, thread?.latestTurn?.completedAt); + } + clearSelection(); + return; + } + if (clicked.value !== "delete") return; + if (confirmThreadDelete) { + const confirmed = await settlePromise(() => + api.dialogs.confirm( + [ + `Delete ${count} thread${count === 1 ? "" : "s"}?`, + "This permanently clears conversation history for these threads.", + ].join("\n"), + ), + ); + if (confirmed._tag === "Failure" || !confirmed.value) return; + } + const deletedThreadKeys = new Set(threadKeys); + for (const threadKey of threadKeys) { + const thread = threadByKeyRef.current.get(threadKey); + if (!thread) continue; + const result = await deleteThread(scopeThreadRef(thread.environmentId, thread.id), { + deletedThreadKeys, + }); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to delete threads", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + return; + } + } + removeFromSelection(threadKeys); + }, + [ + attemptSettle, + clearSelection, + confirmThreadDelete, + deleteThread, + markThreadUnread, + removeFromSelection, + ], + ); + + const handleThreadContextMenu = useCallback( + (threadRef: ScopedThreadRef, position: { x: number; y: number }) => { + void (async () => { + const api = readLocalApi(); + if (!api) return; + const threadKey = scopedThreadKey(threadRef); + const selectionState = useThreadSelectionStore.getState(); + if (selectionState.hasSelection() && selectionState.selectedThreadKeys.has(threadKey)) { + await handleMultiSelectContextMenu(position); + return; + } + const thread = threadByKeyRef.current.get(threadKey); + if (!thread) return; + // Match what the user sees: a row renders slim iff it is in the + // settled partition, so the menu label mirrors that exact state. + const isSettled = settledThreadKeysRef.current.has(threadKey); + const clicked = await settlePromise(() => + api.contextMenu.show( + [ + isSettled + ? { id: "unsettle", label: "Un-settle thread" } + : { id: "settle", label: "Settle thread" }, + { id: "mark-unread", label: "Mark unread" }, + { id: "archive", label: "Archive" }, + { id: "delete", label: "Delete", destructive: true, icon: "trash" }, + ], + position, + ), + ); + if (clicked._tag === "Failure") return; + switch (clicked.value) { + case "settle": + attemptSettle(threadRef); + return; + case "unsettle": + attemptUnsettle(threadRef); + return; + case "mark-unread": + markThreadUnread(threadKey, thread.latestTurn?.completedAt); + return; + case "archive": { + const result = await archiveThread(threadRef); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to archive thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + return; + } + case "delete": { + if (confirmThreadDelete) { + const confirmed = await settlePromise(() => + api.dialogs.confirm( + [ + `Delete thread "${thread.title}"?`, + "This permanently clears conversation history for this thread.", + ].join("\n"), + ), + ); + if (confirmed._tag === "Failure" || !confirmed.value) return; + } + const result = await deleteThread(threadRef); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to delete thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + return; + } + default: + return; + } + })(); + }, + [ + archiveThread, + attemptSettle, + attemptUnsettle, + confirmThreadDelete, + deleteThread, + handleMultiSelectContextMenu, + markThreadUnread, + ], + ); + + // Thread jump (cmd+1..9) and prev/next traversal reuse the same commands as + // v1 — the keybinding layer is shared, only the ordered list differs. + const routeTerminalOpen = useTerminalUiStateStore((state) => + routeThreadRef + ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen + : false, + ); + useEffect(() => { + const onWindowKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented || event.repeat) return; + const command = resolveShortcutCommand(event, keybindings, { + platform: navigator.platform, + context: { + terminalFocus: isTerminalFocused(), + terminalOpen: routeTerminalOpen, + modelPickerOpen: isModelPickerOpen(), + }, + }); + const navigateToThreadKey = (targetThreadKey: string | null) => { + if (!targetThreadKey) return false; + const targetThread = threadByKey.get(targetThreadKey); + if (!targetThread) return false; + event.preventDefault(); + event.stopPropagation(); + navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); + return true; + }; + const traversalDirection = threadTraversalDirectionFromCommand(command); + if (traversalDirection !== null) { + navigateToThreadKey( + resolveAdjacentThreadId({ + threadIds: orderedThreadKeys, + currentThreadId: routeThreadKey, + direction: traversalDirection, + }), + ); + return; + } + const jumpIndex = threadJumpIndexFromCommand(command ?? ""); + if (jumpIndex === null) return; + navigateToThreadKey(orderedThreadKeys[jumpIndex] ?? null); + }; + window.addEventListener("keydown", onWindowKeyDown); + return () => window.removeEventListener("keydown", onWindowKeyDown); + }, [ + keybindings, + navigateToThread, + orderedThreadKeys, + routeTerminalOpen, + routeThreadKey, + threadByKey, + ]); + + useEffect(() => { + const sync = (event: KeyboardEvent) => setShowJumpHints(event.metaKey || event.ctrlKey); + const clear = () => setShowJumpHints(false); + window.addEventListener("keydown", sync); + window.addEventListener("keyup", sync); + window.addEventListener("blur", clear); + return () => { + window.removeEventListener("keydown", sync); + window.removeEventListener("keyup", sync); + window.removeEventListener("blur", clear); + }; + }, []); + + const attachListAutoAnimateRef = useCallback((node: HTMLUListElement | null) => { + if (!node) return; + autoAnimate(node, { duration: 150, easing: "ease-out" }); + }, []); + + const handleNewThreadClick = useCallback(() => { + const firstProject = projects[0]; + if (!firstProject) return; + if (isMobile) setOpenMobile(false); + void handleNewThread(scopeProjectRef(firstProject.environmentId, firstProject.id)); + }, [handleNewThread, isMobile, projects, setOpenMobile]); + + const commandPaletteShortcutLabel = shortcutLabelForCommand(keybindings, "commandPalette.toggle"); + const newThreadShortcutLabel = shortcutLabelForCommand(keybindings, "chat.new"); + + return ( + <> + + + + + + + } + > + + Search + {commandPaletteShortcutLabel ? ( + + {commandPaletteShortcutLabel} + + ) : null} + + + + + + New thread + {newThreadShortcutLabel ? ( + + {newThreadShortcutLabel} + + ) : null} + + + + + +
      + {orderedThreads.map((thread) => { + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + const isSettledRow = settledThreadKeys.has(threadKey); + return ( + + ); + })} +
    + {orderedThreads.length === 0 ? ( +
    + No threads yet +
    + ) : null} +
    +
    + + + + ); +} diff --git a/apps/web/src/components/settings/BetaSettingsPanel.tsx b/apps/web/src/components/settings/BetaSettingsPanel.tsx new file mode 100644 index 00000000000..4c30cdb053c --- /dev/null +++ b/apps/web/src/components/settings/BetaSettingsPanel.tsx @@ -0,0 +1,105 @@ +import { useEffect, useState } from "react"; + +import { useClientSettings, useUpdateClientSettings } from "../../hooks/useSettings"; +import { Input } from "../ui/input"; +import { Switch } from "../ui/switch"; +import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout"; + +const AUTO_SETTLE_MIN_DAYS = 1; +const AUTO_SETTLE_MAX_DAYS = 90; +const AUTO_SETTLE_DEFAULT_DAYS = 3; + +function AutoSettleDaysInput({ + value, + onCommit, +}: { + value: number; + onCommit: (days: number) => void; +}) { + // Local draft so the field can be emptied mid-edit; the setting only moves + // on valid input and snaps back to the persisted value on blur. + const [draft, setDraft] = useState(String(value)); + useEffect(() => { + setDraft(String(value)); + }, [value]); + + return ( + { + setDraft(event.target.value); + const parsed = Number.parseInt(event.target.value, 10); + if ( + Number.isFinite(parsed) && + parsed >= AUTO_SETTLE_MIN_DAYS && + parsed <= AUTO_SETTLE_MAX_DAYS + ) { + onCommit(parsed); + } + }} + onBlur={() => setDraft(String(value))} + aria-label="Days of inactivity before auto-settle" + /> + ); +} + +export function BetaSettingsPanel() { + const sidebarV2Enabled = useClientSettings((settings) => settings.sidebarV2Enabled); + const sidebarAutoSettleAfterDays = useClientSettings( + (settings) => settings.sidebarAutoSettleAfterDays, + ); + const updateSettings = useUpdateClientSettings(); + + return ( + + + updateSettings({ sidebarV2Enabled: Boolean(checked) })} + aria-label="Enable the sidebar v2 beta" + /> + } + /> + {sidebarV2Enabled ? ( + <> + + updateSettings({ + sidebarAutoSettleAfterDays: checked ? AUTO_SETTLE_DEFAULT_DAYS : null, + }) + } + aria-label="Auto-settle inactive threads" + /> + } + /> + {sidebarAutoSettleAfterDays !== null ? ( + updateSettings({ sidebarAutoSettleAfterDays: days })} + /> + } + /> + ) : null} + + ) : null} + + + ); +} diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index 6774b6f333f..9d690f2a4e4 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -3,6 +3,7 @@ import { ArchiveIcon, ArrowLeftIcon, BotIcon, + FlaskConicalIcon, GitBranchIcon, KeyboardIcon, Link2Icon, @@ -28,6 +29,7 @@ export type SettingsSectionPath = | "/settings/providers" | "/settings/source-control" | "/settings/connections" + | "/settings/beta" | "/settings/archived"; export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ @@ -40,6 +42,7 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ { label: "Providers", to: "/settings/providers", icon: BotIcon }, { label: "Source Control", to: "/settings/source-control", icon: GitBranchIcon }, { label: "Connections", to: "/settings/connections", icon: Link2Icon }, + { label: "Beta", to: "/settings/beta", icon: FlaskConicalIcon }, { label: "Archive", to: "/settings/archived", icon: ArchiveIcon }, ]; diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx new file mode 100644 index 00000000000..87181ec42cc --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -0,0 +1,113 @@ +import { useAtomValue } from "@effect/atom-react"; +import { SettingsIcon } from "lucide-react"; +import { memo, useCallback } from "react"; +import { Link, useNavigate } from "@tanstack/react-router"; + +import { APP_STAGE_LABEL } from "../../branding"; +import { primaryServerConfigAtom } from "../../state/server"; +import { resolveSidebarStageBadgeLabel } from "../Sidebar.logic"; +import { + SidebarFooter, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarTrigger, + useSidebar, +} from "../ui/sidebar"; +import { SidebarProviderUpdatePill } from "./SidebarProviderUpdatePill"; +import { SidebarUpdatePill } from "./SidebarUpdatePill"; + +export const SidebarChromeHeader = memo(function SidebarChromeHeader({ + isElectron, +}: { + isElectron: boolean; +}) { + return isElectron ? ( + + + + + ) : ( + + + + + ); +}); + +function SidebarBrand() { + const stageLabel = useSidebarStageLabel(); + + return ( + + + + Code + + + {stageLabel} + + + ); +} + +function useSidebarStageLabel() { + const primaryServerVersion = + useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null; + + return resolveSidebarStageBadgeLabel({ + primaryServerVersion, + fallbackStageLabel: APP_STAGE_LABEL, + }); +} + +function T3Wordmark() { + return ( + + + + ); +} + +export const SidebarChromeFooter = memo(function SidebarChromeFooter() { + const navigate = useNavigate(); + const { isMobile, setOpenMobile } = useSidebar(); + const handleSettingsClick = useCallback(() => { + if (isMobile) { + setOpenMobile(false); + } + void navigate({ to: "/settings" }); + }, [isMobile, navigate, setOpenMobile]); + + return ( + + + + + + + + Settings + + + + + ); +}); diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 07655ad30d7..396c9927113 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -50,6 +50,12 @@ export function useThreadActions() { const deleteThreadMutation = useAtomCommand(threadEnvironment.delete, { reportFailure: false, }); + const settleThreadMutation = useAtomCommand(threadEnvironment.settle, { + reportFailure: false, + }); + const unsettleThreadMutation = useAtomCommand(threadEnvironment.unsettle, { + reportFailure: false, + }); const stopThreadSession = useAtomCommand(threadEnvironment.stopSession); const removeWorktree = useAtomCommand(vcsEnvironment.removeWorktree, { reportFailure: false, @@ -345,6 +351,116 @@ export function useThreadActions() { ], ); + const settleThread = useCallback( + async (target: ScopedThreadRef) => { + const resolved = resolveThreadTarget(target); + const settleResult = await settleThreadMutation({ + environmentId: target.environmentId, + input: { threadId: target.threadId }, + }); + if (settleResult._tag === "Failure" || !resolved) { + return settleResult; + } + + // Settling is the natural moment to reclaim disk: offer (never force) a + // worktree removal when this thread is the only one using it and there is + // no unsaved or unpushed work at stake. + const { thread, threadRef } = resolved; + if (thread.worktreePath === null) { + return settleResult; + } + const threads = readEnvironmentThreadRefs(threadRef.environmentId).flatMap((ref) => { + const shell = readThreadShell(ref); + return shell === null ? [] : [shell]; + }); + const orphanedWorktreePath = getOrphanedWorktreePathForThread(threads, threadRef.threadId); + const threadProject = readProject({ + environmentId: threadRef.environmentId, + projectId: thread.projectId, + }); + if (orphanedWorktreePath === null || threadProject === null) { + return settleResult; + } + const displayWorktreePath = formatWorktreePathForDisplay(orphanedWorktreePath); + toastManager.add( + stackedThreadToast({ + type: "info", + title: "Thread settled", + description: `Worktree ${displayWorktreePath} is no longer used by any thread.`, + actionProps: { + children: "Remove worktree", + onClick: () => { + void (async () => { + // Re-validate at click time: the toast may be stale — the + // thread can have woken up or another thread adopted the + // worktree since the settle happened. + const currentShell = readThreadShell(threadRef); + const currentThreads = readEnvironmentThreadRefs(threadRef.environmentId).flatMap( + (ref) => { + const shell = readThreadShell(ref); + return shell === null ? [] : [shell]; + }, + ); + const stillOrphaned = + getOrphanedWorktreePathForThread(currentThreads, threadRef.threadId) === + orphanedWorktreePath; + const stillSettled = currentShell?.settledOverride === "settled"; + if (!stillOrphaned || !stillSettled) { + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "Worktree kept", + description: "This worktree is in use again and was not removed.", + }), + ); + return; + } + // force stays false: git refuses to remove a worktree with + // uncommitted changes, which is the backstop for work created + // after the settle. Branch commits survive worktree removal. + const removeResult = await removeWorktree({ + environmentId: threadRef.environmentId, + input: { + cwd: threadProject.workspaceRoot, + path: orphanedWorktreePath, + force: false, + }, + }); + if (removeResult._tag === "Failure") { + const error = squashAtomCommandFailure(removeResult); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Worktree removal failed", + description: + error instanceof Error ? error.message : "Could not remove the worktree.", + }), + ); + return; + } + await refreshVcsStatus({ + environmentId: threadRef.environmentId, + input: { cwd: threadProject.workspaceRoot }, + }); + })(); + }, + }, + }), + ); + return settleResult; + }, + [refreshVcsStatus, removeWorktree, resolveThreadTarget, settleThreadMutation], + ); + + const unsettleThread = useCallback( + (target: ScopedThreadRef) => + unsettleThreadMutation({ + environmentId: target.environmentId, + input: { threadId: target.threadId, reason: "user" }, + }), + [unsettleThreadMutation], + ); + const confirmAndDeleteThread = useCallback( async (target: ScopedThreadRef) => { const localApi = readLocalApi(); @@ -379,7 +495,16 @@ export function useThreadActions() { unarchiveThread, deleteThread, confirmAndDeleteThread, + settleThread, + unsettleThread, }), - [archiveThread, confirmAndDeleteThread, deleteThread, unarchiveThread], + [ + archiveThread, + confirmAndDeleteThread, + deleteThread, + settleThread, + unarchiveThread, + unsettleThread, + ], ); } diff --git a/apps/web/src/lib/threadSort.test.ts b/apps/web/src/lib/threadSort.test.ts index b9981bc2e3e..ca9a5986c66 100644 --- a/apps/web/src/lib/threadSort.test.ts +++ b/apps/web/src/lib/threadSort.test.ts @@ -26,6 +26,8 @@ function makeThread(overrides: Partial = {}): Thread { proposedPlans: [], createdAt: "2026-03-09T10:00:00.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, updatedAt: "2026-03-09T10:00:00.000Z", latestTurn: null, diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 3a9140e278c..c48b10d2c12 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -19,6 +19,7 @@ import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybi import { Route as SettingsGeneralRouteImport } from './routes/settings.general' import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagnostics' import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections' +import { Route as SettingsBetaRouteImport } from './routes/settings.beta' import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId' @@ -72,6 +73,11 @@ const SettingsConnectionsRoute = SettingsConnectionsRouteImport.update({ path: '/connections', getParentRoute: () => SettingsRoute, } as any) +const SettingsBetaRoute = SettingsBetaRouteImport.update({ + id: '/beta', + path: '/beta', + getParentRoute: () => SettingsRoute, +} as any) const SettingsArchivedRoute = SettingsArchivedRouteImport.update({ id: '/archived', path: '/archived', @@ -94,6 +100,7 @@ export interface FileRoutesByFullPath { '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren '/settings/archived': typeof SettingsArchivedRoute + '/settings/beta': typeof SettingsBetaRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute '/settings/general': typeof SettingsGeneralRoute @@ -107,6 +114,7 @@ export interface FileRoutesByTo { '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren '/settings/archived': typeof SettingsArchivedRoute + '/settings/beta': typeof SettingsBetaRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute '/settings/general': typeof SettingsGeneralRoute @@ -123,6 +131,7 @@ export interface FileRoutesById { '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren '/settings/archived': typeof SettingsArchivedRoute + '/settings/beta': typeof SettingsBetaRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute '/settings/general': typeof SettingsGeneralRoute @@ -140,6 +149,7 @@ export interface FileRouteTypes { | '/pair' | '/settings' | '/settings/archived' + | '/settings/beta' | '/settings/connections' | '/settings/diagnostics' | '/settings/general' @@ -153,6 +163,7 @@ export interface FileRouteTypes { | '/pair' | '/settings' | '/settings/archived' + | '/settings/beta' | '/settings/connections' | '/settings/diagnostics' | '/settings/general' @@ -168,6 +179,7 @@ export interface FileRouteTypes { | '/pair' | '/settings' | '/settings/archived' + | '/settings/beta' | '/settings/connections' | '/settings/diagnostics' | '/settings/general' @@ -257,6 +269,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsConnectionsRouteImport parentRoute: typeof SettingsRoute } + '/settings/beta': { + id: '/settings/beta' + path: '/beta' + fullPath: '/settings/beta' + preLoaderRoute: typeof SettingsBetaRouteImport + parentRoute: typeof SettingsRoute + } '/settings/archived': { id: '/settings/archived' path: '/archived' @@ -297,6 +316,7 @@ const ChatRouteWithChildren = ChatRoute._addFileChildren(ChatRouteChildren) interface SettingsRouteChildren { SettingsArchivedRoute: typeof SettingsArchivedRoute + SettingsBetaRoute: typeof SettingsBetaRoute SettingsConnectionsRoute: typeof SettingsConnectionsRoute SettingsDiagnosticsRoute: typeof SettingsDiagnosticsRoute SettingsGeneralRoute: typeof SettingsGeneralRoute @@ -307,6 +327,7 @@ interface SettingsRouteChildren { const SettingsRouteChildren: SettingsRouteChildren = { SettingsArchivedRoute: SettingsArchivedRoute, + SettingsBetaRoute: SettingsBetaRoute, SettingsConnectionsRoute: SettingsConnectionsRoute, SettingsDiagnosticsRoute: SettingsDiagnosticsRoute, SettingsGeneralRoute: SettingsGeneralRoute, diff --git a/apps/web/src/routes/settings.beta.tsx b/apps/web/src/routes/settings.beta.tsx new file mode 100644 index 00000000000..a1e78f2dff7 --- /dev/null +++ b/apps/web/src/routes/settings.beta.tsx @@ -0,0 +1,11 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { BetaSettingsPanel } from "../components/settings/BetaSettingsPanel"; + +function SettingsBetaRoute() { + return ; +} + +export const Route = createFileRoute("/settings/beta")({ + component: SettingsBetaRoute, +}); diff --git a/apps/web/src/worktreeCleanup.test.ts b/apps/web/src/worktreeCleanup.test.ts index 13ff2f0f73e..89734357889 100644 --- a/apps/web/src/worktreeCleanup.test.ts +++ b/apps/web/src/worktreeCleanup.test.ts @@ -26,6 +26,8 @@ function makeThread(overrides: Partial = {}): Thread { createdAt: "2026-02-13T00:00:00.000Z", updatedAt: "2026-02-13T00:00:00.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, latestTurn: null, branch: null, diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index d9e19889721..4fa05f850e5 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -127,6 +127,10 @@ "types": "./src/state/threadSort.ts", "default": "./src/state/threadSort.ts" }, + "./state/thread-settled": { + "types": "./src/state/threadSettled.ts", + "default": "./src/state/threadSettled.ts" + }, "./state/vcs": { "types": "./src/state/vcs.ts", "default": "./src/state/vcs.ts" diff --git a/packages/client-runtime/src/operations/commands.test.ts b/packages/client-runtime/src/operations/commands.test.ts index 5cc3f0c1a86..0cb1650066c 100644 --- a/packages/client-runtime/src/operations/commands.test.ts +++ b/packages/client-runtime/src/operations/commands.test.ts @@ -21,7 +21,13 @@ import { import * as EnvironmentSupervisor from "../connection/supervisor.ts"; import * as RpcSession from "../rpc/session.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; -import { archiveThread, createProject, stopThreadSession } from "./commands.ts"; +import { + archiveThread, + createProject, + settleThread, + stopThreadSession, + unsettleThread, +} from "./commands.ts"; const TEST_CRYPTO_LAYER = Layer.succeed( Crypto.Crypto, @@ -134,4 +140,35 @@ describe("environment commands", () => { ]); }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), ); + + it.effect("dispatches settle and unsettle commands without timestamps", () => + Effect.gen(function* () { + const dispatched: ClientOrchestrationCommand[] = []; + const supervisor = yield* makeSupervisor(dispatched); + + yield* settleThread({ + commandId: CommandId.make("settle-command"), + threadId: ThreadId.make("thread-1"), + }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); + yield* unsettleThread({ + commandId: CommandId.make("unsettle-command"), + threadId: ThreadId.make("thread-1"), + reason: "user", + }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); + + expect(dispatched).toEqual([ + { + type: "thread.settle", + commandId: "settle-command", + threadId: "thread-1", + }, + { + type: "thread.unsettle", + commandId: "unsettle-command", + threadId: "thread-1", + reason: "user", + }, + ]); + }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), + ); }); diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index a0c3cbe771f..ef767854804 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -35,6 +35,8 @@ export type CreateThreadInput = CommandInput<"thread.create">; export type DeleteThreadInput = CommandInput<"thread.delete">; export type ArchiveThreadInput = CommandInput<"thread.archive">; export type UnarchiveThreadInput = CommandInput<"thread.unarchive">; +export type SettleThreadInput = CommandInput<"thread.settle">; +export type UnsettleThreadInput = CommandInput<"thread.unsettle">; export type UpdateThreadMetadataInput = CommandInput<"thread.meta.update">; export type SetThreadRuntimeModeInput = CommandInput<"thread.runtime-mode.set">; export type SetThreadInteractionModeInput = CommandInput<"thread.interaction-mode.set">; @@ -153,6 +155,26 @@ export const unarchiveThread: (input: UnarchiveThreadInput) => CommandEffect = E }); }); +export const settleThread: (input: SettleThreadInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.settleThread", +)(function* (input) { + return yield* dispatch({ + ...input, + type: "thread.settle", + commandId: yield* commandId(input), + }); +}); + +export const unsettleThread: (input: UnsettleThreadInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.unsettleThread", +)(function* (input) { + return yield* dispatch({ + ...input, + type: "thread.unsettle", + commandId: yield* commandId(input), + }); +}); + export const updateThreadMetadata: (input: UpdateThreadMetadataInput) => CommandEffect = Effect.fn( "EnvironmentCommands.updateThreadMetadata", )(function* (input) { diff --git a/packages/client-runtime/src/state/entities.test.ts b/packages/client-runtime/src/state/entities.test.ts index c772d134a67..e08fd9e552f 100644 --- a/packages/client-runtime/src/state/entities.test.ts +++ b/packages/client-runtime/src/state/entities.test.ts @@ -96,6 +96,8 @@ const THREAD_SHELL = { createdAt: "2026-06-01T00:00:00.000Z", updatedAt: "2026-06-01T00:00:00.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, session: null, latestUserMessageAt: null, hasPendingApprovals: false, diff --git a/packages/client-runtime/src/state/shellReducer.test.ts b/packages/client-runtime/src/state/shellReducer.test.ts index a069460e63c..fdccc4c47dd 100644 --- a/packages/client-runtime/src/state/shellReducer.test.ts +++ b/packages/client-runtime/src/state/shellReducer.test.ts @@ -36,6 +36,8 @@ const stubThread = { createdAt: "2026-04-01T00:00:00.000Z", updatedAt: "2026-04-01T00:00:00.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, latestUserMessageAt: null, hasPendingApprovals: false, hasPendingUserInput: false, diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index aab5110e9cf..ea87298e98f 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -12,9 +12,11 @@ import { type RevertThreadCheckpointInput, type SetThreadInteractionModeInput, type SetThreadRuntimeModeInput, + type SettleThreadInput, type StartThreadTurnInput, type StopThreadSessionInput, type UnarchiveThreadInput, + type UnsettleThreadInput, type UpdateThreadMetadataInput, archiveThread, createThread, @@ -25,9 +27,11 @@ import { revertThreadCheckpoint, setThreadInteractionMode, setThreadRuntimeMode, + settleThread, startThreadTurn, stopThreadSession, unarchiveThread, + unsettleThread, updateThreadMetadata, } from "../operations/commands.ts"; import type { EnvironmentRegistry } from "../connection/registry.ts"; @@ -42,9 +46,11 @@ export type { RevertThreadCheckpointInput, SetThreadInteractionModeInput, SetThreadRuntimeModeInput, + SettleThreadInput, StartThreadTurnInput, StopThreadSessionInput, UnarchiveThreadInput, + UnsettleThreadInput, UpdateThreadMetadataInput, } from "../operations/commands.ts"; @@ -82,6 +88,18 @@ export function createThreadEnvironmentAtoms( scheduler, concurrency, }), + settle: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:settle", + execute: (input: SettleThreadInput) => settleThread(input), + scheduler, + concurrency, + }), + unsettle: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:unsettle", + execute: (input: UnsettleThreadInput) => unsettleThread(input), + scheduler, + concurrency, + }), updateMetadata: createEnvironmentCommand(runtime, { label: "environment-data:commands:thread:update-metadata", execute: (input: UpdateThreadMetadataInput) => updateThreadMetadata(input), diff --git a/packages/client-runtime/src/state/threadDetail.ts b/packages/client-runtime/src/state/threadDetail.ts index 430900bdbb0..770738ad9bb 100644 --- a/packages/client-runtime/src/state/threadDetail.ts +++ b/packages/client-runtime/src/state/threadDetail.ts @@ -57,6 +57,8 @@ export function mergeEnvironmentThread( createdAt: shell.createdAt, updatedAt: shell.updatedAt, archivedAt: shell.archivedAt, + settledOverride: shell.settledOverride, + settledAt: shell.settledAt, session: shell.session, }; } diff --git a/packages/client-runtime/src/state/threadReducer.test.ts b/packages/client-runtime/src/state/threadReducer.test.ts index 94eb1c65370..211f8748f4e 100644 --- a/packages/client-runtime/src/state/threadReducer.test.ts +++ b/packages/client-runtime/src/state/threadReducer.test.ts @@ -34,6 +34,8 @@ const baseThread: OrchestrationThread = { createdAt: "2026-04-01T00:00:00.000Z", updatedAt: "2026-04-01T00:00:00.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, messages: [], proposedPlans: [], @@ -164,6 +166,62 @@ describe("applyThreadDetailEvent", () => { }); }); + describe("thread.settled / thread.unsettled", () => { + it("sets the settled override and timestamp", () => { + const settledAt = "2026-04-01T05:00:00.000Z"; + const result = applyThreadDetailEvent(baseThread, { + ...baseEventFields, + sequence: 5, + occurredAt: settledAt, + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.settled", + payload: { + threadId: ThreadId.make("thread-1"), + settledAt, + updatedAt: settledAt, + }, + }); + + expect(result.kind).toBe("updated"); + if (result.kind === "updated") { + expect(result.thread.settledOverride).toBe("settled"); + expect(result.thread.settledAt).toBe(settledAt); + } + }); + + it.each([ + ["user", "active"], + ["activity", null], + ] as const)("unsettles for %s with override %s", (reason, settledOverride) => { + const settledThread: OrchestrationThread = { + ...baseThread, + settledOverride: "settled", + settledAt: "2026-04-01T05:00:00.000Z", + }; + const updatedAt = "2026-04-01T06:00:00.000Z"; + const result = applyThreadDetailEvent(settledThread, { + ...baseEventFields, + sequence: 6, + occurredAt: updatedAt, + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.unsettled", + payload: { + threadId: ThreadId.make("thread-1"), + reason, + updatedAt, + }, + }); + + expect(result.kind).toBe("updated"); + if (result.kind === "updated") { + expect(result.thread.settledOverride).toBe(settledOverride); + expect(result.thread.settledAt).toBeNull(); + } + }); + }); + describe("thread.meta-updated", () => { it("patches title and branch", () => { const result = applyThreadDetailEvent(baseThread, { diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index 670540fee70..7fdee37c0e6 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -72,6 +72,8 @@ export function applyThreadDetailEvent( createdAt: event.payload.createdAt, updatedAt: event.payload.updatedAt, archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, messages: [], proposedPlans: [], @@ -100,6 +102,28 @@ export function applyThreadDetailEvent( thread: { ...thread, archivedAt: null, updatedAt: event.payload.updatedAt }, }; + case "thread.settled": + return { + kind: "updated", + thread: { + ...thread, + settledOverride: "settled", + settledAt: event.payload.settledAt, + updatedAt: event.payload.updatedAt, + }, + }; + + case "thread.unsettled": + return { + kind: "updated", + thread: { + ...thread, + settledOverride: event.payload.reason === "user" ? "active" : null, + settledAt: null, + updatedAt: event.payload.updatedAt, + }, + }; + // ── Thread metadata ───────────────────────────────────────────── case "thread.meta-updated": return { diff --git a/packages/client-runtime/src/state/threadSettled.test.ts b/packages/client-runtime/src/state/threadSettled.test.ts new file mode 100644 index 00000000000..558d8ab2537 --- /dev/null +++ b/packages/client-runtime/src/state/threadSettled.test.ts @@ -0,0 +1,178 @@ +import { + ProjectId, + ProviderInstanceId, + ThreadId, + TurnId, + type OrchestrationThreadShell, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + effectiveSettled, + threadLastActivityAt, + type ChangeRequestStateLike, +} from "./threadSettled.ts"; + +const NOW = "2026-04-10T00:00:00.000Z"; +const FRESH = "2026-04-09T00:00:00.000Z"; +const STALE = "2026-04-06T23:59:59.999Z"; + +function makeShell(input: { + readonly settledOverride: OrchestrationThreadShell["settledOverride"]; + readonly activityAt: string | null; + readonly sessionStatus?: "starting" | "running"; +}): OrchestrationThreadShell { + const threadId = ThreadId.make("thread-1"); + return { + id: threadId, + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: + input.activityAt === null + ? null + : { + turnId: TurnId.make("turn-1"), + state: "completed", + requestedAt: input.activityAt, + startedAt: null, + completedAt: null, + assistantMessageId: null, + }, + createdAt: "2026-04-01T00:00:00.000Z", + updatedAt: NOW, + archivedAt: null, + settledOverride: input.settledOverride, + settledAt: input.settledOverride === "settled" ? NOW : null, + session: + input.sessionStatus === undefined + ? null + : { + threadId, + status: input.sessionStatus, + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: NOW, + }, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + }; +} + +describe("threadLastActivityAt", () => { + it("returns the latest real user or turn activity and ignores thread/session updates", () => { + const shell = makeShell({ settledOverride: null, activityAt: null, sessionStatus: "running" }); + const withActivity: OrchestrationThreadShell = { + ...shell, + latestUserMessageAt: "2026-04-04T00:00:00.000Z", + latestTurn: { + turnId: TurnId.make("turn-1"), + state: "completed", + requestedAt: "2026-04-03T00:00:00.000Z", + startedAt: "2026-04-05T00:00:00.000Z", + completedAt: "2026-04-06T00:00:00.000Z", + assistantMessageId: null, + }, + }; + + expect(threadLastActivityAt(withActivity)).toBe("2026-04-06T00:00:00.000Z"); + expect(threadLastActivityAt(shell)).toBeNull(); + }); +}); + +describe("effectiveSettled", () => { + const overrides = ["settled", "active", null] as const; + const changeRequestStates = [undefined, "open", "merged"] as const; + const inactivityCases = [ + ["fresh", FRESH], + ["stale", STALE], + ["no-activity", null], + ] as const; + const runningCases = [false, true] as const; + const truthTable = overrides.flatMap((settledOverride) => + changeRequestStates.flatMap((changeRequestState) => + inactivityCases.flatMap(([inactivity, activityAt]) => + runningCases.map((running) => ({ + settledOverride, + changeRequestState, + inactivity, + activityAt, + running, + expected: + settledOverride === "settled" || + (settledOverride === null && + !running && + (changeRequestState === "merged" || inactivity === "stale")), + })), + ), + ), + ); + + it.each(truthTable)( + "override=$settledOverride pr=$changeRequestState inactivity=$inactivity running=$running", + ({ settledOverride, changeRequestState, activityAt, running, expected }) => { + const shell = makeShell({ + settledOverride, + activityAt, + ...(running ? { sessionStatus: "running" as const } : {}), + }); + const changeRequestOptions = + changeRequestState === undefined + ? {} + : { changeRequestState: changeRequestState as ChangeRequestStateLike }; + + expect( + effectiveSettled(shell, { + now: NOW, + autoSettleAfterDays: 3, + ...changeRequestOptions, + }), + ).toBe(expected); + }, + ); + + it("treats closed change requests like merged ones", () => { + const shell = makeShell({ settledOverride: null, activityAt: null }); + expect( + effectiveSettled(shell, { + now: NOW, + autoSettleAfterDays: null, + changeRequestState: "closed", + }), + ).toBe(true); + }); + + it("never auto-settles a starting session", () => { + const shell = makeShell({ + settledOverride: null, + activityAt: STALE, + sessionStatus: "starting", + }); + expect( + effectiveSettled(shell, { + now: NOW, + autoSettleAfterDays: 3, + changeRequestState: "merged", + }), + ).toBe(false); + }); + + it("uses a strict inactivity boundary and honors a null threshold", () => { + const boundary = makeShell({ + settledOverride: null, + activityAt: "2026-04-07T00:00:00.000Z", + }); + const stale = makeShell({ settledOverride: null, activityAt: STALE }); + + expect(effectiveSettled(boundary, { now: NOW, autoSettleAfterDays: 3 })).toBe(false); + expect(effectiveSettled(stale, { now: NOW, autoSettleAfterDays: null })).toBe(false); + }); +}); diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts new file mode 100644 index 00000000000..5d7e2d92369 --- /dev/null +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -0,0 +1,51 @@ +import type { OrchestrationThreadShell } from "@t3tools/contracts"; + +export type ChangeRequestStateLike = "open" | "closed" | "merged"; + +const DAY_MS = 24 * 60 * 60 * 1_000; + +export function threadLastActivityAt(shell: OrchestrationThreadShell): string | null { + const candidates = [ + shell.latestUserMessageAt, + shell.latestTurn?.requestedAt, + shell.latestTurn?.startedAt, + shell.latestTurn?.completedAt, + ]; + let latest: string | null = null; + let latestTimestamp = Number.NEGATIVE_INFINITY; + + for (const candidate of candidates) { + if (candidate === null || candidate === undefined) continue; + const timestamp = Date.parse(candidate); + if (timestamp > latestTimestamp) { + latest = candidate; + latestTimestamp = timestamp; + } + } + + return latest; +} + +export function effectiveSettled( + shell: OrchestrationThreadShell, + options: { + readonly now: string; + readonly autoSettleAfterDays: number | null; + readonly changeRequestState?: ChangeRequestStateLike | null; + }, +): boolean { + if (shell.settledOverride === "settled") return true; + if (shell.settledOverride === "active") return false; + if (shell.session?.status === "starting" || shell.session?.status === "running") return false; + if (options.changeRequestState === "merged" || options.changeRequestState === "closed") { + return true; + } + if (options.autoSettleAfterDays === null) return false; + + const lastActivityAt = threadLastActivityAt(shell); + if (lastActivityAt === null) return false; + + return ( + Date.parse(lastActivityAt) < Date.parse(options.now) - options.autoSettleAfterDays * DAY_MS + ); +} diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index 8e982723d22..2ce8026a62f 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -67,6 +67,8 @@ const BASE_THREAD: OrchestrationThread = { createdAt: "2026-04-01T00:00:00.000Z", updatedAt: "2026-04-01T00:00:00.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, messages: [], proposedPlans: [], diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 29a732ca69b..bdc7169821e 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -15,6 +15,8 @@ import { ProjectMetaUpdatedPayload, OrchestrationProposedPlan, OrchestrationSession, + OrchestrationThread, + OrchestrationThreadShell, ProjectCreateCommand, ThreadMetaUpdatedPayload, ThreadTurnStartCommand, @@ -37,6 +39,8 @@ const decodeThreadTurnStartRequestedPayload = Schema.decodeUnknownEffect( const decodeOrchestrationLatestTurn = Schema.decodeUnknownEffect(OrchestrationLatestTurn); const decodeOrchestrationProposedPlan = Schema.decodeUnknownEffect(OrchestrationProposedPlan); const decodeOrchestrationSession = Schema.decodeUnknownEffect(OrchestrationSession); +const decodeOrchestrationThread = Schema.decodeUnknownEffect(OrchestrationThread); +const decodeOrchestrationThreadShell = Schema.decodeUnknownEffect(OrchestrationThreadShell); const encodeThreadCreatedPayload = Schema.encodeEffect(ThreadCreatedPayload); function getOptionValue( @@ -344,6 +348,65 @@ it.effect("decodes thread archive and unarchive commands", () => }), ); +it.effect("decodes thread settle and unsettle commands", () => + Effect.gen(function* () { + const settle = yield* decodeOrchestrationCommand({ + type: "thread.settle", + commandId: "cmd-settle-1", + threadId: "thread-1", + }); + const unsettle = yield* decodeOrchestrationCommand({ + type: "thread.unsettle", + commandId: "cmd-unsettle-1", + threadId: "thread-1", + reason: "activity", + }); + + assert.strictEqual(settle.type, "thread.settle"); + assert.strictEqual(unsettle.type, "thread.unsettle"); + }), +); + +it.effect("defaults settled fields when decoding historical thread data", () => + Effect.gen(function* () { + const common = { + id: "thread-1", + projectId: "project-1", + title: "Historical thread", + modelSelection: { provider: "codex", model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + archivedAt: null, + session: null, + }; + const thread = yield* decodeOrchestrationThread({ + ...common, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + }); + const shell = yield* decodeOrchestrationThreadShell({ + ...common, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + }); + + assert.strictEqual(thread.settledOverride, null); + assert.strictEqual(thread.settledAt, null); + assert.strictEqual(shell.settledOverride, null); + assert.strictEqual(shell.settledAt, null); + }), +); + it.effect("decodes thread archived and unarchived events", () => Effect.gen(function* () { const archived = yield* decodeOrchestrationEvent({ @@ -388,6 +451,48 @@ it.effect("decodes thread archived and unarchived events", () => }), ); +it.effect("decodes thread settled and unsettled events", () => + Effect.gen(function* () { + const settled = yield* decodeOrchestrationEvent({ + sequence: 1, + eventId: "event-settle-1", + aggregateKind: "thread", + aggregateId: "thread-1", + type: "thread.settled", + occurredAt: "2026-01-01T00:00:00.000Z", + commandId: "cmd-settle-1", + causationEventId: null, + correlationId: "cmd-settle-1", + metadata: {}, + payload: { + threadId: "thread-1", + settledAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + }); + const unsettled = yield* decodeOrchestrationEvent({ + sequence: 2, + eventId: "event-unsettle-1", + aggregateKind: "thread", + aggregateId: "thread-1", + type: "thread.unsettled", + occurredAt: "2026-01-02T00:00:00.000Z", + commandId: "cmd-unsettle-1", + causationEventId: null, + correlationId: "cmd-unsettle-1", + metadata: {}, + payload: { + threadId: "thread-1", + reason: "user", + updatedAt: "2026-01-02T00:00:00.000Z", + }, + }); + + assert.strictEqual(settled.type, "thread.settled"); + assert.strictEqual(unsettled.type, "thread.unsettled"); + }), +); + it.effect("accepts provider-scoped model options in thread.turn.start", () => Effect.gen(function* () { const parsed = yield* decodeThreadTurnStartCommand({ diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 7e9c421b5df..80b7177f9b7 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -356,6 +356,10 @@ export const OrchestrationThread = Schema.Struct({ createdAt: IsoDateTime, updatedAt: IsoDateTime, archivedAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))), + settledOverride: Schema.NullOr(Schema.Literals(["settled", "active"])).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), + settledAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))), deletedAt: Schema.NullOr(IsoDateTime), messages: Schema.Array(OrchestrationMessage), proposedPlans: Schema.Array(OrchestrationProposedPlan).pipe( @@ -402,6 +406,10 @@ export const OrchestrationThreadShell = Schema.Struct({ createdAt: IsoDateTime, updatedAt: IsoDateTime, archivedAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))), + settledOverride: Schema.NullOr(Schema.Literals(["settled", "active"])).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), + settledAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))), session: Schema.NullOr(OrchestrationSession), latestUserMessageAt: Schema.NullOr(IsoDateTime), hasPendingApprovals: Schema.Boolean, @@ -545,6 +553,19 @@ const ThreadUnarchiveCommand = Schema.Struct({ threadId: ThreadId, }); +const ThreadSettleCommand = Schema.Struct({ + type: Schema.Literal("thread.settle"), + commandId: CommandId, + threadId: ThreadId, +}); + +const ThreadUnsettleCommand = Schema.Struct({ + type: Schema.Literal("thread.unsettle"), + commandId: CommandId, + threadId: ThreadId, + reason: Schema.Literals(["user", "activity"]), +}); + const ThreadMetaUpdateCommand = Schema.Struct({ type: Schema.Literal("thread.meta.update"), commandId: CommandId, @@ -687,6 +708,8 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ ThreadDeleteCommand, ThreadArchiveCommand, ThreadUnarchiveCommand, + ThreadSettleCommand, + ThreadUnsettleCommand, ThreadMetaUpdateCommand, ThreadRuntimeModeSetCommand, ThreadInteractionModeSetCommand, @@ -708,6 +731,8 @@ export const ClientOrchestrationCommand = Schema.Union([ ThreadDeleteCommand, ThreadArchiveCommand, ThreadUnarchiveCommand, + ThreadSettleCommand, + ThreadUnsettleCommand, ThreadMetaUpdateCommand, ThreadRuntimeModeSetCommand, ThreadInteractionModeSetCommand, @@ -810,6 +835,8 @@ export const OrchestrationEventType = Schema.Literals([ "thread.deleted", "thread.archived", "thread.unarchived", + "thread.settled", + "thread.unsettled", "thread.meta-updated", "thread.runtime-mode-set", "thread.interaction-mode-set", @@ -889,6 +916,18 @@ export const ThreadUnarchivedPayload = Schema.Struct({ updatedAt: IsoDateTime, }); +export const ThreadSettledPayload = Schema.Struct({ + threadId: ThreadId, + settledAt: IsoDateTime, + updatedAt: IsoDateTime, +}); + +export const ThreadUnsettledPayload = Schema.Struct({ + threadId: ThreadId, + reason: Schema.Literals(["user", "activity"]), + updatedAt: IsoDateTime, +}); + export const ThreadMetaUpdatedPayload = Schema.Struct({ threadId: ThreadId, title: Schema.optional(TrimmedNonEmptyString), @@ -1056,6 +1095,16 @@ export const OrchestrationEvent = Schema.Union([ type: Schema.Literal("thread.unarchived"), payload: ThreadUnarchivedPayload, }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.settled"), + payload: ThreadSettledPayload, + }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.unsettled"), + payload: ThreadUnsettledPayload, + }), Schema.Struct({ ...EventBaseFields, type: Schema.Literal("thread.meta-updated"), diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index ac2d47ca336..773a37f448b 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -31,6 +31,20 @@ describe("ClientSettings word wrap", () => { }); }); +describe("ClientSettings sidebar v2", () => { + it("defaults the beta off with a three-day auto-settle threshold", () => { + const settings = decodeClientSettings({}); + expect(settings.sidebarV2Enabled).toBe(false); + expect(settings.sidebarAutoSettleAfterDays).toBe(3); + }); + + it("allows auto-settle by inactivity to be disabled", () => { + expect( + decodeClientSettings({ sidebarAutoSettleAfterDays: null }).sidebarAutoSettleAfterDays, + ).toBeNull(); + }); +}); + describe("ServerSettings.providerInstances (slice-2 invariant)", () => { it("defaults to an empty record so legacy configs without the key still decode", () => { expect(DEFAULT_SERVER_SETTINGS.providerInstances).toEqual({}); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 6ccd65533dd..ecf7c47678c 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -72,6 +72,9 @@ export const ClientSettingsSchema = Schema.Struct({ modelOrder: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))), }), ).pipe(Schema.withDecodingDefault(Effect.succeed({}))), + sidebarAutoSettleAfterDays: Schema.NullOr(Schema.Number).pipe( + Schema.withDecodingDefault(Effect.succeed(3)), + ), sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)), ), @@ -88,6 +91,7 @@ export const ClientSettingsSchema = Schema.Struct({ sidebarThreadPreviewCount: SidebarThreadPreviewCount.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT)), ), + sidebarV2Enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), timestampFormat: TimestampFormat.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)), ), @@ -559,6 +563,7 @@ export const ClientSettingsPatch = Schema.Struct({ }), ), ), + sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(Schema.Number)), sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode), sidebarProjectGroupingOverrides: Schema.optionalKey( Schema.Record(TrimmedNonEmptyString, SidebarProjectGroupingMode), @@ -566,6 +571,7 @@ export const ClientSettingsPatch = Schema.Struct({ sidebarProjectSortOrder: Schema.optionalKey(SidebarProjectSortOrder), sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder), sidebarThreadPreviewCount: Schema.optionalKey(SidebarThreadPreviewCount), + sidebarV2Enabled: Schema.optionalKey(Schema.Boolean), timestampFormat: Schema.optionalKey(TimestampFormat), wordWrap: Schema.optionalKey(Schema.Boolean), }); From e17a333d3f356526799cdb10c2db65fef9f50328 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 14 Jul 2026 02:56:04 -0700 Subject: [PATCH 02/14] Iterate sidebar v2 from live feedback: card islands, project scope, creation picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design iteration after testing the beta over Tailscale: - Settled is the only collapse rule. Reverted the "seen Ready threads render slim" heuristic — it silently turned settled back into a derived state. Every unsettled thread is a full card; density is earned by settling. - Cards are bordered islands (rounded-xl, state-colored border: amber approval / sky working / red failed), status dot replaces the favicon on stateful cards, live elapsed timer on working. Slim rows grew to 34px with 13px titles; a hairline divider separates cards from the settled tail. - Harness identity is the provider glyph only (model name in tooltip). The full-row harness tint experiment is removed. - Hover actions (Settle/Un-settle) are absolutely-positioned overlays that fade over the timestamp — no layout shift. - Project scope chips above the list (All / per-project / + add): filters the flat list and informs the new-thread default. Session-local. - New thread opens a project picker dialog: contextual default preselected and focused (Enter confirms), all projects with workspace paths, Add project row. Single-project setups skip the dialog. chat.new (mod+shift+o) routes through the picker via newThreadPickerBus since the shortcut is handled in the _chat route layout. Arrow/Home/End navigation in the list. - chat.newLocal still bypasses the picker as the explicit escape hatch. Co-Authored-By: Claude Fable 5 --- apps/web/src/components/SidebarV2.tsx | 515 +++++++++++++++++++++----- apps/web/src/newThreadPickerBus.ts | 14 + apps/web/src/routes/_chat.tsx | 14 + 3 files changed, 452 insertions(+), 91 deletions(-) create mode 100644 apps/web/src/newThreadPickerBus.ts diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 15ee72a2f19..c46cb39fc9b 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -42,7 +42,14 @@ import { readLocalApi } from "../localApi"; import { useUiStateStore } from "../uiStateStore"; import { useThreadSelectionStore } from "../threadSelectionStore"; import { useThreadActions } from "../hooks/useThreadActions"; -import { useNewThreadHandler } from "../hooks/useHandleNewThread"; +import { useHandleNewThread } from "../hooks/useHandleNewThread"; +import { useOpenAddProjectCommandPalette } from "../commandPaletteContext"; +import { onOpenNewThreadPicker } from "../newThreadPickerBus"; +import { Dialog, DialogHeader, DialogPopup, DialogTitle } from "./ui/dialog"; +import { + startNewThreadFromContext, + startNewThreadInProjectFromContext, +} from "../lib/chatThreadActions"; import { useClientSettings } from "../hooks/useSettings"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { useProjects, useThreadShells } from "../state/entities"; @@ -82,7 +89,16 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; // Row heights are fixed per variant so the list only changes shape at // lifecycle transitions (settle/unsettle), never from streaming updates. -const RAIL_CLASS_BY_STATUS: Partial> = { +// Cards are bordered islands (the mock's language): the border carries the +// state color, faint enough to read as a glow rather than a box. +const CARD_BORDER_BY_STATUS: Record = { + approval: "border-amber-500/45 dark:border-amber-300/35", + working: "border-sky-500/40 dark:border-sky-300/30", + failed: "border-red-500/45 dark:border-red-400/35", + ready: "border-border", +}; + +const STATUS_DOT_BY_STATUS: Partial> = { approval: "bg-amber-500 dark:bg-amber-300/90", working: "bg-sky-500 dark:bg-sky-300/80 animate-pulse", failed: "bg-red-500 dark:bg-red-400/90", @@ -91,7 +107,7 @@ const RAIL_CLASS_BY_STATUS: Partial> = { const STATUS_WORD_BY_STATUS: Partial< Record > = { - approval: { label: "Approval", className: "text-amber-600 dark:text-amber-300/90" }, + approval: { label: "Needs approval", className: "text-amber-600 dark:text-amber-300/90" }, working: { label: "Working", className: "text-sky-600 dark:text-sky-300/80" }, failed: { label: "Failed", className: "text-red-600 dark:text-red-400/90" }, }; @@ -122,6 +138,13 @@ function threadTimeLabel(thread: SidebarThreadSummary, status: SidebarV2Status): const SidebarV2Row = memo(function SidebarV2Row(props: { thread: SidebarThreadSummary; variant: "card" | "slim"; + // Slim rows are either settled (action: un-settle) or merely quiet + // (seen Ready threads — action: settle). + variantAction: "settle" | "unsettle"; + // Draws a hairline above the first quiet row after the card block, so the + // active island and the history tail read as separate zones (mock's + // SETTLED rule) without a labeled section header. + showQuietDivider?: boolean; isActive: boolean; jumpLabel: string | null; currentEnvironmentId: string | null; @@ -142,6 +165,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { onUnsettle, thread, variant, + variantAction, } = props; const threadRef = useMemo( () => scopeThreadRef(thread.environmentId, thread.id), @@ -253,135 +277,180 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { return (
  • + {props.showQuietDivider ? ( +
    + ) : null}
    {favicon} - + {thread.title} {prBadge} - - - {props.jumpLabel ?? - formatRelativeTimeLabel(thread.latestUserMessageAt ?? thread.updatedAt)} + + + {props.jumpLabel ?? + formatRelativeTimeLabel(thread.latestUserMessageAt ?? thread.updatedAt)} + + {variantAction === "unsettle" ? ( + + ) : ( + + )}
  • ); } - const rail = RAIL_CLASS_BY_STATUS[status]; + const statusDot = STATUS_DOT_BY_STATUS[status]; const statusWord = STATUS_WORD_BY_STATUS[status]; const diff = latestTurnDiff(thread); + const workingTimer = + status === "working" && thread.latestTurn?.startedAt + ? formatElapsedDurationLabel(thread.latestTurn.startedAt) + : null; return (
  • - {rail ? ( - - ) : null} -
    - {favicon} +
    + {statusDot ? ( + + ) : ( + favicon + )} {thread.title} {diff ? ( - + +{diff.insertions}{" "} −{diff.deletions} ) : null} - - - {props.jumpLabel ?? threadTimeLabel(thread, status)} + + + {props.jumpLabel ?? workingTimer ?? threadTimeLabel(thread, status)} + +
    -
    +
    {statusWord ? ( - {statusWord.label} + + {statusWord.label} + ) : null} {status === "failed" && thread.session?.lastError ? ( {thread.session.lastError} - ) : null} - {thread.branch ? ( - {thread.branch} - ) : null} - {prBadge} - + ) : ( + <> + {thread.branch ? ( + + {thread.branch} + + ) : null} + {prBadge} + + )} + {driverKind ? ( - + + } + > + + + {thread.modelSelection.model} + + ) : null} + {isRemote ? ( + + + } + > + + + + Running on {props.environmentLabel ?? "a remote environment"} + + ) : null} - {thread.modelSelection.model} - {isRemote ? ( - - - } - > - - {props.environmentLabel ?? "Remote"} - - - Running on {props.environmentLabel ?? "a remote environment"} - - - ) : null}
  • @@ -406,7 +475,8 @@ export default function SidebarV2() { const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); const { settleThread, unsettleThread, archiveThread, deleteThread } = useThreadActions(); - const handleNewThread = useNewThreadHandler(); + const newThreadContext = useHandleNewThread(); + const openAddProjectCommandPalette = useOpenAddProjectCommandPalette(); const { environments } = useEnvironments(); const primaryEnvironmentId = usePrimaryEnvironmentId(); const clearSelection = useThreadSelectionStore((s) => s.clearSelection); @@ -480,9 +550,29 @@ export default function SidebarV2() { [], ); + // Project scope: chips above the list. Scoping filters the list AND + // becomes the new-thread target — one visible control doing both jobs the + // old per-project headers did. + const [projectScopeKey, setProjectScopeKey] = useState(null); + const scopedProject = useMemo( + () => + projectScopeKey === null + ? null + : (projects.find( + (project) => `${project.environmentId}:${project.id}` === projectScopeKey, + ) ?? null), + [projectScopeKey, projects], + ); + const { activeThreads, settledThreads } = useMemo(() => { const now = `${nowMinute}:00.000Z`; - const visible = threads.filter((thread) => thread.archivedAt === null); + const visible = threads.filter( + (thread) => + thread.archivedAt === null && + (scopedProject === null || + (thread.environmentId === scopedProject.environmentId && + thread.projectId === scopedProject.id)), + ); const active: EnvironmentThreadShell[] = []; const settled: EnvironmentThreadShell[] = []; for (const thread of visible) { @@ -502,7 +592,7 @@ export default function SidebarV2() { Date.parse(left.latestUserMessageAt ?? left.updatedAt), ), }; - }, [autoSettleAfterDays, changeRequestStateByKey, nowMinute, threads]); + }, [autoSettleAfterDays, changeRequestStateByKey, nowMinute, scopedProject, threads]); const orderedThreads = useMemo( () => [...activeThreads, ...settledThreads], @@ -884,12 +974,77 @@ export default function SidebarV2() { autoAnimate(node, { duration: 150, easing: "ease-out" }); }, []); + // New thread defaults to the project you're in (active thread's project, + // falling back to the top project) — same resolution the command palette + // uses. The chevron menu is the explicit project picker the flat list no + // longer gets from per-project headers. + const [newThreadPickerOpen, setNewThreadPickerOpen] = useState(false); + // chat.new (mod+shift+o / mod+n) is handled by the _chat route layout; in + // v2 with multiple projects it opens this picker via the event bus. + useEffect(() => onOpenNewThreadPicker(() => setNewThreadPickerOpen(true)), []); const handleNewThreadClick = useCallback(() => { - const firstProject = projects[0]; - if (!firstProject) return; - if (isMobile) setOpenMobile(false); - void handleNewThread(scopeProjectRef(firstProject.environmentId, firstProject.id)); - }, [handleNewThread, isMobile, projects, setOpenMobile]); + // One project: nothing to pick, create immediately. + if (projects.length <= 1) { + if (isMobile) setOpenMobile(false); + void startNewThreadFromContext({ + activeDraftThread: newThreadContext.activeDraftThread, + activeThread: newThreadContext.activeThread ?? undefined, + defaultProjectRef: newThreadContext.defaultProjectRef, + handleNewThread: newThreadContext.handleNewThread, + }); + return; + } + setNewThreadPickerOpen(true); + }, [isMobile, newThreadContext, projects.length, setOpenMobile]); + const createThreadInProject = useCallback( + (environmentId: (typeof projects)[number]["environmentId"], projectId: string) => { + setNewThreadPickerOpen(false); + if (isMobile) setOpenMobile(false); + const project = projects.find( + (candidate) => candidate.environmentId === environmentId && candidate.id === projectId, + ); + if (!project) return; + void startNewThreadInProjectFromContext( + { + activeDraftThread: newThreadContext.activeDraftThread, + activeThread: newThreadContext.activeThread ?? undefined, + defaultProjectRef: newThreadContext.defaultProjectRef, + handleNewThread: newThreadContext.handleNewThread, + }, + scopeProjectRef(project.environmentId, project.id), + ); + }, + [isMobile, newThreadContext, projects, setOpenMobile], + ); + const activeProjectRef = (() => { + const activeThread = routeThreadKey ? threadByKey.get(routeThreadKey) : null; + return activeThread + ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) + : null; + })(); + const newThreadTargetRef = scopedProject + ? scopeProjectRef(scopedProject.environmentId, scopedProject.id) + : (activeProjectRef ?? newThreadContext.defaultProjectRef); + const newThreadTargetProject = newThreadTargetRef + ? (projects.find( + (project) => + project.environmentId === newThreadTargetRef.environmentId && + project.id === newThreadTargetRef.projectId, + ) ?? null) + : null; + // Picker order: the contextual default first (preselected), everything else + // after — the common case is Enter/click on the top row. + const newThreadPickerProjects = useMemo(() => { + if (!newThreadTargetProject) return projects; + return [ + newThreadTargetProject, + ...projects.filter( + (project) => + project.environmentId !== newThreadTargetProject.environmentId || + project.id !== newThreadTargetProject.id, + ), + ]; + }, [newThreadTargetProject, projects]); const commandPaletteShortcutLabel = shortcutLabelForCommand(keybindings, "commandPalette.toggle"); const newThreadShortcutLabel = shortcutLabelForCommand(keybindings, "chat.new"); @@ -937,16 +1092,97 @@ export default function SidebarV2() { + {projects.length > 0 ? ( + +
    + {projects.length > 1 ? ( + + ) : null} + {projects.map((project) => { + const scopeKey = `${project.environmentId}:${project.id}`; + const isScoped = projectScopeKey === scopeKey; + return ( + + ); + })} + + + } + > + + + Add project + +
    +
    + ) : null} -
      - {orderedThreads.map((thread) => { +
        + {orderedThreads.map((thread, threadIndex) => { const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); const isSettledRow = settledThreadKeys.has(threadKey); + // Settled is the ONLY thing that collapses a row: every + // not-settled thread is a full card. Density comes from users + // (or the auto rules) actually settling work, not from the + // sidebar second-guessing what still matters. + const isCard = !isSettledRow; + const previousThread = threadIndex > 0 ? orderedThreads[threadIndex - 1] : null; + const previousWasCard = + previousThread != null && + !settledThreadKeys.has( + scopedThreadKey(scopeThreadRef(previousThread.environmentId, previousThread.id)), + ); + const showQuietDivider = !isCard && previousWasCard; return ( {orderedThreads.length === 0 ? ( -
        - No threads yet +
        + {projects.length === 0 ? ( + <> + No projects yet + + + ) : scopedProject ? ( + `No threads in ${scopedProject.title} yet` + ) : ( + "No threads yet" + )}
        ) : null} + + + + New thread in… + +
        { + if ( + event.key !== "ArrowDown" && + event.key !== "ArrowUp" && + event.key !== "Home" && + event.key !== "End" + ) { + return; + } + const container = event.currentTarget; + const options = [...container.querySelectorAll("button")]; + if (options.length === 0) return; + const currentIndex = options.findIndex((option) => option === document.activeElement); + const nextIndex = + event.key === "Home" + ? 0 + : event.key === "End" + ? options.length - 1 + : event.key === "ArrowDown" + ? (currentIndex + 1) % options.length + : (currentIndex - 1 + options.length) % options.length; + event.preventDefault(); + options[nextIndex]?.focus(); + }} + > + {newThreadPickerProjects.map((project, index) => { + const isDefault = index === 0 && newThreadTargetProject !== null; + return ( + + ); + })} + +
        +
        +
        ); } diff --git a/apps/web/src/newThreadPickerBus.ts b/apps/web/src/newThreadPickerBus.ts new file mode 100644 index 00000000000..a1cc7560e20 --- /dev/null +++ b/apps/web/src/newThreadPickerBus.ts @@ -0,0 +1,14 @@ +// Tiny event bus connecting the global chat.new shortcut (handled in the +// _chat route layout) to SidebarV2's project picker dialog. The route layer +// can't render the picker itself — it lives with the sidebar — and the +// sidebar can't own the window keydown handler without racing the layout's. +const NEW_THREAD_PICKER_EVENT = "t3code:open-new-thread-picker"; + +export function openNewThreadPicker(): void { + window.dispatchEvent(new CustomEvent(NEW_THREAD_PICKER_EVENT)); +} + +export function onOpenNewThreadPicker(listener: () => void): () => void { + window.addEventListener(NEW_THREAD_PICKER_EVENT, listener); + return () => window.removeEventListener(NEW_THREAD_PICKER_EVENT, listener); +} diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 9fb1eae721e..b590c59ed9d 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -3,6 +3,9 @@ import { useAtomValue } from "@effect/atom-react"; import { useEffect } from "react"; import { isCommandPaletteOpen } from "../commandPaletteContext"; +import { useClientSettings } from "../hooks/useSettings"; +import { openNewThreadPicker } from "../newThreadPickerBus"; +import { useProjects } from "../state/entities"; import { dispatchPreviewAction } from "../components/preview/previewActionBus"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { @@ -25,6 +28,8 @@ function ChatRouteGlobalShortcuts() { const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread, routeThreadRef } = useHandleNewThread(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const sidebarV2Enabled = useClientSettings((settings) => settings.sidebarV2Enabled); + const projectCount = useProjects().length; const terminalOpen = useTerminalUiStateStore((state) => routeThreadRef ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen @@ -75,6 +80,13 @@ function ChatRouteGlobalShortcuts() { if (command === "chat.new") { event.preventDefault(); event.stopPropagation(); + // Sidebar v2 routes creation through its project picker whenever + // there is a real choice to make; v1 (and single-project setups) + // keep the immediate contextual create. + if (sidebarV2Enabled && projectCount > 1) { + openNewThreadPicker(); + return; + } void startNewThreadFromContext({ activeDraftThread, activeThread: activeThread ?? undefined, @@ -140,8 +152,10 @@ function ChatRouteGlobalShortcuts() { keybindings, defaultProjectRef, previewOpen, + projectCount, routeThreadRef, selectedThreadKeysSize, + sidebarV2Enabled, terminalOpen, ]); From 069f271b1cb4bfabcac8a9572ffba4eb876a747d Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 14 Jul 2026 04:20:29 -0700 Subject: [PATCH 03/14] Address PR #3960 review findings and CI failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI: add settledOverride/settledAt + new client settings to desktop and mobile test fixtures and the mobile threadDetailToShell mapping. Review fixes (cursor bugbot + macroscope): - failed status now requires session.status === "error" instead of any lingering lastError on a stopped/ready session - effectiveSettled: pending approvals or user input always win — blocked work can never collapse into the settled tail, even manually settled - decider unsettle-on-activity guards check settledOverride === "settled" so activity no longer erases a user's explicit keep-active override - repeat thread.settle re-emits with updatedAt = original settledAt, so double-settles no longer bump recency ordering - sidebarAutoSettleAfterDays bounded 1..90 in schema and patch - approval wait sort/label use shell.updatedAt (bumped by the approval activity in the projection pipeline) instead of latestUserMessageAt / turn start, with the approximation documented - v2 rows: Enter/Space keyboard activation; double-click inline rename ported from v1 plus a context-menu Rename entry - new-thread picker: cross-project creation no longer inherits the active thread's branch/worktree; preselection consults the active draft via resolveThreadActionProjectRef; stale project scope resets to All - settle worktree prompt: vcs status checked before offering (and again at click time) — dirty or ahead worktrees are never offered Co-Authored-By: Claude Fable 5 --- .../settings/DesktopClientSettings.test.ts | 2 + .../archive/archivedThreadList.test.ts | 2 + .../src/features/home/homeListItems.test.ts | 2 + .../src/features/home/homeThreadList.test.ts | 2 + apps/mobile/src/lib/repositoryGroups.test.ts | 2 + apps/mobile/src/lib/threadActivity.test.ts | 2 + apps/mobile/src/state/use-thread-selection.ts | 2 + .../src/orchestration/decider.settled.test.ts | 52 ++++- apps/server/src/orchestration/decider.ts | 8 +- apps/web/src/components/Sidebar.logic.test.ts | 21 +- apps/web/src/components/Sidebar.logic.ts | 15 +- apps/web/src/components/SidebarV2.tsx | 193 +++++++++++++++--- apps/web/src/hooks/useThreadActions.ts | 37 +++- apps/web/src/lib/chatThreadActions.test.ts | 21 ++ apps/web/src/lib/chatThreadActions.ts | 8 + apps/web/src/worktreeCleanup.test.ts | 14 +- apps/web/src/worktreeCleanup.ts | 7 + .../src/state/threadSettled.test.ts | 39 ++-- .../client-runtime/src/state/threadSettled.ts | 2 + packages/contracts/src/settings.test.ts | 7 + packages/contracts/src/settings.ts | 16 +- 21 files changed, 386 insertions(+), 68 deletions(-) diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index ea7ec6e1512..47800f3192d 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -20,6 +20,7 @@ const clientSettings: ClientSettings = { diffIgnoreWhitespace: true, favorites: [], providerModelPreferences: {}, + sidebarAutoSettleAfterDays: 3, sidebarProjectGroupingMode: "repository_path", sidebarProjectGroupingOverrides: { "environment-1:/tmp/project-a": "separate", @@ -27,6 +28,7 @@ const clientSettings: ClientSettings = { sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", sidebarThreadPreviewCount: 6, + sidebarV2Enabled: false, timestampFormat: "24-hour", wordWrap: true, }; diff --git a/apps/mobile/src/features/archive/archivedThreadList.test.ts b/apps/mobile/src/features/archive/archivedThreadList.test.ts index 6cd530ab37d..697d13e7c47 100644 --- a/apps/mobile/src/features/archive/archivedThreadList.test.ts +++ b/apps/mobile/src/features/archive/archivedThreadList.test.ts @@ -41,6 +41,8 @@ function makeThread( hasPendingUserInput: false, hasActionableProposedPlan: false, ...input, + settledOverride: input.settledOverride ?? null, + settledAt: input.settledAt ?? null, }; } diff --git a/apps/mobile/src/features/home/homeListItems.test.ts b/apps/mobile/src/features/home/homeListItems.test.ts index 36e98ef129f..c5a9f2c6bbc 100644 --- a/apps/mobile/src/features/home/homeListItems.test.ts +++ b/apps/mobile/src/features/home/homeListItems.test.ts @@ -47,6 +47,8 @@ function makeThread(id: string, projectId: ProjectId): EnvironmentThreadShell { createdAt: "2026-06-01T00:00:00.000Z", updatedAt: "2026-06-01T00:00:00.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, session: null, latestUserMessageAt: null, hasPendingApprovals: false, diff --git a/apps/mobile/src/features/home/homeThreadList.test.ts b/apps/mobile/src/features/home/homeThreadList.test.ts index 8f13000b9e2..46d1173b0dd 100644 --- a/apps/mobile/src/features/home/homeThreadList.test.ts +++ b/apps/mobile/src/features/home/homeThreadList.test.ts @@ -41,6 +41,8 @@ function makeThread( hasPendingUserInput: false, hasActionableProposedPlan: false, ...input, + settledOverride: input.settledOverride ?? null, + settledAt: input.settledAt ?? null, }; } diff --git a/apps/mobile/src/lib/repositoryGroups.test.ts b/apps/mobile/src/lib/repositoryGroups.test.ts index 8cea5df2307..ab4311524ce 100644 --- a/apps/mobile/src/lib/repositoryGroups.test.ts +++ b/apps/mobile/src/lib/repositoryGroups.test.ts @@ -38,6 +38,8 @@ function makeThread( hasPendingUserInput: false, hasActionableProposedPlan: false, ...input, + settledOverride: input.settledOverride ?? null, + settledAt: input.settledAt ?? null, }; } diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 23b47fc625f..5f9f099a46c 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -50,6 +50,8 @@ function makeThread( checkpoints: [], session: null, ...input, + settledOverride: input.settledOverride ?? null, + settledAt: input.settledAt ?? null, }; } diff --git a/apps/mobile/src/state/use-thread-selection.ts b/apps/mobile/src/state/use-thread-selection.ts index d369fbc4377..80bc1916b11 100644 --- a/apps/mobile/src/state/use-thread-selection.ts +++ b/apps/mobile/src/state/use-thread-selection.ts @@ -58,6 +58,8 @@ function threadDetailToShell( createdAt: thread.createdAt, updatedAt: thread.updatedAt, archivedAt: thread.archivedAt, + settledOverride: thread.settledOverride, + settledAt: thread.settledAt, session: thread.session, latestUserMessageAt: latestUserMessageAt(thread), hasPendingApprovals: false, diff --git a/apps/server/src/orchestration/decider.settled.test.ts b/apps/server/src/orchestration/decider.settled.test.ts index dca1100e286..d4ecfba6a40 100644 --- a/apps/server/src/orchestration/decider.settled.test.ts +++ b/apps/server/src/orchestration/decider.settled.test.ts @@ -98,6 +98,7 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => { expect(reEmitEvents[0]?.type).toBe("thread.settled"); if (reEmitEvents[0]?.type === "thread.settled") { expect(reEmitEvents[0].payload.settledAt).toBe(SETTLED_AT); + expect(reEmitEvents[0].payload.updatedAt).toBe(SETTLED_AT); } }), ); @@ -199,10 +200,55 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => { readModel: makeReadModel("active"), }); const sessionEvents = Array.isArray(sessionResult) ? sessionResult : [sessionResult]; - expect(sessionEvents.map((event) => event.type)).toEqual([ - "thread.unsettled", - "thread.session-set", + expect(sessionEvents.map((event) => event.type)).toEqual(["thread.session-set"]); + }), + ); + + it.effect("preserves an explicit active override during activity", () => + Effect.gen(function* () { + const turnResult = yield* decideOrchestrationCommand({ + command: { + type: "thread.turn.start", + commandId: CommandId.make("cmd-active-turn-start"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: MessageId.make("message-active"), + role: "user", + text: "Continue", + attachments: [], + }, + runtimeMode: "full-access", + interactionMode: "default", + createdAt: NOW, + }, + readModel: makeReadModel("active"), + }); + const turnEvents = Array.isArray(turnResult) ? turnResult : [turnResult]; + expect(turnEvents.map((event) => event.type)).toEqual([ + "thread.message-sent", + "thread.turn-start-requested", ]); + + const activityResult = yield* decideOrchestrationCommand({ + command: { + type: "thread.activity.append", + commandId: CommandId.make("cmd-active-approval"), + threadId: ThreadId.make("thread-1"), + activity: { + id: EventId.make("activity-active"), + tone: "approval", + kind: "approval.requested", + summary: "Command approval requested", + payload: null, + turnId: null, + createdAt: NOW, + }, + createdAt: NOW, + }, + readModel: makeReadModel("active"), + }); + const activityEvents = Array.isArray(activityResult) ? activityResult : [activityResult]; + expect(activityEvents.map((event) => event.type)).toEqual(["thread.activity-appended"]); }), ); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index ad84090da79..1f9cc311ce5 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -337,7 +337,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" payload: { threadId: command.threadId, settledAt, - updatedAt: occurredAt, + updatedAt: settledAt, }, }; } @@ -519,7 +519,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" createdAt: command.createdAt, }, }; - if (targetThread.settledOverride === null) { + if (targetThread.settledOverride !== "settled") { return [userMessageEvent, turnStartRequestedEvent]; } const unsettledEvent: Omit = { @@ -681,7 +681,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" // must not fight a user's explicit settle. const isSessionActivity = command.session.status === "starting" || command.session.status === "running"; - if (thread.settledOverride === null || !isSessionActivity) { + if (thread.settledOverride !== "settled" || !isSessionActivity) { return sessionSetEvent; } const unsettledEvent: Omit = { @@ -857,7 +857,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" const wakesSettledThread = command.activity.kind === "approval.requested" || command.activity.kind === "user-input.requested"; - if (thread.settledOverride === null || !wakesSettledThread) { + if (thread.settledOverride !== "settled" || !wakesSettledThread) { return activityAppendedEvent; } const unsettledEvent: Omit = { diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 5348aa67cb3..6a1a2da2b06 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -592,17 +592,23 @@ describe("resolveSidebarV2Status", () => { ).toBe("working"); }); - it("reports failed only for stopped sessions with an error", () => { + it("reports failed only while the session status is error", () => { expect( resolveSidebarV2Status({ hasPendingApprovals: false, - session: { ...session, status: "stopped" as const, lastError: "boom" }, + session: { ...session, status: "error" as const, lastError: "boom" }, }), ).toBe("failed"); expect( resolveSidebarV2Status({ hasPendingApprovals: false, - session: { ...session, status: "stopped" as const }, + session: { ...session, status: "stopped" as const, lastError: "persisted" }, + }), + ).toBe("ready"); + expect( + resolveSidebarV2Status({ + hasPendingApprovals: false, + session: { ...session, status: "ready" as const, lastError: "persisted" }, }), ).toBe("ready"); }); @@ -617,12 +623,13 @@ describe("sortThreadsForSidebarV2", () => { id: string; hasPendingApprovals?: boolean; latestUserMessageAt: string; + updatedAt?: string; }) => ({ id: input.id, hasPendingApprovals: input.hasPendingApprovals ?? false, session: null, createdAt: "2026-03-09T10:00:00.000Z", - updatedAt: input.latestUserMessageAt, + updatedAt: input.updatedAt ?? input.latestUserMessageAt, latestUserMessageAt: input.latestUserMessageAt, }); @@ -632,12 +639,14 @@ describe("sortThreadsForSidebarV2", () => { sortable({ id: "approval-new", hasPendingApprovals: true, - latestUserMessageAt: "2026-03-09T11:00:00.000Z", + latestUserMessageAt: "2026-03-09T08:00:00.000Z", + updatedAt: "2026-03-09T11:00:00.000Z", }), sortable({ id: "approval-old", hasPendingApprovals: true, - latestUserMessageAt: "2026-03-09T09:00:00.000Z", + latestUserMessageAt: "2026-03-09T11:30:00.000Z", + updatedAt: "2026-03-09T09:00:00.000Z", }), sortable({ id: "stale", latestUserMessageAt: "2026-03-09T08:00:00.000Z" }), ]); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 40e9e89fbfb..bce7c48be02 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -373,7 +373,7 @@ export function resolveSidebarV2Status(thread: SidebarV2StatusInput): SidebarV2S if (thread.session?.status === "running" || thread.session?.status === "starting") { return "working"; } - if (thread.session != null && thread.session.lastError !== null) { + if (thread.session?.status === "error") { return "failed"; } return "ready"; @@ -390,13 +390,16 @@ export function sortThreadsForSidebarV2< if (leftApproval !== rightApproval) { return leftApproval ? -1 : 1; } - const byRecency = - getThreadSortTimestamp(right, "updated_at") - getThreadSortTimestamp(left, "updated_at"); - // Approval wait time is "how long you've been the bottleneck", which is - // oldest-activity-first — the inverse of recency. + // Approval activities update the shell's updatedAt in the projection + // pipeline, making it the best available request-time proxy. Oldest waits + // pin first. (The shell does not expose the approval request timestamp.) if (leftApproval && rightApproval) { - return -byRecency || left.id.localeCompare(right.id); + return ( + Date.parse(left.updatedAt) - Date.parse(right.updatedAt) || left.id.localeCompare(right.id) + ); } + const byRecency = + getThreadSortTimestamp(right, "updated_at") - getThreadSortTimestamp(left, "updated_at"); return byRecency || left.id.localeCompare(right.id); }); } diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index c46cb39fc9b..b00abee5b80 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -16,6 +16,7 @@ import { useMemo, useRef, useState, + type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, } from "react"; import { useParams, useRouter } from "@tanstack/react-router"; @@ -47,6 +48,7 @@ import { useOpenAddProjectCommandPalette } from "../commandPaletteContext"; import { onOpenNewThreadPicker } from "../newThreadPickerBus"; import { Dialog, DialogHeader, DialogPopup, DialogTitle } from "./ui/dialog"; import { + resolveThreadActionProjectRef, startNewThreadFromContext, startNewThreadInProjectFromContext, } from "../lib/chatThreadActions"; @@ -55,7 +57,9 @@ import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments" import { useProjects, useThreadShells } from "../state/entities"; import { primaryServerKeybindingsAtom } from "../state/server"; import { vcsEnvironment } from "../state/vcs"; +import { threadEnvironment } from "../state/threads"; import { useEnvironmentQuery } from "../state/query"; +import { useAtomCommand } from "../state/use-atom-command"; import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; import { formatElapsedDurationLabel, formatRelativeTimeLabel } from "../timestampFormat"; import type { SidebarThreadSummary } from "../types"; @@ -128,8 +132,10 @@ function threadTimeLabel(thread: SidebarThreadSummary, status: SidebarV2Status): return formatElapsedDurationLabel(thread.latestTurn.startedAt); } if (status === "approval") { - const waitingSince = thread.latestTurn?.startedAt ?? thread.updatedAt; - return `waiting ${formatElapsedDurationLabel(waitingSince)}`; + // Approval activities bump shell.updatedAt in the projection pipeline. + // The shell has no dedicated request timestamp, so this is the closest + // accurate wait-start signal shared by the label and approval ordering. + return `waiting ${formatElapsedDurationLabel(thread.updatedAt)}`; } const timestamp = thread.latestUserMessageAt ?? thread.updatedAt; return formatRelativeTimeLabel(timestamp); @@ -152,17 +158,31 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { projectCwd: string | null; providerEntryByInstanceId: ReadonlyMap; onThreadClick: (event: ReactMouseEvent, threadRef: ScopedThreadRef) => void; + onThreadActivate: (threadRef: ScopedThreadRef) => void; + onStartRename: (threadRef: ScopedThreadRef, title: string) => void; + onRenameTitleChange: (title: string) => void; + onCommitRename: (threadRef: ScopedThreadRef, title: string, originalTitle: string) => void; + onCancelRename: () => void; + isRenaming: boolean; + renamingTitle: string; onContextMenu: (threadRef: ScopedThreadRef, position: { x: number; y: number }) => void; onSettle: (threadRef: ScopedThreadRef) => void; onUnsettle: (threadRef: ScopedThreadRef) => void; onChangeRequestState: (threadKey: string, state: "open" | "closed" | "merged" | null) => void; }) { const { + isRenaming, onChangeRequestState, + onCancelRename, + onCommitRename, onContextMenu, + onRenameTitleChange, onSettle, + onStartRename, + onThreadActivate, onThreadClick, onUnsettle, + renamingTitle, thread, variant, variantAction, @@ -221,6 +241,50 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { }, [onContextMenu, threadRef], ); + const handleKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + if (event.target !== event.currentTarget) return; + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + onThreadActivate(threadRef); + }, + [onThreadActivate, threadRef], + ); + const handleDoubleClick = useCallback( + (event: ReactMouseEvent) => { + if (isRenaming || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { + return; + } + if ((event.target as HTMLElement).closest("button, a, input")) return; + event.preventDefault(); + onStartRename(threadRef, thread.title); + }, + [isRenaming, onStartRename, thread.title, threadRef], + ); + const renameCommittedRef = useRef(false); + useEffect(() => { + if (isRenaming) renameCommittedRef.current = false; + }, [isRenaming]); + const handleRenameKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + event.stopPropagation(); + if (event.key === "Enter") { + event.preventDefault(); + renameCommittedRef.current = true; + onCommitRename(threadRef, renamingTitle, thread.title); + } else if (event.key === "Escape") { + event.preventDefault(); + renameCommittedRef.current = true; + onCancelRename(); + } + }, + [onCancelRename, onCommitRename, renamingTitle, thread.title, threadRef], + ); + const handleRenameBlur = useCallback(() => { + if (!renameCommittedRef.current) { + onCommitRename(threadRef, renamingTitle, thread.title); + } + }, [onCommitRename, renamingTitle, thread.title, threadRef]); const handleSettleClick = useCallback( (event: ReactMouseEvent) => { event.preventDefault(); @@ -261,6 +325,36 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { /> ); + const title = isRenaming ? ( + onRenameTitleChange(event.target.value)} + onFocus={(event) => event.currentTarget.select()} + onKeyDown={handleRenameKeyDown} + onBlur={handleRenameBlur} + onClick={(event) => event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + className="min-w-0 flex-1 rounded border border-border bg-background px-1 text-[13px] text-foreground outline-none focus:border-ring" + /> + ) : ( + + {thread.title} + + ); + const prBadge = prStatus && pr ? ( @@ -408,7 +423,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { type="button" aria-label="Settle thread" onClick={handleSettleClick} - className="absolute inset-y-0 right-0 inline-flex items-center gap-1 rounded-md border border-border bg-card px-2 text-[11px] text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover/v2-row:opacity-100" + className="absolute inset-y-0 right-0 inline-flex items-center gap-1 border border-border bg-background px-2 text-[11px] text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover/v2-row:opacity-100" > @@ -419,7 +434,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { ); } - const statusDot = STATUS_DOT_BY_STATUS[status]; + const statusEdge = CARD_EDGE_BY_STATUS[status]; const statusWord = STATUS_WORD_BY_STATUS[status]; const diff = latestTurnDiff(thread); const workingTimer = @@ -430,46 +445,44 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { return (
      • -
        - {statusDot ? ( - - ) : ( - favicon - )} + {statusEdge ? ( + + ) : null} +
        + {favicon} {title} {diff ? ( - + +{diff.insertions}{" "} −{diff.deletions} ) : null} - + {props.jumpLabel ?? workingTimer ?? threadTimeLabel(thread, status)} @@ -478,16 +491,21 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { type="button" aria-label="Settle thread" onClick={handleSettleClick} - className="absolute inset-y-0 right-0 inline-flex items-center gap-1 rounded-md border border-border bg-card px-2 text-[11px] text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover/v2-row:opacity-100" + className="absolute inset-y-0 right-0 inline-flex items-center gap-1 border border-border bg-background px-2 text-[11px] text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover/v2-row:opacity-100" > Settle
        -
        +
        {statusWord ? ( - + {statusWord.label} ) : null} @@ -498,7 +516,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { ) : ( <> {thread.branch ? ( - + {thread.branch} ) : null} @@ -1242,10 +1260,10 @@ export default function SidebarV2() { aria-selected={projectScopeKey === null} onClick={() => setProjectScopeKey(null)} className={cn( - "shrink-0 rounded-full border px-2.5 py-1 text-[11px] font-medium transition-colors", + "shrink-0 border px-2.5 py-1 font-mono text-[11px] font-medium uppercase tracking-wide transition-colors", projectScopeKey === null - ? "border-foreground/25 bg-accent text-foreground" - : "border-border bg-transparent text-muted-foreground hover:bg-accent/60 hover:text-foreground", + ? "border-foreground bg-foreground text-background" + : "border-black/15 text-muted-foreground hover:border-black/40 hover:text-foreground dark:border-white/15 dark:hover:border-white/40", )} > All @@ -1262,10 +1280,10 @@ export default function SidebarV2() { aria-selected={isScoped} onClick={() => setProjectScopeKey(isScoped ? null : scopeKey)} className={cn( - "flex shrink-0 items-center gap-1.5 rounded-full border py-1 pl-1.5 pr-2.5 text-[11px] font-medium transition-colors", + "flex shrink-0 items-center gap-1.5 border py-1 pl-1.5 pr-2.5 font-mono text-[11px] font-medium transition-colors", isScoped - ? "border-foreground/25 bg-accent text-foreground" - : "border-border bg-transparent text-muted-foreground hover:bg-accent/60 hover:text-foreground", + ? "border-foreground bg-foreground/10 text-foreground dark:bg-white/[0.1]" + : "border-black/15 text-muted-foreground hover:border-black/40 hover:text-foreground dark:border-white/15 dark:hover:border-white/40", )} > } > @@ -1351,7 +1369,7 @@ export default function SidebarV2() {
        -
        - {statusWord ? ( - - {statusWord.label} - - ) : null} - {status === "failed" && thread.session?.lastError ? ( - - {thread.session.lastError} - - ) : ( - <> - {thread.branch ? ( - - {thread.branch} - - ) : null} - {prBadge} - - )} - - {driverKind ? ( - - } - > - - - {thread.modelSelection.model} - +
        +
        + {title} + {diff ? ( + + +{diff.insertions}{" "} + −{diff.deletions} + ) : null} - {isRemote ? ( - - - } - > - - - - Running on {props.environmentLabel ?? "a remote environment"} - - +
        +
        + {statusWord ? ( + + {statusWord.label} + ) : null} - + {status === "failed" && thread.session?.lastError ? ( + + {thread.session.lastError} + + ) : ( + <> + {thread.branch ? ( + + {thread.branch} + + ) : null} + {prBadge} + + )} + + {driverKind ? ( + + } + > + + + {thread.modelSelection.model} + + ) : null} + {isRemote ? ( + + + } + > + + + + Running on {props.environmentLabel ?? "a remote environment"} + + + ) : null} + +
      • @@ -622,6 +642,11 @@ export default function SidebarV2() { ), [projects], ); + const projectTitleByKey = useMemo( + () => + new Map(projects.map((project) => [`${project.environmentId}:${project.id}`, project.title])), + [projects], + ); // now is quantized to the minute so effectiveSettled memoization doesn't // churn on every render; auto-settle thresholds are day-granular anyway. @@ -1344,6 +1369,9 @@ export default function SidebarV2() { projectCwd={ projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null } + projectTitle={ + projectTitleByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null + } providerEntryByInstanceId={providerEntryByInstanceId} onThreadClick={handleThreadClick} onThreadActivate={navigateToThread} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 5f9aec837ca..b568dc6208f 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -2049,7 +2049,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) >
        {!isComposerCollapsedMobile && (activePendingApproval ? ( -
        +
        ) : pendingUserInputs.length > 0 ? ( -
        +
        ) : showPlanFollowUpPrompt && activeProposedPlan ? ( -
        +
        ) : isComposerCollapsedMobile && pendingUserInputs.length > 0 ? (
        | undefined; preferredScriptId: string | null; @@ -58,6 +60,7 @@ export const ChatHeader = memo(function ChatHeader({ draftId, activeThreadTitle, activeProjectName, + activeProjectCwd, openInCwd, activeProjectScripts, preferredScriptId, @@ -79,6 +82,27 @@ export const ChatHeader = memo(function ChatHeader({ return (
        + {/* The project always leads the header: knowing which project a + thread lives in is priority zero, and the thread title alone + doesn't answer it. Mirrors the sidebar cards' title bar — + favicon + mono uppercase name. */} + {activeProjectName ? ( + + + + + {activeProjectName} + + + + / + + + ) : null} Date: Wed, 15 Jul 2026 14:21:36 -0700 Subject: [PATCH 06/14] Add Thread List v2 beta to the mobile app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the web sidebar v2 design to React Native behind a device-local Settings → Beta toggle (threadListV2Enabled — mobile has no client-settings sync, so the flag lives in mobile preferences): - threadListV2.ts: v2 status model (approval/working/failed/ready), static creation-order sort, and the effectiveSettled partition into an active card block + settled recency tail (shared @t3tools/client-runtime/state/thread-settled, web-default 3-day auto-settle) - thread-list-v2-items.tsx: square title-bar cards — project favicon + mono uppercase name as chrome, status edge strip, two-line titles, branch/status meta — and dimmed slim rows under a SETTLED divider - Settle (cards) / un-settle (slim) as the primary swipe action and in the long-press menu; useThreadListActions grows settle/unsettle via the shared threadEnvironment atoms - HomeScreen renders the flat v2 list (no grouping, rows never reorder on activity) when the toggle is on; v1 list untouched otherwise Co-Authored-By: Claude Fable 5 --- .../src/features/home/HomeRouteScreen.tsx | 5 +- apps/mobile/src/features/home/HomeScreen.tsx | 99 ++++++ .../src/features/home/useThreadListActions.ts | 74 ++++- .../features/settings/SettingsRouteScreen.tsx | 33 ++ .../features/threads/thread-list-v2-items.tsx | 298 ++++++++++++++++++ .../src/features/threads/threadListV2.test.ts | 150 +++++++++ .../src/features/threads/threadListV2.ts | 110 +++++++ .../src/persistence/mobile-preferences.ts | 10 + 8 files changed, 762 insertions(+), 17 deletions(-) create mode 100644 apps/mobile/src/features/threads/thread-list-v2-items.tsx create mode 100644 apps/mobile/src/features/threads/threadListV2.test.ts create mode 100644 apps/mobile/src/features/threads/threadListV2.ts diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 49cf06d85ec..0e08529eebd 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -28,7 +28,8 @@ export function HomeRouteScreen() { const { savedConnectionsById } = useSavedRemoteConnections(); const navigation = useNavigation(); const [searchQuery, setSearchQuery] = useState(""); - const { archiveThread, confirmDeleteThread } = useThreadListActions(); + const { archiveThread, confirmDeleteThread, settleThread, unsettleThread } = + useThreadListActions(); const pendingTasks = usePendingNewTasks(); const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); const environments = useMemo( @@ -111,6 +112,8 @@ export function HomeRouteScreen() { } onArchiveThread={archiveThread} onDeleteThread={confirmDeleteThread} + onSettleThread={settleThread} + onUnsettleThread={unsettleThread} onEnvironmentChange={setSelectedEnvironmentId} onOpenEnvironments={() => navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" }) diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 0807304631d..db26c64ce57 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -32,6 +32,8 @@ import { ThreadListRow, ThreadListShowMoreRow, } from "../threads/thread-list-items"; +import { ThreadListV2Row } from "../threads/thread-list-v2-items"; +import { buildThreadListV2Items, type ThreadListV2Item } from "../threads/threadListV2"; import type { HomeListFilterMenuEnvironment } from "./home-list-filter-menu"; import { buildHomeListLayout, @@ -73,6 +75,8 @@ interface HomeScreenProps { readonly onSelectThread: (thread: EnvironmentThreadShell) => void; readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; + readonly onSettleThread: (thread: EnvironmentThreadShell) => void; + readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void; readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void; readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; readonly onNewThreadInProject: (project: EnvironmentProject) => void; @@ -162,6 +166,9 @@ export function HomeScreen(props: HomeScreenProps) { ReadonlyMap >(() => new Map()); const preferencesResult = useAtomValue(mobilePreferencesAtom); + const threadListV2Enabled = + AsyncResult.isSuccess(preferencesResult) && + preferencesResult.value.threadListV2Enabled === true; const savePreferences = useAtomSet(updateMobilePreferencesAtom); const openSwipeableRef = useRef(null); const listRef = useRef(null); @@ -267,6 +274,63 @@ export function HomeScreen(props: HomeScreenProps) { return map; }, [props.projects]); + const projectByKey = useMemo(() => { + const map = new Map(); + for (const project of props.projects) { + map.set(scopedProjectKey(project.environmentId, project.id), project); + } + return map; + }, [props.projects]); + + // Thread List v2 (beta): one flat list in creation order, no grouping. + // Settled threads collapse into a recency tail below the card block. + const threadListV2Items = useMemo( + () => + threadListV2Enabled + ? buildThreadListV2Items({ + threads: props.threads, + environmentId: props.selectedEnvironmentId, + searchQuery: props.searchQuery, + }) + : [], + [props.searchQuery, props.selectedEnvironmentId, props.threads, threadListV2Enabled], + ); + + const renderV2Item = useCallback( + ({ item }: LegendListRenderItemProps) => ( + + ), + [ + handleSwipeableClose, + handleSwipeableWillOpen, + projectByKey, + props.onArchiveThread, + props.onDeleteThread, + props.onSelectThread, + props.onSettleThread, + props.onUnsettleThread, + ], + ); + const v2KeyExtractor = useCallback( + (item: ThreadListV2Item) => `${item.thread.environmentId}:${item.thread.id}`, + [], + ); + const extraData = useMemo( () => ({ savedConnectionsById: props.savedConnectionsById, projectCwdByKey }), [props.savedConnectionsById, projectCwdByKey], @@ -440,6 +504,41 @@ export function HomeScreen(props: HomeScreenProps) { ) ) : null; + if (threadListV2Enabled) { + return ( + + + + + {connectionStatus} + + ); + } + return ( {/* Sticky headers are deliberately not wired up: LegendList's JS sticky diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index 8effdc942d9..6fa274491fd 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -9,16 +9,22 @@ import { scopedThreadKey } from "../../lib/scopedEntities"; import { threadEnvironment } from "../../state/threads"; import { useAtomCommand } from "../../state/use-atom-command"; -type ThreadListAction = "archive" | "unarchive" | "delete"; +type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle"; + +const ACTION_VERBS: Record = { + archive: "archived", + unarchive: "unarchived", + delete: "deleted", + settle: "settled", + unsettle: "un-settled", +}; function actionFailureMessage(action: ThreadListAction, cause: Cause.Cause): string { const error = Cause.squash(cause); if (error instanceof Error && error.message.trim().length > 0) { return error.message; } - const verb = - action === "archive" ? "archived" : action === "unarchive" ? "unarchived" : "deleted"; - return `The thread could not be ${verb}.`; + return `The thread could not be ${ACTION_VERBS[action]}.`; } function selectionHaptic(): void { @@ -28,6 +34,8 @@ function selectionHaptic(): void { function actionFailureTitle(action: ThreadListAction): string { if (action === "archive") return "Could not archive thread"; if (action === "unarchive") return "Could not unarchive thread"; + if (action === "settle") return "Could not settle thread"; + if (action === "unsettle") return "Could not un-settle thread"; return "Could not delete thread"; } @@ -37,6 +45,8 @@ function useThreadActionExecutor( const archiveMutation = useAtomCommand(threadEnvironment.archive, { reportFailure: false }); const unarchiveMutation = useAtomCommand(threadEnvironment.unarchive, { reportFailure: false }); const deleteMutation = useAtomCommand(threadEnvironment.delete, { reportFailure: false }); + const settleMutation = useAtomCommand(threadEnvironment.settle, { reportFailure: false }); + const unsettleMutation = useAtomCommand(threadEnvironment.unsettle, { reportFailure: false }); const inFlightThreadKeys = useRef(new Set()); const executeAction = useCallback( @@ -49,16 +59,27 @@ function useThreadActionExecutor( inFlightThreadKeys.current.add(key); selectionHaptic(); try { - const mutation = - action === "archive" - ? archiveMutation - : action === "unarchive" - ? unarchiveMutation - : deleteMutation; - const result = await mutation({ - environmentId: thread.environmentId, - input: { threadId: thread.id }, - }); + const result = + action === "settle" + ? await settleMutation({ + environmentId: thread.environmentId, + input: { threadId: thread.id }, + }) + : action === "unsettle" + ? await unsettleMutation({ + environmentId: thread.environmentId, + input: { threadId: thread.id, reason: "user" }, + }) + : await ( + action === "archive" + ? archiveMutation + : action === "unarchive" + ? unarchiveMutation + : deleteMutation + )({ + environmentId: thread.environmentId, + input: { threadId: thread.id }, + }); if (result._tag === "Failure") { Alert.alert(actionFailureTitle(action), actionFailureMessage(action, result.cause)); return; @@ -68,7 +89,14 @@ function useThreadActionExecutor( inFlightThreadKeys.current.delete(key); } }, - [archiveMutation, deleteMutation, onCompleted, unarchiveMutation], + [ + archiveMutation, + deleteMutation, + onCompleted, + settleMutation, + unarchiveMutation, + unsettleMutation, + ], ); return executeAction; @@ -111,6 +139,8 @@ function useConfirmDeleteThread( export function useThreadListActions(): { readonly archiveThread: (thread: EnvironmentThreadShell) => void; readonly confirmDeleteThread: (thread: EnvironmentThreadShell) => void; + readonly settleThread: (thread: EnvironmentThreadShell) => void; + readonly unsettleThread: (thread: EnvironmentThreadShell) => void; } { const executeAction = useThreadActionExecutor(); @@ -120,10 +150,22 @@ export function useThreadListActions(): { }, [executeAction], ); + const settleThread = useCallback( + (thread: EnvironmentThreadShell) => { + void executeAction("settle", thread); + }, + [executeAction], + ); + const unsettleThread = useCallback( + (thread: EnvironmentThreadShell) => { + void executeAction("unsettle", thread); + }, + [executeAction], + ); const confirmDeleteThread = useConfirmDeleteThread(executeAction); - return { archiveThread, confirmDeleteThread }; + return { archiveThread, confirmDeleteThread, settleThread, unsettleThread }; } export function useArchivedThreadListActions( diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 6f87eeeba84..87486ba5112 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -123,6 +123,8 @@ function LocalSettingsRouteScreen() { + + @@ -506,6 +508,8 @@ function ConfiguredSettingsRouteScreen() { + + @@ -514,6 +518,35 @@ function ConfiguredSettingsRouteScreen() { ); } +/** + * Device-local beta toggles. Mobile has no client-settings sync, so this is + * the counterpart of web's Settings → Beta backed by mobile preferences. + */ +function BetaSettingsSection() { + const preferencesResult = useAtomValue(mobilePreferencesAtom); + const savePreferences = useAtomSet(updateMobilePreferencesAtom); + const threadListV2Enabled = AsyncResult.isSuccess(preferencesResult) + ? preferencesResult.value.threadListV2Enabled === true + : false; + + return ( + + + savePreferences({ threadListV2Enabled: value })} + /> + + + One flat thread list in creation order. Active work renders as cards; settled threads + collapse to compact rows. Switch back any time. + + + ); +} + function AppSettingsSection() { const icon = useThemeColor("--color-icon"); diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx new file mode 100644 index 00000000000..68beb4100d1 --- /dev/null +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -0,0 +1,298 @@ +import type { + EnvironmentProject, + EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; +import type { MenuAction } from "@react-native-menu/menu"; +import { memo, useCallback, useMemo, type ComponentProps } from "react"; +import { Platform, Pressable, useWindowDimensions, View } from "react-native"; +import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; + +import { AppText as Text } from "../../components/AppText"; +import { ControlPillMenu } from "../../components/ControlPill"; +import { ProjectFavicon } from "../../components/ProjectFavicon"; +import { cn } from "../../lib/cn"; +import { relativeTime } from "../../lib/time"; +import { useThemeColor } from "../../lib/useThemeColor"; +import { ThreadSwipeable } from "../home/thread-swipe-actions"; +import { resolveThreadListV2Status, type ThreadListV2Status } from "./threadListV2"; + +/** + * Thread List v2 rows, ported from the web sidebar v2 (SidebarV2.tsx): + * brutalist square cards where a thin title bar carries the project + * favicon + name as chrome, a solid edge strip carries status color, and + * settled threads collapse to dimmed slim rows. + */ + +const MONO_FONT = Platform.select({ + ios: "Menlo", + android: "monospace", + default: "monospace", +}); + +const EDGE_CLASS_BY_STATUS: Partial> = { + approval: "bg-amber-500 dark:bg-amber-400", + working: "bg-sky-500 dark:bg-sky-400", + failed: "bg-red-500", +}; + +const STATUS_WORD_BY_STATUS: Partial< + Record +> = { + approval: { label: "NEEDS APPROVAL", className: "text-amber-600 dark:text-amber-400" }, + working: { label: "WORKING", className: "text-sky-600 dark:text-sky-400" }, + failed: { label: "FAILED", className: "text-red-600 dark:text-red-400" }, +}; + +function threadTimeLabel(thread: EnvironmentThreadShell, status: ThreadListV2Status): string { + if (status === "approval") { + return `waiting ${relativeTime(thread.updatedAt)}`; + } + return relativeTime(thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt); +} + +const CARD_MENU_ACTIONS: MenuAction[] = [ + { id: "settle", title: "Settle", image: "checkmark" }, + { id: "archive", title: "Archive", image: "archivebox" }, + { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, +]; + +const SLIM_MENU_ACTIONS: MenuAction[] = [ + { id: "unsettle", title: "Un-settle", image: "arrow.uturn.backward" }, + { id: "archive", title: "Archive", image: "archivebox" }, + { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, +]; + +export const ThreadListV2SettledDivider = memo(function ThreadListV2SettledDivider() { + const separatorColor = useThemeColor("--color-separator"); + return ( + + + Settled + + + + ); +}); + +export const ThreadListV2Row = memo(function ThreadListV2Row(props: { + readonly thread: EnvironmentThreadShell; + readonly variant: "card" | "slim"; + readonly showSettledDivider: boolean; + readonly project: EnvironmentProject | null; + readonly onSelectThread: (thread: EnvironmentThreadShell) => void; + readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; + readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; + readonly onSettleThread: (thread: EnvironmentThreadShell) => void; + readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void; + readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; + readonly onSwipeableClose: (methods: SwipeableMethods) => void; + readonly simultaneousSwipeGesture?: ComponentProps< + typeof ThreadSwipeable + >["simultaneousWithExternalGesture"]; +}) { + const { width: windowWidth } = useWindowDimensions(); + const { + thread, + variant, + onSelectThread, + onArchiveThread, + onDeleteThread, + onSettleThread, + onUnsettleThread, + } = props; + + const separatorColor = useThemeColor("--color-separator"); + const screenColor = useThemeColor("--color-screen"); + + const status = resolveThreadListV2Status(thread); + const statusEdge = EDGE_CLASS_BY_STATUS[status]; + const statusWord = STATUS_WORD_BY_STATUS[status]; + const timeLabel = threadTimeLabel(thread, status); + + const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); + const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]); + const handleSettle = useCallback(() => onSettleThread(thread), [onSettleThread, thread]); + const handleUnsettle = useCallback(() => onUnsettleThread(thread), [onUnsettleThread, thread]); + const handleMenuAction = useCallback( + ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { + if (nativeEvent.event === "settle") handleSettle(); + if (nativeEvent.event === "unsettle") handleUnsettle(); + if (nativeEvent.event === "archive") handleArchive(); + if (nativeEvent.event === "delete") handleDelete(); + }, + [handleArchive, handleDelete, handleSettle, handleUnsettle], + ); + + // Swipe: the v2 primary action is Settle (cards) / Un-settle (slim) — + // the lifecycle transition IS the organizing gesture of this list. + const primaryAction = useMemo( + () => + variant === "card" + ? { + accessibilityLabel: `Settle ${thread.title}`, + icon: "checkmark" as const, + label: "Settle", + onPress: handleSettle, + } + : { + accessibilityLabel: `Un-settle ${thread.title}`, + icon: "arrow.uturn.backward" as const, + label: "Un-settle", + onPress: handleUnsettle, + }, + [handleSettle, handleUnsettle, thread.title, variant], + ); + + const rowContent = (close: () => void) => + variant === "card" ? ( + { + close(); + onSelectThread(thread); + }} + style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })} + > + + + {/* Title bar: project favicon + name as chrome. */} + + {props.project ? ( + + ) : null} + + {props.project?.title ?? ""} + + + {timeLabel} + + + {/* Status edge strip spans the full card over both zones. */} + {statusEdge ? ( + + ) : null} + + + {thread.title} + + + {statusWord ? ( + + {statusWord.label} + + ) : null} + {status === "failed" && thread.session?.lastError ? ( + + {thread.session.lastError} + + ) : thread.branch ? ( + + {thread.branch} + + ) : null} + + + + + + ) : ( + { + close(); + onSelectThread(thread); + }} + style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })} + > + {/* Settled history recedes: dimmed favicon + muted title. */} + + {props.project ? ( + + + + ) : null} + + {thread.title} + + + {relativeTime(thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt)} + + + + ); + + return ( + <> + {props.showSettledDivider ? : null} + + {(close) => ( + + {rowContent(close)} + + )} + + {props.showSettledDivider ? null : variant === "slim" ? ( + + ) : null} + + ); +}); diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts new file mode 100644 index 00000000000..eadffc14747 --- /dev/null +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -0,0 +1,150 @@ +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + buildThreadListV2Items, + resolveThreadListV2Status, + sortThreadsForListV2, +} from "./threadListV2"; + +const environmentId = EnvironmentId.make("environment-1"); + +function makeThread( + input: Partial & Pick, +): EnvironmentThreadShell { + return { + environmentId, + projectId: ProjectId.make("project-1"), + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + archivedAt: null, + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + ...input, + settledOverride: input.settledOverride ?? null, + settledAt: input.settledAt ?? null, + }; +} + +const NOW = "2026-06-02T00:00:00.000Z"; + +describe("resolveThreadListV2Status", () => { + it("prioritizes approval over a running session", () => { + const thread = makeThread({ + id: ThreadId.make("t"), + title: "t", + hasPendingApprovals: true, + session: { + threadId: ThreadId.make("t"), + status: "running", + providerName: "Codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: NOW, + }, + }); + expect(resolveThreadListV2Status(thread)).toBe("approval"); + }); + + it("resolves ready for quiescent threads", () => { + expect(resolveThreadListV2Status(makeThread({ id: ThreadId.make("t"), title: "t" }))).toBe( + "ready", + ); + }); +}); + +describe("sortThreadsForListV2", () => { + it("orders by creation time, newest first, ignoring activity", () => { + const sorted = sortThreadsForListV2([ + { id: "oldest", createdAt: "2026-06-01T08:00:00.000Z" }, + { id: "newest", createdAt: "2026-06-01T12:00:00.000Z" }, + { id: "middle", createdAt: "2026-06-01T10:00:00.000Z" }, + ]); + expect(sorted.map((thread) => thread.id)).toEqual(["newest", "middle", "oldest"]); + }); +}); + +describe("buildThreadListV2Items", () => { + it("partitions settled threads into a slim tail with one divider", () => { + const items = buildThreadListV2Items({ + threads: [ + makeThread({ id: ThreadId.make("active"), title: "Active" }), + makeThread({ + id: ThreadId.make("settled"), + title: "Settled", + settledOverride: "settled", + }), + makeThread({ + id: ThreadId.make("settled-2"), + title: "Settled 2", + settledOverride: "settled", + }), + ], + environmentId: null, + searchQuery: "", + now: NOW, + }); + + expect(items.map((item) => [item.thread.id, item.variant])).toEqual([ + ["active", "card"], + ["settled", "slim"], + ["settled-2", "slim"], + ]); + expect(items.map((item) => item.showSettledDivider)).toEqual([false, true, false]); + expect(items.map((item) => item.isLast)).toEqual([false, false, true]); + }); + + it("keeps cards in creation order while settled sorts by recency", () => { + const items = buildThreadListV2Items({ + threads: [ + makeThread({ + id: ThreadId.make("older-created"), + title: "Older", + createdAt: "2026-06-01T08:00:00.000Z", + updatedAt: NOW, // recent activity must NOT promote it + }), + makeThread({ + id: ThreadId.make("newer-created"), + title: "Newer", + createdAt: "2026-06-01T12:00:00.000Z", + }), + ], + environmentId: null, + searchQuery: "", + now: NOW, + }); + + expect(items.map((item) => item.thread.id)).toEqual(["newer-created", "older-created"]); + }); + + it("excludes archived threads and filters by search query", () => { + const items = buildThreadListV2Items({ + threads: [ + makeThread({ id: ThreadId.make("match"), title: "Fix login bug" }), + makeThread({ id: ThreadId.make("miss"), title: "Greeting" }), + makeThread({ + id: ThreadId.make("archived"), + title: "Fix login again", + archivedAt: NOW, + }), + ], + environmentId: null, + searchQuery: "login", + now: NOW, + }); + + expect(items.map((item) => item.thread.id)).toEqual(["match"]); + }); +}); diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts new file mode 100644 index 00000000000..ea3bbf41b42 --- /dev/null +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -0,0 +1,110 @@ +import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import type { EnvironmentId } from "@t3tools/contracts"; + +/** + * Thread List v2 model, ported from the web sidebar v2 + * (apps/web/src/components/Sidebar.logic.ts + SidebarV2.tsx). + * + * Four visual states, three colors: color is reserved for "act now" + * (approval), "in motion" (working), and "broken" (failed). Ready is the + * unlabeled resting state. + */ +export type ThreadListV2Status = "approval" | "working" | "failed" | "ready"; + +export function resolveThreadListV2Status( + thread: Pick, +): ThreadListV2Status { + if (thread.hasPendingApprovals) { + return "approval"; + } + if (thread.session?.status === "running" || thread.session?.status === "starting") { + return "working"; + } + if (thread.session?.status === "error") { + return "failed"; + } + return "ready"; +} + +/** + * v2 sort: static creation order, newest thread on top. Activity NEVER + * reorders the list — a row holds its position from open until settled, so + * the screen only moves at lifecycle transitions. Mirrors web's + * sortThreadsForSidebarV2. + */ +export function sortThreadsForListV2( + threads: readonly T[], +): T[] { + return [...threads].toSorted( + (left, right) => + Date.parse(right.createdAt) - Date.parse(left.createdAt) || left.id.localeCompare(right.id), + ); +} + +export interface ThreadListV2Item { + readonly thread: EnvironmentThreadShell; + readonly variant: "card" | "slim"; + /** First settled row after the card block draws the SETTLED divider. */ + readonly showSettledDivider: boolean; + readonly isLast: boolean; +} + +/** + * Partitions visible threads into the active card block (creation order) and + * the settled recency tail, matching the web v2 list. `autoSettleAfterDays` + * mirrors the web default of 3 — mobile has no client-settings sync yet, so + * the default is fixed here rather than user-configurable. + */ +export function buildThreadListV2Items(input: { + readonly threads: ReadonlyArray; + readonly environmentId: EnvironmentId | null; + readonly searchQuery: string; + readonly autoSettleAfterDays?: number; + /** Injectable for tests; defaults to now. */ + readonly now?: string; +}): ThreadListV2Item[] { + const now = input.now ?? new Date().toISOString(); + const autoSettleAfterDays = input.autoSettleAfterDays ?? 3; + const query = input.searchQuery.trim().toLocaleLowerCase(); + + const active: EnvironmentThreadShell[] = []; + const settled: EnvironmentThreadShell[] = []; + for (const thread of input.threads) { + if (thread.archivedAt !== null) continue; + if (input.environmentId !== null && thread.environmentId !== input.environmentId) continue; + if (query.length > 0 && !thread.title.toLocaleLowerCase().includes(query)) continue; + // PR state feeds the web partition per-row; mobile shells don't watch PRs + // from the list, so the partition here is override/session/inactivity. + if (effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState: null })) { + settled.push(thread); + } else { + active.push(thread); + } + } + + const orderedActive = sortThreadsForListV2(active); + const orderedSettled = settled.toSorted( + (left, right) => + Date.parse(right.latestUserMessageAt ?? right.updatedAt) - + Date.parse(left.latestUserMessageAt ?? left.updatedAt), + ); + + const items: ThreadListV2Item[] = []; + for (const thread of orderedActive) { + items.push({ thread, variant: "card", showSettledDivider: false, isLast: false }); + } + for (const [index, thread] of orderedSettled.entries()) { + items.push({ + thread, + variant: "slim", + showSettledDivider: index === 0, + isLast: false, + }); + } + const last = items.at(-1); + if (last) { + items[items.length - 1] = { ...last, isLast: true }; + } + return items; +} diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 6971570b0e6..1138ad2b655 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -22,6 +22,12 @@ export interface Preferences { readonly codeWordBreak?: boolean; readonly connectOnboardingOptOutAccounts?: ReadonlyArray; readonly collapsedProjectGroups?: readonly string[]; + /** + * Device-local mirror of the web beta's `sidebarV2Enabled`. Mobile has no + * client-settings sync, so the flat v2 thread list is opted into per + * device. + */ + readonly threadListV2Enabled?: boolean; } export class MobilePreferencesLoadError extends Schema.TaggedErrorClass()( @@ -71,6 +77,7 @@ function sanitizePreferences(parsed: Preferences): Preferences { codeWordBreak?: boolean; connectOnboardingOptOutAccounts?: ReadonlyArray; collapsedProjectGroups?: readonly string[]; + threadListV2Enabled?: boolean; } = {}; if (typeof parsed.liveActivitiesEnabled === "boolean") { @@ -97,6 +104,9 @@ function sanitizePreferences(parsed: Preferences): Preferences { (key): key is string => typeof key === "string", ); } + if (typeof parsed.threadListV2Enabled === "boolean") { + preferences.threadListV2Enabled = parsed.threadListV2Enabled; + } return preferences; } From f74e7ba83ce4de319eccc3406749ccf60602da51 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 15 Jul 2026 16:15:08 -0700 Subject: [PATCH 07/14] Fix mobile and sorting --- apps/mobile/src/features/threads/threadListV2.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index ea3bbf41b42..da1b19969f5 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -36,7 +36,9 @@ export function resolveThreadListV2Status( export function sortThreadsForListV2( threads: readonly T[], ): T[] { - return [...threads].toSorted( + // .sort() on a copy, not .toSorted(): Hermes doesn't ship the ES2023 + // change-by-copy array methods. + return [...threads].sort( (left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt) || left.id.localeCompare(right.id), ); @@ -84,7 +86,7 @@ export function buildThreadListV2Items(input: { } const orderedActive = sortThreadsForListV2(active); - const orderedSettled = settled.toSorted( + const orderedSettled = [...settled].sort( (left, right) => Date.parse(right.latestUserMessageAt ?? right.updatedAt) - Date.parse(left.latestUserMessageAt ?? left.updatedAt), From a2f8028f1360833fd6ef339b63b3c7ca503777e7 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 15 Jul 2026 20:05:13 -0700 Subject: [PATCH 08/14] Make v2 thread cards read as touchable, not as notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first mobile pass copied the web card anatomy (outlined translucent box, thin header strip) — which is exactly iOS push-banner anatomy, so rows read as information rather than buttons. Rebuild the card around native list-app touch signals: - Solid raised bg-card surface, no border, 18pt continuous corners - Leading 38pt favicon tile as the touch anchor; mono project name becomes an eyebrow line above the title - Trailing chevron affordance; timestamp joins the eyebrow row - Spring press feedback (scale to 97%) via reanimated - Status edge strip survives as a full-height bar on the left edge Settled rows, swipe actions, and long-press menus unchanged. Co-Authored-By: Claude Fable 5 --- .../features/threads/thread-list-v2-items.tsx | 218 +++++++++++------- 1 file changed, 135 insertions(+), 83 deletions(-) diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 68beb4100d1..a7496630d2b 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -3,10 +3,12 @@ import type { EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; import type { MenuAction } from "@react-native-menu/menu"; -import { memo, useCallback, useMemo, type ComponentProps } from "react"; +import { memo, useCallback, useMemo, type ComponentProps, type ReactNode } from "react"; import { Platform, Pressable, useWindowDimensions, View } from "react-native"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; +import Animated, { useAnimatedStyle, useSharedValue, withSpring } from "react-native-reanimated"; +import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text } from "../../components/AppText"; import { ControlPillMenu } from "../../components/ControlPill"; import { ProjectFavicon } from "../../components/ProjectFavicon"; @@ -17,10 +19,12 @@ import { ThreadSwipeable } from "../home/thread-swipe-actions"; import { resolveThreadListV2Status, type ThreadListV2Status } from "./threadListV2"; /** - * Thread List v2 rows, ported from the web sidebar v2 (SidebarV2.tsx): - * brutalist square cards where a thin title bar carries the project - * favicon + name as chrome, a solid edge strip carries status color, and - * settled threads collapse to dimmed slim rows. + * Thread List v2 rows. The design language is the web sidebar v2 (status + * edge strip, mono project labels, settled tail), but the card anatomy is + * native iOS list-app, not the web's window chrome: a solid raised surface + * (no outline), a leading favicon tile as the touch anchor, a trailing + * chevron, and spring press feedback. Bordered translucent boxes with a + * header row read as notifications — information, not buttons. */ const MONO_FONT = Platform.select({ @@ -62,12 +66,47 @@ const SLIM_MENU_ACTIONS: MenuAction[] = [ { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, ]; +const PRESS_SPRING = { damping: 30, stiffness: 400 } as const; + +/** + * Pressable that springs down to 97% while touched — the "this is a real + * object under your finger" signal every native list app ships. + */ +function PressableScaleCard(props: { + readonly accessibilityHint: string; + readonly accessibilityLabel: string; + readonly onPress: () => void; + readonly children: ReactNode; +}) { + const scale = useSharedValue(1); + const animatedStyle = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] })); + + return ( + + { + scale.value = withSpring(0.97, PRESS_SPRING); + }} + onPressOut={() => { + scale.value = withSpring(1, PRESS_SPRING); + }} + > + {props.children} + + + ); +} + export const ThreadListV2SettledDivider = memo(function ThreadListV2SettledDivider() { const separatorColor = useThemeColor("--color-separator"); return ( - + Settled @@ -104,7 +143,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { onUnsettleThread, } = props; - const separatorColor = useThemeColor("--color-separator"); + const iconSubtleColor = useThemeColor("--color-icon-subtle"); const screenColor = useThemeColor("--color-screen"); const status = resolveThreadListV2Status(thread); @@ -148,86 +187,102 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const rowContent = (close: () => void) => variant === "card" ? ( - { - close(); - onSelectThread(thread); - }} - style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })} - > - - - {/* Title bar: project favicon + name as chrome. */} - + + { + close(); + onSelectThread(thread); + }} + > + {/* Solid raised card: bg-card with no border reads as an object, + not a notification banner. */} + + {statusEdge ? : null} + {/* Favicon tile: the leading touch anchor, app-icon style. */} + {props.project ? ( ) : null} - - {props.project?.title ?? ""} - - - {timeLabel} - - {/* Status edge strip spans the full card over both zones. */} - {statusEdge ? ( - - ) : null} - - + + + + {props.project?.title ?? ""} + + + {timeLabel} + + + {thread.title} - - {statusWord ? ( - - {statusWord.label} - - ) : null} - {status === "failed" && thread.session?.lastError ? ( - - {thread.session.lastError} - - ) : thread.branch ? ( - - {thread.branch} - - ) : null} - + {statusWord || thread.branch || (status === "failed" && thread.session?.lastError) ? ( + + {statusWord ? ( + + {statusWord.label} + + ) : null} + {status === "failed" && thread.session?.lastError ? ( + + {thread.session.lastError} + + ) : thread.branch ? ( + + {thread.branch} + + ) : null} + + ) : null} + + {/* Trailing chevron: the universal "this navigates" affordance. */} + + - - + + ) : ( ({ opacity: pressed ? 0.7 : 1 })} > {/* Settled history recedes: dimmed favicon + muted title. */} - + {props.project ? ( ) : null} - + {thread.title} {relativeTime(thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt)} @@ -290,9 +345,6 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { )} - {props.showSettledDivider ? null : variant === "slim" ? ( - - ) : null} ); }); From da08b588faffe7d0a7c9509ce40feffb35178e63 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 15 Jul 2026 21:31:50 -0700 Subject: [PATCH 09/14] Client-only sidebar v2: back settle with archive, revert all server changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes the new sidebar/thread-list testable against existing unupgraded servers by removing every backend dependency of the settled feature: - apps/server, contracts orchestration schemas, and client-runtime command/reducer plumbing revert to main baseline (no thread.settle commands, no settled events/fields, no migration 033) - effectiveSettled() is now archive-based: archivedAt is the explicit settled signal; PR merged/closed and inactivity auto-settle keep working client-side. Blocked work (pending approval/input, live session) still never settles - settle/unsettle actions ride threadEnvironment.archive/unarchive on web and mobile — UI and UX unchanged - Archived threads stay visible as the settled tail: the live shell stream drops archived threads (thread.archived → thread-removed), so both clients merge them back from the archived shell snapshot and refresh it after settle/unsettle/archive actions - Client settings (sidebarV2Enabled, sidebarAutoSettleAfterDays) are kept — they persist client-side only Trade-offs vs the event-sourced model (still on t3code/sidebar-v2-mobile): no activity-driven auto-un-settle for archived threads, and no "keep active" override distinct from unarchiving. Co-Authored-By: Claude Fable 5 --- .../archive/archivedThreadList.test.ts | 2 - apps/mobile/src/features/home/HomeScreen.tsx | 41 ++- .../src/features/home/homeListItems.test.ts | 2 - .../src/features/home/homeThreadList.test.ts | 2 - .../src/features/home/useThreadListActions.ts | 44 +-- .../src/features/threads/threadListV2.test.ts | 15 +- .../src/features/threads/threadListV2.ts | 5 +- apps/mobile/src/lib/repositoryGroups.test.ts | 2 - apps/mobile/src/lib/threadActivity.test.ts | 2 - apps/mobile/src/state/use-thread-selection.ts | 2 - .../Layers/OrchestrationEngine.test.ts | 2 - .../Layers/ProjectionPipeline.ts | 34 -- .../Layers/ProjectionSnapshotQuery.test.ts | 4 - .../Layers/ProjectionSnapshotQuery.ts | 20 -- apps/server/src/orchestration/Schemas.ts | 4 - .../orchestration/commandInvariants.test.ts | 4 - .../src/orchestration/decider.settled.test.ts | 322 ------------------ apps/server/src/orchestration/decider.ts | 128 +------ .../orchestration/projector.settled.test.ts | 88 ----- .../src/orchestration/projector.test.ts | 2 - apps/server/src/orchestration/projector.ts | 28 -- .../Layers/ProjectionRepositories.test.ts | 2 - .../persistence/Layers/ProjectionThreads.ts | 10 - apps/server/src/persistence/Migrations.ts | 2 - .../033_ProjectionThreadsSettled.ts | 23 -- .../persistence/Services/ProjectionThreads.ts | 2 - .../Layers/ProviderSessionReaper.test.ts | 2 - .../src/relay/AgentAwarenessRelay.test.ts | 6 - apps/server/src/server.test.ts | 6 - .../web/src/components/ChatView.logic.test.ts | 2 - apps/web/src/components/ChatView.logic.ts | 2 - .../components/CommandPalette.logic.test.ts | 2 - apps/web/src/components/Sidebar.logic.test.ts | 2 - apps/web/src/components/SidebarV2.tsx | 45 ++- apps/web/src/hooks/useThreadActions.ts | 10 +- apps/web/src/lib/threadSort.test.ts | 2 - apps/web/src/worktreeCleanup.test.ts | 2 - .../src/operations/commands.test.ts | 39 +-- .../client-runtime/src/operations/commands.ts | 22 -- .../client-runtime/src/state/entities.test.ts | 2 - .../src/state/shellReducer.test.ts | 2 - .../src/state/threadCommands.ts | 18 - .../client-runtime/src/state/threadDetail.ts | 2 - .../src/state/threadReducer.test.ts | 58 ---- .../client-runtime/src/state/threadReducer.ts | 24 -- .../src/state/threadSettled.test.ts | 38 +-- .../client-runtime/src/state/threadSettled.ts | 14 +- .../src/state/threads-sync.test.ts | 2 - packages/contracts/src/orchestration.test.ts | 105 ------ packages/contracts/src/orchestration.ts | 49 --- 50 files changed, 146 insertions(+), 1102 deletions(-) delete mode 100644 apps/server/src/orchestration/decider.settled.test.ts delete mode 100644 apps/server/src/orchestration/projector.settled.test.ts delete mode 100644 apps/server/src/persistence/Migrations/033_ProjectionThreadsSettled.ts diff --git a/apps/mobile/src/features/archive/archivedThreadList.test.ts b/apps/mobile/src/features/archive/archivedThreadList.test.ts index 697d13e7c47..6cd530ab37d 100644 --- a/apps/mobile/src/features/archive/archivedThreadList.test.ts +++ b/apps/mobile/src/features/archive/archivedThreadList.test.ts @@ -41,8 +41,6 @@ function makeThread( hasPendingUserInput: false, hasActionableProposedPlan: false, ...input, - settledOverride: input.settledOverride ?? null, - settledAt: input.settledAt ?? null, }; } diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index db26c64ce57..c06d42f1ce1 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -23,7 +23,8 @@ import { useThemeColor } from "../../lib/useThemeColor"; import { EmptyState } from "../../components/EmptyState"; import type { WorkspaceState } from "../../state/workspaceModel"; import type { SavedRemoteConnection } from "../../lib/connection"; -import { scopedProjectKey } from "../../lib/scopedEntities"; +import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; +import { useArchivedThreadSnapshots } from "../archive/useArchivedThreadSnapshots"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { @@ -284,17 +285,37 @@ export function HomeScreen(props: HomeScreenProps) { // Thread List v2 (beta): one flat list in creation order, no grouping. // Settled threads collapse into a recency tail below the card block. - const threadListV2Items = useMemo( + // Settle = archive in the client-only model, and the live shell stream + // drops archived threads — merge them back from the archived snapshot so + // they render as the settled tail. Live shells win on overlap. + const archivedEnvironmentIds = useMemo( () => - threadListV2Enabled - ? buildThreadListV2Items({ - threads: props.threads, - environmentId: props.selectedEnvironmentId, - searchQuery: props.searchQuery, - }) - : [], - [props.searchQuery, props.selectedEnvironmentId, props.threads, threadListV2Enabled], + threadListV2Enabled ? props.environments.map((environment) => environment.environmentId) : [], + [props.environments, threadListV2Enabled], ); + const { snapshots: archivedSnapshots } = useArchivedThreadSnapshots(archivedEnvironmentIds); + const threadListV2Items = useMemo(() => { + if (!threadListV2Enabled) return []; + const liveKeys = new Set( + props.threads.map((thread) => scopedThreadKey(thread.environmentId, thread.id)), + ); + const archived = archivedSnapshots.flatMap(({ environmentId, snapshot }) => + snapshot.threads + .map((thread) => ({ ...thread, environmentId })) + .filter((thread) => !liveKeys.has(scopedThreadKey(thread.environmentId, thread.id))), + ); + return buildThreadListV2Items({ + threads: [...props.threads, ...archived], + environmentId: props.selectedEnvironmentId, + searchQuery: props.searchQuery, + }); + }, [ + archivedSnapshots, + props.searchQuery, + props.selectedEnvironmentId, + props.threads, + threadListV2Enabled, + ]); const renderV2Item = useCallback( ({ item }: LegendListRenderItemProps) => ( diff --git a/apps/mobile/src/features/home/homeListItems.test.ts b/apps/mobile/src/features/home/homeListItems.test.ts index c5a9f2c6bbc..36e98ef129f 100644 --- a/apps/mobile/src/features/home/homeListItems.test.ts +++ b/apps/mobile/src/features/home/homeListItems.test.ts @@ -47,8 +47,6 @@ function makeThread(id: string, projectId: ProjectId): EnvironmentThreadShell { createdAt: "2026-06-01T00:00:00.000Z", updatedAt: "2026-06-01T00:00:00.000Z", archivedAt: null, - settledOverride: null, - settledAt: null, session: null, latestUserMessageAt: null, hasPendingApprovals: false, diff --git a/apps/mobile/src/features/home/homeThreadList.test.ts b/apps/mobile/src/features/home/homeThreadList.test.ts index 46d1173b0dd..8f13000b9e2 100644 --- a/apps/mobile/src/features/home/homeThreadList.test.ts +++ b/apps/mobile/src/features/home/homeThreadList.test.ts @@ -41,8 +41,6 @@ function makeThread( hasPendingUserInput: false, hasActionableProposedPlan: false, ...input, - settledOverride: input.settledOverride ?? null, - settledAt: input.settledAt ?? null, }; } diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index 6fa274491fd..29f54d4f4ab 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -6,6 +6,7 @@ import { Alert } from "react-native"; import { showConfirmDialog } from "../../components/ConfirmDialogHost"; import { scopedThreadKey } from "../../lib/scopedEntities"; +import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThreadSnapshots"; import { threadEnvironment } from "../../state/threads"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -45,8 +46,10 @@ function useThreadActionExecutor( const archiveMutation = useAtomCommand(threadEnvironment.archive, { reportFailure: false }); const unarchiveMutation = useAtomCommand(threadEnvironment.unarchive, { reportFailure: false }); const deleteMutation = useAtomCommand(threadEnvironment.delete, { reportFailure: false }); - const settleMutation = useAtomCommand(threadEnvironment.settle, { reportFailure: false }); - const unsettleMutation = useAtomCommand(threadEnvironment.unsettle, { reportFailure: false }); + // Client-only settled model: settle/unsettle ride the archive lifecycle so + // no server upgrade is required. See client-runtime threadSettled.ts. + const settleMutation = archiveMutation; + const unsettleMutation = unarchiveMutation; const inFlightThreadKeys = useRef(new Set()); const executeAction = useCallback( @@ -59,31 +62,30 @@ function useThreadActionExecutor( inFlightThreadKeys.current.add(key); selectionHaptic(); try { - const result = + const mutation = action === "settle" - ? await settleMutation({ - environmentId: thread.environmentId, - input: { threadId: thread.id }, - }) + ? settleMutation : action === "unsettle" - ? await unsettleMutation({ - environmentId: thread.environmentId, - input: { threadId: thread.id, reason: "user" }, - }) - : await ( - action === "archive" - ? archiveMutation - : action === "unarchive" - ? unarchiveMutation - : deleteMutation - )({ - environmentId: thread.environmentId, - input: { threadId: thread.id }, - }); + ? unsettleMutation + : action === "archive" + ? archiveMutation + : action === "unarchive" + ? unarchiveMutation + : deleteMutation; + const result = await mutation({ + environmentId: thread.environmentId, + input: { threadId: thread.id }, + }); if (result._tag === "Failure") { Alert.alert(actionFailureTitle(action), actionFailureMessage(action, result.cause)); return; } + // Settle rides archive, and archiving drops the thread from the live + // shell stream — refresh the archived snapshot so the v2 list can + // show it as a settled row without waiting for a manual reload. + if (action === "settle" || action === "unsettle" || action === "archive") { + refreshArchivedThreadsForEnvironment(thread.environmentId); + } onCompleted?.(action, thread); } finally { inFlightThreadKeys.current.delete(key); diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index eadffc14747..ebac77760ab 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -31,8 +31,6 @@ function makeThread( hasPendingUserInput: false, hasActionableProposedPlan: false, ...input, - settledOverride: input.settledOverride ?? null, - settledAt: input.settledAt ?? null, }; } @@ -77,19 +75,19 @@ describe("sortThreadsForListV2", () => { }); describe("buildThreadListV2Items", () => { - it("partitions settled threads into a slim tail with one divider", () => { + it("partitions archived (settled) threads into a slim tail with one divider", () => { const items = buildThreadListV2Items({ threads: [ makeThread({ id: ThreadId.make("active"), title: "Active" }), makeThread({ id: ThreadId.make("settled"), title: "Settled", - settledOverride: "settled", + archivedAt: NOW, }), makeThread({ id: ThreadId.make("settled-2"), title: "Settled 2", - settledOverride: "settled", + archivedAt: NOW, }), ], environmentId: null, @@ -129,7 +127,7 @@ describe("buildThreadListV2Items", () => { expect(items.map((item) => item.thread.id)).toEqual(["newer-created", "older-created"]); }); - it("excludes archived threads and filters by search query", () => { + it("keeps archived threads in the tail and filters by search query", () => { const items = buildThreadListV2Items({ threads: [ makeThread({ id: ThreadId.make("match"), title: "Fix login bug" }), @@ -145,6 +143,9 @@ describe("buildThreadListV2Items", () => { now: NOW, }); - expect(items.map((item) => item.thread.id)).toEqual(["match"]); + expect(items.map((item) => [item.thread.id, item.variant])).toEqual([ + ["match", "card"], + ["archived", "slim"], + ]); }); }); diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index da1b19969f5..28cd917dfa0 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -73,11 +73,12 @@ export function buildThreadListV2Items(input: { const active: EnvironmentThreadShell[] = []; const settled: EnvironmentThreadShell[] = []; for (const thread of input.threads) { - if (thread.archivedAt !== null) continue; + // Archived threads stay in the list: in the client-only settled model, + // archive IS settle, so they render as the settled tail. if (input.environmentId !== null && thread.environmentId !== input.environmentId) continue; if (query.length > 0 && !thread.title.toLocaleLowerCase().includes(query)) continue; // PR state feeds the web partition per-row; mobile shells don't watch PRs - // from the list, so the partition here is override/session/inactivity. + // from the list, so the partition here is archive/session/inactivity. if (effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState: null })) { settled.push(thread); } else { diff --git a/apps/mobile/src/lib/repositoryGroups.test.ts b/apps/mobile/src/lib/repositoryGroups.test.ts index ab4311524ce..8cea5df2307 100644 --- a/apps/mobile/src/lib/repositoryGroups.test.ts +++ b/apps/mobile/src/lib/repositoryGroups.test.ts @@ -38,8 +38,6 @@ function makeThread( hasPendingUserInput: false, hasActionableProposedPlan: false, ...input, - settledOverride: input.settledOverride ?? null, - settledAt: input.settledAt ?? null, }; } diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 5f9f099a46c..23b47fc625f 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -50,8 +50,6 @@ function makeThread( checkpoints: [], session: null, ...input, - settledOverride: input.settledOverride ?? null, - settledAt: input.settledAt ?? null, }; } diff --git a/apps/mobile/src/state/use-thread-selection.ts b/apps/mobile/src/state/use-thread-selection.ts index 80bc1916b11..d369fbc4377 100644 --- a/apps/mobile/src/state/use-thread-selection.ts +++ b/apps/mobile/src/state/use-thread-selection.ts @@ -58,8 +58,6 @@ function threadDetailToShell( createdAt: thread.createdAt, updatedAt: thread.updatedAt, archivedAt: thread.archivedAt, - settledOverride: thread.settledOverride, - settledAt: thread.settledAt, session: thread.session, latestUserMessageAt: latestUserMessageAt(thread), hasPendingApprovals: false, diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 7d44d7c9969..405103450e8 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -146,8 +146,6 @@ describe("OrchestrationEngine", () => { createdAt: "2026-03-03T00:00:02.000Z", updatedAt: "2026-03-03T00:00:03.000Z", archivedAt: null, - settledOverride: null, - settledAt: null, deletedAt: null, messages: [], proposedPlans: [], diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 3ceae0ea43b..f12df850941 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -607,8 +607,6 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti createdAt: event.payload.createdAt, updatedAt: event.payload.updatedAt, archivedAt: null, - settledOverride: null, - settledAt: null, latestUserMessageAt: null, pendingApprovalCount: 0, pendingUserInputCount: 0, @@ -647,38 +645,6 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti return; } - case "thread.settled": { - const existingRow = yield* projectionThreadRepository.getById({ - threadId: event.payload.threadId, - }); - if (Option.isNone(existingRow)) { - return; - } - yield* projectionThreadRepository.upsert({ - ...existingRow.value, - settledOverride: "settled", - settledAt: event.payload.settledAt, - updatedAt: event.payload.updatedAt, - }); - return; - } - - case "thread.unsettled": { - const existingRow = yield* projectionThreadRepository.getById({ - threadId: event.payload.threadId, - }); - if (Option.isNone(existingRow)) { - return; - } - yield* projectionThreadRepository.upsert({ - ...existingRow.value, - settledOverride: event.payload.reason === "user" ? "active" : null, - settledAt: null, - updatedAt: event.payload.updatedAt, - }); - return; - } - case "thread.meta-updated": { const existingRow = yield* projectionThreadRepository.getById({ threadId: event.payload.threadId, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 220665c557c..9a136b06872 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -308,8 +308,6 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { createdAt: "2026-02-24T00:00:02.000Z", updatedAt: "2026-02-24T00:00:03.000Z", archivedAt: null, - settledOverride: null, - settledAt: null, deletedAt: null, messages: [ { @@ -420,8 +418,6 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { createdAt: "2026-02-24T00:00:02.000Z", updatedAt: "2026-02-24T00:00:03.000Z", archivedAt: null, - settledOverride: null, - settledAt: null, session: { threadId: ThreadId.make("thread-1"), status: "running", diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 155e9ab0013..32210436e67 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -334,8 +334,6 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", - settled_override AS "settledOverride", - settled_at AS "settledAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -364,8 +362,6 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", - settled_override AS "settledOverride", - settled_at AS "settledAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -396,8 +392,6 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", - settled_override AS "settledOverride", - settled_at AS "settledAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -760,8 +754,6 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", - settled_override AS "settledOverride", - settled_at AS "settledAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -1194,8 +1186,6 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { createdAt: row.createdAt, updatedAt: row.updatedAt, archivedAt: row.archivedAt, - settledOverride: row.settledOverride, - settledAt: row.settledAt, deletedAt: row.deletedAt, messages: messagesByThread.get(row.threadId) ?? [], proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], @@ -1394,8 +1384,6 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { createdAt: row.createdAt, updatedAt: row.updatedAt, archivedAt: row.archivedAt, - settledOverride: row.settledOverride, - settledAt: row.settledAt, deletedAt: row.deletedAt, messages: [], proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], @@ -1525,8 +1513,6 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { createdAt: row.createdAt, updatedAt: row.updatedAt, archivedAt: row.archivedAt, - settledOverride: row.settledOverride, - settledAt: row.settledAt, session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, hasPendingApprovals: row.pendingApprovalCount > 0, @@ -1661,8 +1647,6 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { createdAt: row.createdAt, updatedAt: row.updatedAt, archivedAt: row.archivedAt, - settledOverride: row.settledOverride, - settledAt: row.settledAt, session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, hasPendingApprovals: row.pendingApprovalCount > 0, @@ -1903,8 +1887,6 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { createdAt: threadRow.value.createdAt, updatedAt: threadRow.value.updatedAt, archivedAt: threadRow.value.archivedAt, - settledOverride: threadRow.value.settledOverride, - settledAt: threadRow.value.settledAt, session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null, latestUserMessageAt: threadRow.value.latestUserMessageAt, hasPendingApprovals: threadRow.value.pendingApprovalCount > 0, @@ -1999,8 +1981,6 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { createdAt: threadRow.value.createdAt, updatedAt: threadRow.value.updatedAt, archivedAt: threadRow.value.archivedAt, - settledOverride: threadRow.value.settledOverride, - settledAt: threadRow.value.settledAt, deletedAt: null, messages: messageRows.map((row) => { const message = { diff --git a/apps/server/src/orchestration/Schemas.ts b/apps/server/src/orchestration/Schemas.ts index 0d0d7bdd5e4..f7ebf693440 100644 --- a/apps/server/src/orchestration/Schemas.ts +++ b/apps/server/src/orchestration/Schemas.ts @@ -4,13 +4,11 @@ import { ProjectDeletedPayload as ContractsProjectDeletedPayloadSchema, ThreadCreatedPayload as ContractsThreadCreatedPayloadSchema, ThreadArchivedPayload as ContractsThreadArchivedPayloadSchema, - ThreadSettledPayload as ContractsThreadSettledPayloadSchema, ThreadMetaUpdatedPayload as ContractsThreadMetaUpdatedPayloadSchema, ThreadRuntimeModeSetPayload as ContractsThreadRuntimeModeSetPayloadSchema, ThreadInteractionModeSetPayload as ContractsThreadInteractionModeSetPayloadSchema, ThreadDeletedPayload as ContractsThreadDeletedPayloadSchema, ThreadUnarchivedPayload as ContractsThreadUnarchivedPayloadSchema, - ThreadUnsettledPayload as ContractsThreadUnsettledPayloadSchema, ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema, ThreadProposedPlanUpsertedPayload as ContractsThreadProposedPlanUpsertedPayloadSchema, ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema, @@ -31,13 +29,11 @@ export const ProjectDeletedPayload = ContractsProjectDeletedPayloadSchema; export const ThreadCreatedPayload = ContractsThreadCreatedPayloadSchema; export const ThreadArchivedPayload = ContractsThreadArchivedPayloadSchema; -export const ThreadSettledPayload = ContractsThreadSettledPayloadSchema; export const ThreadMetaUpdatedPayload = ContractsThreadMetaUpdatedPayloadSchema; export const ThreadRuntimeModeSetPayload = ContractsThreadRuntimeModeSetPayloadSchema; export const ThreadInteractionModeSetPayload = ContractsThreadInteractionModeSetPayloadSchema; export const ThreadDeletedPayload = ContractsThreadDeletedPayloadSchema; export const ThreadUnarchivedPayload = ContractsThreadUnarchivedPayloadSchema; -export const ThreadUnsettledPayload = ContractsThreadUnsettledPayloadSchema; export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema; export const ThreadProposedPlanUpsertedPayload = ContractsThreadProposedPlanUpsertedPayloadSchema; diff --git a/apps/server/src/orchestration/commandInvariants.test.ts b/apps/server/src/orchestration/commandInvariants.test.ts index 9531cd5c3af..9c6c8bd2a18 100644 --- a/apps/server/src/orchestration/commandInvariants.test.ts +++ b/apps/server/src/orchestration/commandInvariants.test.ts @@ -68,8 +68,6 @@ const readModel: OrchestrationReadModel = { createdAt: now, updatedAt: now, archivedAt: null, - settledOverride: null, - settledAt: null, latestTurn: null, messages: [], session: null, @@ -93,8 +91,6 @@ const readModel: OrchestrationReadModel = { createdAt: now, updatedAt: now, archivedAt: null, - settledOverride: null, - settledAt: null, latestTurn: null, messages: [], session: null, diff --git a/apps/server/src/orchestration/decider.settled.test.ts b/apps/server/src/orchestration/decider.settled.test.ts deleted file mode 100644 index d4ecfba6a40..00000000000 --- a/apps/server/src/orchestration/decider.settled.test.ts +++ /dev/null @@ -1,322 +0,0 @@ -import { - CommandId, - EventId, - MessageId, - ProjectId, - ProviderInstanceId, - ThreadId, - type OrchestrationReadModel, - type OrchestrationSession, - type OrchestrationThread, -} from "@t3tools/contracts"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { expect, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; - -import { decideOrchestrationCommand } from "./decider.ts"; - -const NOW = "2026-01-01T00:00:00.000Z"; -const SETTLED_AT = "2025-12-30T00:00:00.000Z"; - -function makeReadModel( - settledOverride: OrchestrationThread["settledOverride"], - archivedAt: string | null = null, -): OrchestrationReadModel { - return { - snapshotSequence: 0, - projects: [], - threads: [ - { - id: ThreadId.make("thread-1"), - projectId: ProjectId.make("project-1"), - title: "Thread", - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - latestTurn: null, - createdAt: NOW, - updatedAt: NOW, - archivedAt, - settledOverride, - settledAt: settledOverride === "settled" ? SETTLED_AT : null, - deletedAt: null, - messages: [], - proposedPlans: [], - activities: [], - checkpoints: [], - session: null, - }, - ], - updatedAt: NOW, - }; -} - -function makeSession(status: OrchestrationSession["status"]): OrchestrationSession { - return { - threadId: ThreadId.make("thread-1"), - status, - providerName: "Codex", - runtimeMode: "full-access", - activeTurnId: null, - lastError: null, - updatedAt: NOW, - }; -} - -it.layer(NodeServices.layer)("settled thread decider", (it) => { - it.effect("settles active threads and re-emits idempotently for settled ones", () => - Effect.gen(function* () { - const event = yield* decideOrchestrationCommand({ - command: { - type: "thread.settle", - commandId: CommandId.make("cmd-settle"), - threadId: ThreadId.make("thread-1"), - }, - readModel: makeReadModel(null), - }); - const events = Array.isArray(event) ? event : [event]; - expect(events).toHaveLength(1); - expect(events[0]?.type).toBe("thread.settled"); - if (events[0]?.type === "thread.settled") { - expect(events[0].payload.settledAt).toBe(events[0].payload.updatedAt); - } - - // Already settled: the engine rejects zero-event commands, so idempotency - // is by re-emission — preserving the original settledAt. - const reEmit = yield* decideOrchestrationCommand({ - command: { - type: "thread.settle", - commandId: CommandId.make("cmd-settle-again"), - threadId: ThreadId.make("thread-1"), - }, - readModel: makeReadModel("settled"), - }); - const reEmitEvents = Array.isArray(reEmit) ? reEmit : [reEmit]; - expect(reEmitEvents).toHaveLength(1); - expect(reEmitEvents[0]?.type).toBe("thread.settled"); - if (reEmitEvents[0]?.type === "thread.settled") { - expect(reEmitEvents[0].payload.settledAt).toBe(SETTLED_AT); - expect(reEmitEvents[0].payload.updatedAt).toBe(SETTLED_AT); - } - }), - ); - - it.effect("rejects settling and unsettling archived threads", () => - Effect.gen(function* () { - const settleError = yield* decideOrchestrationCommand({ - command: { - type: "thread.settle", - commandId: CommandId.make("cmd-settle-archived"), - threadId: ThreadId.make("thread-1"), - }, - readModel: makeReadModel(null, NOW), - }).pipe(Effect.flip); - expect(settleError._tag).toBe("OrchestrationCommandInvariantError"); - - const unsettleError = yield* decideOrchestrationCommand({ - command: { - type: "thread.unsettle", - commandId: CommandId.make("cmd-unsettle-archived"), - threadId: ThreadId.make("thread-1"), - reason: "user", - }, - readModel: makeReadModel("settled", NOW), - }).pipe(Effect.flip); - expect(unsettleError._tag).toBe("OrchestrationCommandInvariantError"); - }), - ); - - it.effect("maps unsettle reasons to overrides and re-emits idempotently", () => - Effect.gen(function* () { - const userEvent = yield* decideOrchestrationCommand({ - command: { - type: "thread.unsettle", - commandId: CommandId.make("cmd-unsettle-user"), - threadId: ThreadId.make("thread-1"), - reason: "user", - }, - readModel: makeReadModel("settled"), - }); - const userEvents = Array.isArray(userEvent) ? userEvent : [userEvent]; - expect(userEvents).toHaveLength(1); - expect(userEvents[0]?.type).toBe("thread.unsettled"); - if (userEvents[0]?.type === "thread.unsettled") { - expect(userEvents[0].payload.reason).toBe("user"); - } - - // Re-dispatching against the already-reached state re-emits rather than - // producing zero events (the engine rejects empty commands). - const userAgain = yield* decideOrchestrationCommand({ - command: { - type: "thread.unsettle", - commandId: CommandId.make("cmd-unsettle-user-again"), - threadId: ThreadId.make("thread-1"), - reason: "user", - }, - readModel: makeReadModel("active"), - }); - const userAgainEvents = Array.isArray(userAgain) ? userAgain : [userAgain]; - expect(userAgainEvents).toHaveLength(1); - expect(userAgainEvents[0]?.type).toBe("thread.unsettled"); - }), - ); - - it.effect("prepends activity unsets for turn starts and live session updates", () => - Effect.gen(function* () { - const turnResult = yield* decideOrchestrationCommand({ - command: { - type: "thread.turn.start", - commandId: CommandId.make("cmd-turn-start"), - threadId: ThreadId.make("thread-1"), - message: { - messageId: MessageId.make("message-1"), - role: "user", - text: "Continue", - attachments: [], - }, - runtimeMode: "full-access", - interactionMode: "default", - createdAt: NOW, - }, - readModel: makeReadModel("settled"), - }); - const turnEvents = Array.isArray(turnResult) ? turnResult : [turnResult]; - expect(turnEvents.map((event) => event.type)).toEqual([ - "thread.unsettled", - "thread.message-sent", - "thread.turn-start-requested", - ]); - - const sessionResult = yield* decideOrchestrationCommand({ - command: { - type: "thread.session.set", - commandId: CommandId.make("cmd-session-set"), - threadId: ThreadId.make("thread-1"), - session: makeSession("running"), - createdAt: NOW, - }, - readModel: makeReadModel("active"), - }); - const sessionEvents = Array.isArray(sessionResult) ? sessionResult : [sessionResult]; - expect(sessionEvents.map((event) => event.type)).toEqual(["thread.session-set"]); - }), - ); - - it.effect("preserves an explicit active override during activity", () => - Effect.gen(function* () { - const turnResult = yield* decideOrchestrationCommand({ - command: { - type: "thread.turn.start", - commandId: CommandId.make("cmd-active-turn-start"), - threadId: ThreadId.make("thread-1"), - message: { - messageId: MessageId.make("message-active"), - role: "user", - text: "Continue", - attachments: [], - }, - runtimeMode: "full-access", - interactionMode: "default", - createdAt: NOW, - }, - readModel: makeReadModel("active"), - }); - const turnEvents = Array.isArray(turnResult) ? turnResult : [turnResult]; - expect(turnEvents.map((event) => event.type)).toEqual([ - "thread.message-sent", - "thread.turn-start-requested", - ]); - - const activityResult = yield* decideOrchestrationCommand({ - command: { - type: "thread.activity.append", - commandId: CommandId.make("cmd-active-approval"), - threadId: ThreadId.make("thread-1"), - activity: { - id: EventId.make("activity-active"), - tone: "approval", - kind: "approval.requested", - summary: "Command approval requested", - payload: null, - turnId: null, - createdAt: NOW, - }, - createdAt: NOW, - }, - readModel: makeReadModel("active"), - }); - const activityEvents = Array.isArray(activityResult) ? activityResult : [activityResult]; - expect(activityEvents.map((event) => event.type)).toEqual(["thread.activity-appended"]); - }), - ); - - it.effect("does not unsettle for session stop/error status writes", () => - Effect.gen(function* () { - for (const status of ["stopped", "error", "ready", "idle"] as const) { - const result = yield* decideOrchestrationCommand({ - command: { - type: "thread.session.set", - commandId: CommandId.make(`cmd-session-${status}`), - threadId: ThreadId.make("thread-1"), - session: makeSession(status), - createdAt: NOW, - }, - readModel: makeReadModel("settled"), - }); - const events = Array.isArray(result) ? result : [result]; - expect(events.map((event) => event.type)).toEqual(["thread.session-set"]); - } - }), - ); - - it.effect("unsettles for approval and user-input activities but not others", () => - Effect.gen(function* () { - const approvalResult = yield* decideOrchestrationCommand({ - command: { - type: "thread.activity.append", - commandId: CommandId.make("cmd-activity-approval"), - threadId: ThreadId.make("thread-1"), - activity: { - id: EventId.make("activity-1"), - tone: "approval", - kind: "approval.requested", - summary: "Command approval requested", - payload: null, - turnId: null, - createdAt: NOW, - }, - createdAt: NOW, - }, - readModel: makeReadModel("settled"), - }); - const approvalEvents = Array.isArray(approvalResult) ? approvalResult : [approvalResult]; - expect(approvalEvents.map((event) => event.type)).toEqual([ - "thread.unsettled", - "thread.activity-appended", - ]); - - const routineResult = yield* decideOrchestrationCommand({ - command: { - type: "thread.activity.append", - commandId: CommandId.make("cmd-activity-routine"), - threadId: ThreadId.make("thread-1"), - activity: { - id: EventId.make("activity-2"), - tone: "info", - kind: "tool.completed", - summary: "Tool completed", - payload: null, - turnId: null, - createdAt: NOW, - }, - createdAt: NOW, - }, - readModel: makeReadModel("settled"), - }); - const routineEvents = Array.isArray(routineResult) ? routineResult : [routineResult]; - expect(routineEvents.map((event) => event.type)).toEqual(["thread.activity-appended"]); - }), - ); -}); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 1f9cc311ce5..9c95b42269d 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -312,61 +312,6 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } - case "thread.settle": { - const thread = yield* requireThreadNotArchived({ - readModel, - command, - threadId: command.threadId, - }); - const occurredAt = yield* nowIso; - // Settling an already-settled thread re-emits with the original - // settledAt: the engine rejects zero-event commands, and bulk-settle / - // double-click must stay silent no-ops rather than surface errors. - const settledAt = - thread.settledOverride === "settled" && thread.settledAt !== null - ? thread.settledAt - : occurredAt; - return { - ...(yield* withEventBase({ - aggregateKind: "thread", - aggregateId: command.threadId, - occurredAt, - commandId: command.commandId, - })), - type: "thread.settled", - payload: { - threadId: command.threadId, - settledAt, - updatedAt: settledAt, - }, - }; - } - - case "thread.unsettle": { - yield* requireThreadNotArchived({ - readModel, - command, - threadId: command.threadId, - }); - // Idempotent by re-emission (see thread.settle): reducing the event a - // second time lands on the same override state. - const occurredAt = yield* nowIso; - return { - ...(yield* withEventBase({ - aggregateKind: "thread", - aggregateId: command.threadId, - occurredAt, - commandId: command.commandId, - })), - type: "thread.unsettled", - payload: { - threadId: command.threadId, - reason: command.reason, - updatedAt: occurredAt, - }, - }; - } - case "thread.meta.update": { const thread = yield* requireThread({ readModel, @@ -519,24 +464,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" createdAt: command.createdAt, }, }; - if (targetThread.settledOverride !== "settled") { - return [userMessageEvent, turnStartRequestedEvent]; - } - const unsettledEvent: Omit = { - ...(yield* withEventBase({ - aggregateKind: "thread", - aggregateId: command.threadId, - occurredAt: command.createdAt, - commandId: command.commandId, - })), - type: "thread.unsettled", - payload: { - threadId: command.threadId, - reason: "activity", - updatedAt: command.createdAt, - }, - }; - return [unsettledEvent, userMessageEvent, turnStartRequestedEvent]; + return [userMessageEvent, turnStartRequestedEvent]; } case "thread.turn.interrupt": { @@ -657,12 +585,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" } case "thread.session.set": { - const thread = yield* requireThread({ + yield* requireThread({ readModel, command, threadId: command.threadId, }); - const sessionSetEvent: Omit = { + return { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, @@ -676,29 +604,6 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" session: command.session, }, }; - // Only a session coming alive is activity worth waking a settled thread - // for — status writes like ready/stopped/error arrive after the fact and - // must not fight a user's explicit settle. - const isSessionActivity = - command.session.status === "starting" || command.session.status === "running"; - if (thread.settledOverride !== "settled" || !isSessionActivity) { - return sessionSetEvent; - } - const unsettledEvent: Omit = { - ...(yield* withEventBase({ - aggregateKind: "thread", - aggregateId: command.threadId, - occurredAt: command.createdAt, - commandId: command.commandId, - })), - type: "thread.unsettled", - payload: { - threadId: command.threadId, - reason: "activity", - updatedAt: command.createdAt, - }, - }; - return [unsettledEvent, sessionSetEvent]; } case "thread.message.assistant.delta": { @@ -825,7 +730,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" } case "thread.activity.append": { - const thread = yield* requireThread({ + yield* requireThread({ readModel, command, threadId: command.threadId, @@ -838,7 +743,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ? ((command.activity.payload as { requestId: string }) .requestId as OrchestrationEvent["metadata"]["requestId"]) : undefined; - const activityAppendedEvent: Omit = { + return { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, @@ -852,29 +757,6 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" activity: command.activity, }, }; - // An approval or user-input request is blocked-on-you work — it must - // never stay hidden inside a settled slim row. - const wakesSettledThread = - command.activity.kind === "approval.requested" || - command.activity.kind === "user-input.requested"; - if (thread.settledOverride !== "settled" || !wakesSettledThread) { - return activityAppendedEvent; - } - const unsettledEvent: Omit = { - ...(yield* withEventBase({ - aggregateKind: "thread", - aggregateId: command.threadId, - occurredAt: command.createdAt, - commandId: command.commandId, - })), - type: "thread.unsettled", - payload: { - threadId: command.threadId, - reason: "activity", - updatedAt: command.createdAt, - }, - }; - return [unsettledEvent, activityAppendedEvent]; } default: { diff --git a/apps/server/src/orchestration/projector.settled.test.ts b/apps/server/src/orchestration/projector.settled.test.ts deleted file mode 100644 index 2070c44418a..00000000000 --- a/apps/server/src/orchestration/projector.settled.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { - CommandId, - EventId, - ProjectId, - ThreadId, - type OrchestrationEvent, -} from "@t3tools/contracts"; -import { expect, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; - -import { createEmptyReadModel, projectEvent } from "./projector.ts"; - -function makeEvent(input: { - readonly sequence: number; - readonly type: OrchestrationEvent["type"]; - readonly payload: unknown; -}): OrchestrationEvent { - return { - sequence: input.sequence, - eventId: EventId.make(`event-${input.sequence}`), - type: input.type, - aggregateKind: "thread", - aggregateId: ThreadId.make("thread-1"), - occurredAt: "2026-01-01T00:00:00.000Z", - commandId: CommandId.make(`command-${input.sequence}`), - causationEventId: null, - correlationId: null, - metadata: {}, - payload: input.payload as never, - } as OrchestrationEvent; -} - -it.effect("projects settled lifecycle events", () => - Effect.gen(function* () { - const now = "2026-01-01T00:00:00.000Z"; - const created = yield* projectEvent( - createEmptyReadModel(now), - makeEvent({ - sequence: 1, - type: "thread.created", - payload: { - threadId: ThreadId.make("thread-1"), - projectId: ProjectId.make("project-1"), - title: "Thread", - modelSelection: { provider: "codex", model: "gpt-5.4" }, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - createdAt: now, - updatedAt: now, - }, - }), - ); - const settled = yield* projectEvent( - created, - makeEvent({ - sequence: 2, - type: "thread.settled", - payload: { threadId: ThreadId.make("thread-1"), settledAt: now, updatedAt: now }, - }), - ); - expect(settled.threads[0]?.settledOverride).toBe("settled"); - expect(settled.threads[0]?.settledAt).toBe(now); - - const userUnsettled = yield* projectEvent( - settled, - makeEvent({ - sequence: 3, - type: "thread.unsettled", - payload: { threadId: ThreadId.make("thread-1"), reason: "user", updatedAt: now }, - }), - ); - expect(userUnsettled.threads[0]?.settledOverride).toBe("active"); - expect(userUnsettled.threads[0]?.settledAt).toBeNull(); - - const activityUnsettled = yield* projectEvent( - userUnsettled, - makeEvent({ - sequence: 4, - type: "thread.unsettled", - payload: { threadId: ThreadId.make("thread-1"), reason: "activity", updatedAt: now }, - }), - ); - expect(activityUnsettled.threads[0]?.settledOverride).toBeNull(); - expect(activityUnsettled.threads[0]?.settledAt).toBeNull(); - }), -); diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index e9a55c0796b..fadd5078026 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -89,8 +89,6 @@ describe("orchestration projector", () => { createdAt: now, updatedAt: now, archivedAt: null, - settledOverride: null, - settledAt: null, deletedAt: null, messages: [], proposedPlans: [], diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index c8f47dcebbf..fc6ab8f6fcf 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -22,9 +22,7 @@ import { ThreadMetaUpdatedPayload, ThreadProposedPlanUpsertedPayload, ThreadRuntimeModeSetPayload, - ThreadSettledPayload, ThreadUnarchivedPayload, - ThreadUnsettledPayload, ThreadRevertedPayload, ThreadSessionSetPayload, ThreadTurnDiffCompletedPayload, @@ -288,8 +286,6 @@ export function projectEvent( createdAt: payload.createdAt, updatedAt: payload.updatedAt, archivedAt: null, - settledOverride: null, - settledAt: null, deletedAt: null, messages: [], activities: [], @@ -341,30 +337,6 @@ export function projectEvent( })), ); - case "thread.settled": - return decodeForEvent(ThreadSettledPayload, event.payload, event.type, "payload").pipe( - Effect.map((payload) => ({ - ...nextBase, - threads: updateThread(nextBase.threads, payload.threadId, { - settledOverride: "settled", - settledAt: payload.settledAt, - updatedAt: payload.updatedAt, - }), - })), - ); - - case "thread.unsettled": - return decodeForEvent(ThreadUnsettledPayload, event.payload, event.type, "payload").pipe( - Effect.map((payload) => ({ - ...nextBase, - threads: updateThread(nextBase.threads, payload.threadId, { - settledOverride: payload.reason === "user" ? "active" : null, - settledAt: null, - updatedAt: payload.updatedAt, - }), - })), - ); - case "thread.meta-updated": return decodeForEvent(ThreadMetaUpdatedPayload, event.payload, event.type, "payload").pipe( Effect.map((payload) => ({ diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index 0403e55d9ee..a2069e62a14 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -91,8 +91,6 @@ projectionRepositoriesLayer("Projection repositories", (it) => { createdAt: "2026-03-24T00:00:00.000Z", updatedAt: "2026-03-24T00:00:00.000Z", archivedAt: null, - settledOverride: null, - settledAt: null, latestUserMessageAt: null, pendingApprovalCount: 0, pendingUserInputCount: 0, diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index e0c85e91494..1baeb375c15 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -43,8 +43,6 @@ const makeProjectionThreadRepository = Effect.gen(function* () { created_at, updated_at, archived_at, - settled_override, - settled_at, latest_user_message_at, pending_approval_count, pending_user_input_count, @@ -64,8 +62,6 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.createdAt}, ${row.updatedAt}, ${row.archivedAt}, - ${row.settledOverride}, - ${row.settledAt}, ${row.latestUserMessageAt}, ${row.pendingApprovalCount}, ${row.pendingUserInputCount}, @@ -85,8 +81,6 @@ const makeProjectionThreadRepository = Effect.gen(function* () { created_at = excluded.created_at, updated_at = excluded.updated_at, archived_at = excluded.archived_at, - settled_override = excluded.settled_override, - settled_at = excluded.settled_at, latest_user_message_at = excluded.latest_user_message_at, pending_approval_count = excluded.pending_approval_count, pending_user_input_count = excluded.pending_user_input_count, @@ -113,8 +107,6 @@ const makeProjectionThreadRepository = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", - settled_override AS "settledOverride", - settled_at AS "settledAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -143,8 +135,6 @@ const makeProjectionThreadRepository = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", - settled_override AS "settledOverride", - settled_at AS "settledAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index f0183fd26c3..ba1131ee259 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -45,7 +45,6 @@ import Migration0029 from "./Migrations/029_ProjectionThreadDetailOrderingIndexe import Migration0030 from "./Migrations/030_ProjectionThreadShellArchiveIndexes.ts"; import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts"; import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts"; -import Migration0033 from "./Migrations/033_ProjectionThreadsSettled.ts"; /** * Migration loader with all migrations defined inline. @@ -90,7 +89,6 @@ export const migrationEntries = [ [30, "ProjectionThreadShellArchiveIndexes", Migration0030], [31, "AuthAuthorizationScopes", Migration0031], [32, "AuthPairingProofKeyThumbprint", Migration0032], - [33, "ProjectionThreadsSettled", Migration0033], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/033_ProjectionThreadsSettled.ts b/apps/server/src/persistence/Migrations/033_ProjectionThreadsSettled.ts deleted file mode 100644 index e93407defe2..00000000000 --- a/apps/server/src/persistence/Migrations/033_ProjectionThreadsSettled.ts +++ /dev/null @@ -1,23 +0,0 @@ -import * as Effect from "effect/Effect"; -import * as SqlClient from "effect/unstable/sql/SqlClient"; - -export default Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - const columns = yield* sql<{ readonly name: string }>` - PRAGMA table_info(projection_threads) - `; - - if (!columns.some((column) => column.name === "settled_override")) { - yield* sql` - ALTER TABLE projection_threads - ADD COLUMN settled_override TEXT - `; - } - - if (!columns.some((column) => column.name === "settled_at")) { - yield* sql` - ALTER TABLE projection_threads - ADD COLUMN settled_at TEXT - `; - } -}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index 8057d434950..44fdc147a4a 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -36,8 +36,6 @@ export const ProjectionThread = Schema.Struct({ createdAt: IsoDateTime, updatedAt: IsoDateTime, archivedAt: Schema.NullOr(IsoDateTime), - settledOverride: Schema.NullOr(Schema.Literals(["settled", "active"])), - settledAt: Schema.NullOr(IsoDateTime), latestUserMessageAt: Schema.NullOr(IsoDateTime), pendingApprovalCount: NonNegativeInt, pendingUserInputCount: NonNegativeInt, diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index 3843c8acbcd..166a88ef17b 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -100,8 +100,6 @@ function makeReadModel( createdAt: now, updatedAt: now, archivedAt: null, - settledOverride: null, - settledAt: null, latestUserMessageAt: null, hasPendingApprovals: false, hasPendingUserInput: false, diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 3ec6f4d0435..9ca17dbc2dd 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -305,8 +305,6 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { createdAt: now, updatedAt: now, archivedAt: null, - settledOverride: null, - settledAt: null, session: null, latestUserMessageAt: null, hasPendingApprovals: false, @@ -453,8 +451,6 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { createdAt: now, updatedAt: now, archivedAt: null, - settledOverride: null, - settledAt: null, session: { threadId, status: "running", @@ -610,8 +606,6 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { createdAt: now, updatedAt: now, archivedAt: null, - settledOverride: null, - settledAt: null, session: { threadId, status: "running", diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 29bd0d440ed..347c2920792 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -163,8 +163,6 @@ const makeDefaultOrchestrationReadModel = () => { createdAt: now, updatedAt: now, archivedAt: null, - settledOverride: null, - settledAt: null, latestTurn: null, messages: [], session: null, @@ -194,8 +192,6 @@ const makeDefaultOrchestrationThreadShell = ( createdAt: now, updatedAt: now, archivedAt: null, - settledOverride: null, - settledAt: null, session: null, latestUserMessageAt: null, hasPendingApprovals: false, @@ -5499,8 +5495,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { createdAt: now, updatedAt: now, archivedAt: null, - settledOverride: null, - settledAt: null, latestTurn: null, messages: [], session: null, diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index bf0664bc4aa..0a0103df183 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -42,8 +42,6 @@ function makeThread(overrides: Partial = {}): Thread { createdAt: now, updatedAt: now, archivedAt: null, - settledOverride: null, - settledAt: null, deletedAt: null, latestTurn: null, branch: null, diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index b629e9f9c15..705793ec77e 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -45,8 +45,6 @@ export function buildLocalDraftThread( createdAt: draftThread.createdAt, updatedAt: draftThread.createdAt, archivedAt: null, - settledOverride: null, - settledAt: null, deletedAt: null, latestTurn: null, branch: draftThread.branch, diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 6609017f91c..651fe34e4b4 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -24,8 +24,6 @@ function makeThread(overrides: Partial = {}): Thread { proposedPlans: [], createdAt: "2026-03-01T00:00:00.000Z", archivedAt: null, - settledOverride: null, - settledAt: null, deletedAt: null, updatedAt: "2026-03-01T00:00:00.000Z", latestTurn: null, diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 9ed6f8e748b..4b3cb63a74e 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -910,8 +910,6 @@ function makeThread(overrides: Partial = {}): Thread { proposedPlans: [], createdAt: "2026-03-09T10:00:00.000Z", archivedAt: null, - settledOverride: null, - settledAt: null, deletedAt: null, updatedAt: "2026-03-09T10:00:00.000Z", latestTurn: null, diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index b5bab645768..a97912ebf07 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -43,6 +43,10 @@ import { readLocalApi } from "../localApi"; import { useUiStateStore } from "../uiStateStore"; import { useThreadSelectionStore } from "../threadSelectionStore"; import { useThreadActions } from "../hooks/useThreadActions"; +import { + refreshArchivedThreadsForEnvironment, + useArchivedThreadSnapshots, +} from "../lib/archivedThreadsState"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { useOpenAddProjectCommandPalette } from "../commandPaletteContext"; import { onOpenNewThreadPicker } from "../newThreadPickerBus"; @@ -702,14 +706,37 @@ export default function SidebarV2() { } }, [projectScopeKey, projects]); + // Archived threads ARE the settled tail in the client-only settled model, + // but the live shell stream drops a thread the moment it is archived + // (thread.archived → thread-removed). Merge them back in from the archived + // snapshot query; live shells win on the (brief) overlap. + const archivedEnvironmentIds = useMemo( + () => environments.map((environment) => environment.environmentId), + [environments], + ); + const { snapshots: archivedSnapshots } = useArchivedThreadSnapshots(archivedEnvironmentIds); + const allThreads = useMemo(() => { + const liveKeys = new Set( + threads.map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), + ); + const archived = archivedSnapshots.flatMap(({ environmentId, snapshot }) => + snapshot.threads + .map((thread) => ({ ...thread, environmentId })) + .filter( + (thread) => + !liveKeys.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), + ), + ); + return [...threads, ...archived]; + }, [archivedSnapshots, threads]); + const { activeThreads, settledThreads } = useMemo(() => { const now = `${nowMinute}:00.000Z`; - const visible = threads.filter( + const visible = allThreads.filter( (thread) => - thread.archivedAt === null && - (scopedProject === null || - (thread.environmentId === scopedProject.environmentId && - thread.projectId === scopedProject.id)), + scopedProject === null || + (thread.environmentId === scopedProject.environmentId && + thread.projectId === scopedProject.id), ); const active: EnvironmentThreadShell[] = []; const settled: EnvironmentThreadShell[] = []; @@ -730,7 +757,7 @@ export default function SidebarV2() { Date.parse(left.latestUserMessageAt ?? left.updatedAt), ), }; - }, [autoSettleAfterDays, changeRequestStateByKey, nowMinute, scopedProject, threads]); + }, [allThreads, autoSettleAfterDays, changeRequestStateByKey, nowMinute, scopedProject]); const orderedThreads = useMemo( () => [...activeThreads, ...settledThreads], @@ -877,7 +904,11 @@ export default function SidebarV2() { description: error instanceof Error ? error.message : "An error occurred.", }), ); + return; } + // Settle = archive: the shell stream drops the thread, so pull the + // archived snapshot immediately to keep it visible as a settled row. + refreshArchivedThreadsForEnvironment(threadRef.environmentId); })(); }, [settleThread], @@ -895,7 +926,9 @@ export default function SidebarV2() { description: error instanceof Error ? error.message : "An error occurred.", }), ); + return; } + refreshArchivedThreadsForEnvironment(threadRef.environmentId); })(); }, [unsettleThread], diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 859a82f9da2..47c7767bf66 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -54,10 +54,12 @@ export function useThreadActions() { const deleteThreadMutation = useAtomCommand(threadEnvironment.delete, { reportFailure: false, }); - const settleThreadMutation = useAtomCommand(threadEnvironment.settle, { + // Client-only settled model: settle/unsettle ride the pre-existing archive + // lifecycle so no server upgrade is required. See threadSettled.ts. + const settleThreadMutation = useAtomCommand(threadEnvironment.archive, { reportFailure: false, }); - const unsettleThreadMutation = useAtomCommand(threadEnvironment.unsettle, { + const unsettleThreadMutation = useAtomCommand(threadEnvironment.unarchive, { reportFailure: false, }); const stopThreadSession = useAtomCommand(threadEnvironment.stopSession); @@ -416,7 +418,7 @@ export function useThreadActions() { const stillOrphaned = getOrphanedWorktreePathForThread(currentThreads, threadRef.threadId) === orphanedWorktreePath; - const stillSettled = currentShell?.settledOverride === "settled"; + const stillSettled = currentShell?.archivedAt != null; if (!stillOrphaned || !stillSettled) { toastManager.add( stackedThreadToast({ @@ -485,7 +487,7 @@ export function useThreadActions() { (target: ScopedThreadRef) => unsettleThreadMutation({ environmentId: target.environmentId, - input: { threadId: target.threadId, reason: "user" }, + input: { threadId: target.threadId }, }), [unsettleThreadMutation], ); diff --git a/apps/web/src/lib/threadSort.test.ts b/apps/web/src/lib/threadSort.test.ts index ca9a5986c66..b9981bc2e3e 100644 --- a/apps/web/src/lib/threadSort.test.ts +++ b/apps/web/src/lib/threadSort.test.ts @@ -26,8 +26,6 @@ function makeThread(overrides: Partial = {}): Thread { proposedPlans: [], createdAt: "2026-03-09T10:00:00.000Z", archivedAt: null, - settledOverride: null, - settledAt: null, deletedAt: null, updatedAt: "2026-03-09T10:00:00.000Z", latestTurn: null, diff --git a/apps/web/src/worktreeCleanup.test.ts b/apps/web/src/worktreeCleanup.test.ts index 39882e44903..85dcb2af632 100644 --- a/apps/web/src/worktreeCleanup.test.ts +++ b/apps/web/src/worktreeCleanup.test.ts @@ -30,8 +30,6 @@ function makeThread(overrides: Partial = {}): Thread { createdAt: "2026-02-13T00:00:00.000Z", updatedAt: "2026-02-13T00:00:00.000Z", archivedAt: null, - settledOverride: null, - settledAt: null, deletedAt: null, latestTurn: null, branch: null, diff --git a/packages/client-runtime/src/operations/commands.test.ts b/packages/client-runtime/src/operations/commands.test.ts index 0cb1650066c..5cc3f0c1a86 100644 --- a/packages/client-runtime/src/operations/commands.test.ts +++ b/packages/client-runtime/src/operations/commands.test.ts @@ -21,13 +21,7 @@ import { import * as EnvironmentSupervisor from "../connection/supervisor.ts"; import * as RpcSession from "../rpc/session.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; -import { - archiveThread, - createProject, - settleThread, - stopThreadSession, - unsettleThread, -} from "./commands.ts"; +import { archiveThread, createProject, stopThreadSession } from "./commands.ts"; const TEST_CRYPTO_LAYER = Layer.succeed( Crypto.Crypto, @@ -140,35 +134,4 @@ describe("environment commands", () => { ]); }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), ); - - it.effect("dispatches settle and unsettle commands without timestamps", () => - Effect.gen(function* () { - const dispatched: ClientOrchestrationCommand[] = []; - const supervisor = yield* makeSupervisor(dispatched); - - yield* settleThread({ - commandId: CommandId.make("settle-command"), - threadId: ThreadId.make("thread-1"), - }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); - yield* unsettleThread({ - commandId: CommandId.make("unsettle-command"), - threadId: ThreadId.make("thread-1"), - reason: "user", - }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); - - expect(dispatched).toEqual([ - { - type: "thread.settle", - commandId: "settle-command", - threadId: "thread-1", - }, - { - type: "thread.unsettle", - commandId: "unsettle-command", - threadId: "thread-1", - reason: "user", - }, - ]); - }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), - ); }); diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index ef767854804..a0c3cbe771f 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -35,8 +35,6 @@ export type CreateThreadInput = CommandInput<"thread.create">; export type DeleteThreadInput = CommandInput<"thread.delete">; export type ArchiveThreadInput = CommandInput<"thread.archive">; export type UnarchiveThreadInput = CommandInput<"thread.unarchive">; -export type SettleThreadInput = CommandInput<"thread.settle">; -export type UnsettleThreadInput = CommandInput<"thread.unsettle">; export type UpdateThreadMetadataInput = CommandInput<"thread.meta.update">; export type SetThreadRuntimeModeInput = CommandInput<"thread.runtime-mode.set">; export type SetThreadInteractionModeInput = CommandInput<"thread.interaction-mode.set">; @@ -155,26 +153,6 @@ export const unarchiveThread: (input: UnarchiveThreadInput) => CommandEffect = E }); }); -export const settleThread: (input: SettleThreadInput) => CommandEffect = Effect.fn( - "EnvironmentCommands.settleThread", -)(function* (input) { - return yield* dispatch({ - ...input, - type: "thread.settle", - commandId: yield* commandId(input), - }); -}); - -export const unsettleThread: (input: UnsettleThreadInput) => CommandEffect = Effect.fn( - "EnvironmentCommands.unsettleThread", -)(function* (input) { - return yield* dispatch({ - ...input, - type: "thread.unsettle", - commandId: yield* commandId(input), - }); -}); - export const updateThreadMetadata: (input: UpdateThreadMetadataInput) => CommandEffect = Effect.fn( "EnvironmentCommands.updateThreadMetadata", )(function* (input) { diff --git a/packages/client-runtime/src/state/entities.test.ts b/packages/client-runtime/src/state/entities.test.ts index e08fd9e552f..c772d134a67 100644 --- a/packages/client-runtime/src/state/entities.test.ts +++ b/packages/client-runtime/src/state/entities.test.ts @@ -96,8 +96,6 @@ const THREAD_SHELL = { createdAt: "2026-06-01T00:00:00.000Z", updatedAt: "2026-06-01T00:00:00.000Z", archivedAt: null, - settledOverride: null, - settledAt: null, session: null, latestUserMessageAt: null, hasPendingApprovals: false, diff --git a/packages/client-runtime/src/state/shellReducer.test.ts b/packages/client-runtime/src/state/shellReducer.test.ts index fdccc4c47dd..a069460e63c 100644 --- a/packages/client-runtime/src/state/shellReducer.test.ts +++ b/packages/client-runtime/src/state/shellReducer.test.ts @@ -36,8 +36,6 @@ const stubThread = { createdAt: "2026-04-01T00:00:00.000Z", updatedAt: "2026-04-01T00:00:00.000Z", archivedAt: null, - settledOverride: null, - settledAt: null, latestUserMessageAt: null, hasPendingApprovals: false, hasPendingUserInput: false, diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index ea87298e98f..aab5110e9cf 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -12,11 +12,9 @@ import { type RevertThreadCheckpointInput, type SetThreadInteractionModeInput, type SetThreadRuntimeModeInput, - type SettleThreadInput, type StartThreadTurnInput, type StopThreadSessionInput, type UnarchiveThreadInput, - type UnsettleThreadInput, type UpdateThreadMetadataInput, archiveThread, createThread, @@ -27,11 +25,9 @@ import { revertThreadCheckpoint, setThreadInteractionMode, setThreadRuntimeMode, - settleThread, startThreadTurn, stopThreadSession, unarchiveThread, - unsettleThread, updateThreadMetadata, } from "../operations/commands.ts"; import type { EnvironmentRegistry } from "../connection/registry.ts"; @@ -46,11 +42,9 @@ export type { RevertThreadCheckpointInput, SetThreadInteractionModeInput, SetThreadRuntimeModeInput, - SettleThreadInput, StartThreadTurnInput, StopThreadSessionInput, UnarchiveThreadInput, - UnsettleThreadInput, UpdateThreadMetadataInput, } from "../operations/commands.ts"; @@ -88,18 +82,6 @@ export function createThreadEnvironmentAtoms( scheduler, concurrency, }), - settle: createEnvironmentCommand(runtime, { - label: "environment-data:commands:thread:settle", - execute: (input: SettleThreadInput) => settleThread(input), - scheduler, - concurrency, - }), - unsettle: createEnvironmentCommand(runtime, { - label: "environment-data:commands:thread:unsettle", - execute: (input: UnsettleThreadInput) => unsettleThread(input), - scheduler, - concurrency, - }), updateMetadata: createEnvironmentCommand(runtime, { label: "environment-data:commands:thread:update-metadata", execute: (input: UpdateThreadMetadataInput) => updateThreadMetadata(input), diff --git a/packages/client-runtime/src/state/threadDetail.ts b/packages/client-runtime/src/state/threadDetail.ts index 770738ad9bb..430900bdbb0 100644 --- a/packages/client-runtime/src/state/threadDetail.ts +++ b/packages/client-runtime/src/state/threadDetail.ts @@ -57,8 +57,6 @@ export function mergeEnvironmentThread( createdAt: shell.createdAt, updatedAt: shell.updatedAt, archivedAt: shell.archivedAt, - settledOverride: shell.settledOverride, - settledAt: shell.settledAt, session: shell.session, }; } diff --git a/packages/client-runtime/src/state/threadReducer.test.ts b/packages/client-runtime/src/state/threadReducer.test.ts index 211f8748f4e..94eb1c65370 100644 --- a/packages/client-runtime/src/state/threadReducer.test.ts +++ b/packages/client-runtime/src/state/threadReducer.test.ts @@ -34,8 +34,6 @@ const baseThread: OrchestrationThread = { createdAt: "2026-04-01T00:00:00.000Z", updatedAt: "2026-04-01T00:00:00.000Z", archivedAt: null, - settledOverride: null, - settledAt: null, deletedAt: null, messages: [], proposedPlans: [], @@ -166,62 +164,6 @@ describe("applyThreadDetailEvent", () => { }); }); - describe("thread.settled / thread.unsettled", () => { - it("sets the settled override and timestamp", () => { - const settledAt = "2026-04-01T05:00:00.000Z"; - const result = applyThreadDetailEvent(baseThread, { - ...baseEventFields, - sequence: 5, - occurredAt: settledAt, - aggregateKind: "thread", - aggregateId: ThreadId.make("thread-1"), - type: "thread.settled", - payload: { - threadId: ThreadId.make("thread-1"), - settledAt, - updatedAt: settledAt, - }, - }); - - expect(result.kind).toBe("updated"); - if (result.kind === "updated") { - expect(result.thread.settledOverride).toBe("settled"); - expect(result.thread.settledAt).toBe(settledAt); - } - }); - - it.each([ - ["user", "active"], - ["activity", null], - ] as const)("unsettles for %s with override %s", (reason, settledOverride) => { - const settledThread: OrchestrationThread = { - ...baseThread, - settledOverride: "settled", - settledAt: "2026-04-01T05:00:00.000Z", - }; - const updatedAt = "2026-04-01T06:00:00.000Z"; - const result = applyThreadDetailEvent(settledThread, { - ...baseEventFields, - sequence: 6, - occurredAt: updatedAt, - aggregateKind: "thread", - aggregateId: ThreadId.make("thread-1"), - type: "thread.unsettled", - payload: { - threadId: ThreadId.make("thread-1"), - reason, - updatedAt, - }, - }); - - expect(result.kind).toBe("updated"); - if (result.kind === "updated") { - expect(result.thread.settledOverride).toBe(settledOverride); - expect(result.thread.settledAt).toBeNull(); - } - }); - }); - describe("thread.meta-updated", () => { it("patches title and branch", () => { const result = applyThreadDetailEvent(baseThread, { diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index 7fdee37c0e6..670540fee70 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -72,8 +72,6 @@ export function applyThreadDetailEvent( createdAt: event.payload.createdAt, updatedAt: event.payload.updatedAt, archivedAt: null, - settledOverride: null, - settledAt: null, deletedAt: null, messages: [], proposedPlans: [], @@ -102,28 +100,6 @@ export function applyThreadDetailEvent( thread: { ...thread, archivedAt: null, updatedAt: event.payload.updatedAt }, }; - case "thread.settled": - return { - kind: "updated", - thread: { - ...thread, - settledOverride: "settled", - settledAt: event.payload.settledAt, - updatedAt: event.payload.updatedAt, - }, - }; - - case "thread.unsettled": - return { - kind: "updated", - thread: { - ...thread, - settledOverride: event.payload.reason === "user" ? "active" : null, - settledAt: null, - updatedAt: event.payload.updatedAt, - }, - }; - // ── Thread metadata ───────────────────────────────────────────── case "thread.meta-updated": return { diff --git a/packages/client-runtime/src/state/threadSettled.test.ts b/packages/client-runtime/src/state/threadSettled.test.ts index 8ea27466f2c..f6609c3bd50 100644 --- a/packages/client-runtime/src/state/threadSettled.test.ts +++ b/packages/client-runtime/src/state/threadSettled.test.ts @@ -18,7 +18,7 @@ const FRESH = "2026-04-09T00:00:00.000Z"; const STALE = "2026-04-06T23:59:59.999Z"; function makeShell(input: { - readonly settledOverride: OrchestrationThreadShell["settledOverride"]; + readonly archivedAt: string | null; readonly activityAt: string | null; readonly sessionStatus?: "starting" | "running"; readonly pending?: "approval" | "user-input"; @@ -46,9 +46,7 @@ function makeShell(input: { }, createdAt: "2026-04-01T00:00:00.000Z", updatedAt: NOW, - archivedAt: null, - settledOverride: input.settledOverride, - settledAt: input.settledOverride === "settled" ? NOW : null, + archivedAt: input.archivedAt, session: input.sessionStatus === undefined ? null @@ -70,7 +68,7 @@ function makeShell(input: { describe("threadLastActivityAt", () => { it("returns the latest real user or turn activity and ignores thread/session updates", () => { - const shell = makeShell({ settledOverride: null, activityAt: null, sessionStatus: "running" }); + const shell = makeShell({ archivedAt: null, activityAt: null, sessionStatus: "running" }); const withActivity: OrchestrationThreadShell = { ...shell, latestUserMessageAt: "2026-04-04T00:00:00.000Z", @@ -90,7 +88,7 @@ describe("threadLastActivityAt", () => { }); describe("effectiveSettled", () => { - const overrides = ["settled", "active", null] as const; + const archivedCases = [null, NOW] as const; const changeRequestStates = [undefined, "open", "merged"] as const; const inactivityCases = [ ["fresh", FRESH], @@ -99,23 +97,23 @@ describe("effectiveSettled", () => { ] as const; const runningCases = [false, true] as const; const pendingCases = [undefined, "approval", "user-input"] as const; - const truthTable = overrides.flatMap((settledOverride) => + const truthTable = archivedCases.flatMap((archivedAt) => changeRequestStates.flatMap((changeRequestState) => inactivityCases.flatMap(([inactivity, activityAt]) => runningCases.flatMap((running) => pendingCases.map((pending) => ({ - settledOverride, + archivedAt, changeRequestState, inactivity, activityAt, running, pending, + // Settled iff nothing blocks (pending work / live session) AND + // any positive signal holds: archived, merged PR, or staleness. expected: pending === undefined && - (settledOverride === "settled" || - (settledOverride === null && - !running && - (changeRequestState === "merged" || inactivity === "stale"))), + !running && + (archivedAt !== null || changeRequestState === "merged" || inactivity === "stale"), })), ), ), @@ -123,10 +121,10 @@ describe("effectiveSettled", () => { ); it.each(truthTable)( - "override=$settledOverride pr=$changeRequestState inactivity=$inactivity running=$running pending=$pending", - ({ settledOverride, changeRequestState, activityAt, running, pending, expected }) => { + "archived=$archivedAt pr=$changeRequestState inactivity=$inactivity running=$running pending=$pending", + ({ archivedAt, changeRequestState, activityAt, running, pending, expected }) => { const shell = makeShell({ - settledOverride, + archivedAt, activityAt, ...(running ? { sessionStatus: "running" as const } : {}), ...(pending === undefined ? {} : { pending }), @@ -147,7 +145,7 @@ describe("effectiveSettled", () => { ); it("treats closed change requests like merged ones", () => { - const shell = makeShell({ settledOverride: null, activityAt: null }); + const shell = makeShell({ archivedAt: null, activityAt: null }); expect( effectiveSettled(shell, { now: NOW, @@ -157,9 +155,9 @@ describe("effectiveSettled", () => { ).toBe(true); }); - it("never auto-settles a starting session", () => { + it("never settles a starting session, even when archived", () => { const shell = makeShell({ - settledOverride: null, + archivedAt: NOW, activityAt: STALE, sessionStatus: "starting", }); @@ -174,10 +172,10 @@ describe("effectiveSettled", () => { it("uses a strict inactivity boundary and honors a null threshold", () => { const boundary = makeShell({ - settledOverride: null, + archivedAt: null, activityAt: "2026-04-07T00:00:00.000Z", }); - const stale = makeShell({ settledOverride: null, activityAt: STALE }); + const stale = makeShell({ archivedAt: null, activityAt: STALE }); expect(effectiveSettled(boundary, { now: NOW, autoSettleAfterDays: 3 })).toBe(false); expect(effectiveSettled(stale, { now: NOW, autoSettleAfterDays: null })).toBe(false); diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts index 7f5e73f53af..8ffaabe8d78 100644 --- a/packages/client-runtime/src/state/threadSettled.ts +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -26,6 +26,17 @@ export function threadLastActivityAt(shell: OrchestrationThreadShell): string | return latest; } +/** + * Client-only settled resolution, backed by the pre-existing archive + * lifecycle instead of dedicated settle commands — no server, protocol, or + * database changes required. "Settled" here means: the user archived the + * thread, its PR merged/closed, or it has been inactive past the auto-settle + * window. + * + * Trade-offs vs the event-sourced settled model (kept on the main feature + * branch): activity does not auto-un-settle an archived thread, and there is + * no distinct "keep active" override — un-settling is just unarchiving. + */ export function effectiveSettled( shell: OrchestrationThreadShell, options: { @@ -36,9 +47,8 @@ export function effectiveSettled( ): boolean { // Blocked work must remain visible even when a user explicitly settled it. if (shell.hasPendingApprovals || shell.hasPendingUserInput) return false; - if (shell.settledOverride === "settled") return true; - if (shell.settledOverride === "active") return false; if (shell.session?.status === "starting" || shell.session?.status === "running") return false; + if (shell.archivedAt !== null) return true; if (options.changeRequestState === "merged" || options.changeRequestState === "closed") { return true; } diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index 2ce8026a62f..8e982723d22 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -67,8 +67,6 @@ const BASE_THREAD: OrchestrationThread = { createdAt: "2026-04-01T00:00:00.000Z", updatedAt: "2026-04-01T00:00:00.000Z", archivedAt: null, - settledOverride: null, - settledAt: null, deletedAt: null, messages: [], proposedPlans: [], diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index bdc7169821e..29a732ca69b 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -15,8 +15,6 @@ import { ProjectMetaUpdatedPayload, OrchestrationProposedPlan, OrchestrationSession, - OrchestrationThread, - OrchestrationThreadShell, ProjectCreateCommand, ThreadMetaUpdatedPayload, ThreadTurnStartCommand, @@ -39,8 +37,6 @@ const decodeThreadTurnStartRequestedPayload = Schema.decodeUnknownEffect( const decodeOrchestrationLatestTurn = Schema.decodeUnknownEffect(OrchestrationLatestTurn); const decodeOrchestrationProposedPlan = Schema.decodeUnknownEffect(OrchestrationProposedPlan); const decodeOrchestrationSession = Schema.decodeUnknownEffect(OrchestrationSession); -const decodeOrchestrationThread = Schema.decodeUnknownEffect(OrchestrationThread); -const decodeOrchestrationThreadShell = Schema.decodeUnknownEffect(OrchestrationThreadShell); const encodeThreadCreatedPayload = Schema.encodeEffect(ThreadCreatedPayload); function getOptionValue( @@ -348,65 +344,6 @@ it.effect("decodes thread archive and unarchive commands", () => }), ); -it.effect("decodes thread settle and unsettle commands", () => - Effect.gen(function* () { - const settle = yield* decodeOrchestrationCommand({ - type: "thread.settle", - commandId: "cmd-settle-1", - threadId: "thread-1", - }); - const unsettle = yield* decodeOrchestrationCommand({ - type: "thread.unsettle", - commandId: "cmd-unsettle-1", - threadId: "thread-1", - reason: "activity", - }); - - assert.strictEqual(settle.type, "thread.settle"); - assert.strictEqual(unsettle.type, "thread.unsettle"); - }), -); - -it.effect("defaults settled fields when decoding historical thread data", () => - Effect.gen(function* () { - const common = { - id: "thread-1", - projectId: "project-1", - title: "Historical thread", - modelSelection: { provider: "codex", model: "gpt-5.4" }, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - latestTurn: null, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - archivedAt: null, - session: null, - }; - const thread = yield* decodeOrchestrationThread({ - ...common, - deletedAt: null, - messages: [], - proposedPlans: [], - activities: [], - checkpoints: [], - }); - const shell = yield* decodeOrchestrationThreadShell({ - ...common, - latestUserMessageAt: null, - hasPendingApprovals: false, - hasPendingUserInput: false, - hasActionableProposedPlan: false, - }); - - assert.strictEqual(thread.settledOverride, null); - assert.strictEqual(thread.settledAt, null); - assert.strictEqual(shell.settledOverride, null); - assert.strictEqual(shell.settledAt, null); - }), -); - it.effect("decodes thread archived and unarchived events", () => Effect.gen(function* () { const archived = yield* decodeOrchestrationEvent({ @@ -451,48 +388,6 @@ it.effect("decodes thread archived and unarchived events", () => }), ); -it.effect("decodes thread settled and unsettled events", () => - Effect.gen(function* () { - const settled = yield* decodeOrchestrationEvent({ - sequence: 1, - eventId: "event-settle-1", - aggregateKind: "thread", - aggregateId: "thread-1", - type: "thread.settled", - occurredAt: "2026-01-01T00:00:00.000Z", - commandId: "cmd-settle-1", - causationEventId: null, - correlationId: "cmd-settle-1", - metadata: {}, - payload: { - threadId: "thread-1", - settledAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - }, - }); - const unsettled = yield* decodeOrchestrationEvent({ - sequence: 2, - eventId: "event-unsettle-1", - aggregateKind: "thread", - aggregateId: "thread-1", - type: "thread.unsettled", - occurredAt: "2026-01-02T00:00:00.000Z", - commandId: "cmd-unsettle-1", - causationEventId: null, - correlationId: "cmd-unsettle-1", - metadata: {}, - payload: { - threadId: "thread-1", - reason: "user", - updatedAt: "2026-01-02T00:00:00.000Z", - }, - }); - - assert.strictEqual(settled.type, "thread.settled"); - assert.strictEqual(unsettled.type, "thread.unsettled"); - }), -); - it.effect("accepts provider-scoped model options in thread.turn.start", () => Effect.gen(function* () { const parsed = yield* decodeThreadTurnStartCommand({ diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 80b7177f9b7..7e9c421b5df 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -356,10 +356,6 @@ export const OrchestrationThread = Schema.Struct({ createdAt: IsoDateTime, updatedAt: IsoDateTime, archivedAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))), - settledOverride: Schema.NullOr(Schema.Literals(["settled", "active"])).pipe( - Schema.withDecodingDefault(Effect.succeed(null)), - ), - settledAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))), deletedAt: Schema.NullOr(IsoDateTime), messages: Schema.Array(OrchestrationMessage), proposedPlans: Schema.Array(OrchestrationProposedPlan).pipe( @@ -406,10 +402,6 @@ export const OrchestrationThreadShell = Schema.Struct({ createdAt: IsoDateTime, updatedAt: IsoDateTime, archivedAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))), - settledOverride: Schema.NullOr(Schema.Literals(["settled", "active"])).pipe( - Schema.withDecodingDefault(Effect.succeed(null)), - ), - settledAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))), session: Schema.NullOr(OrchestrationSession), latestUserMessageAt: Schema.NullOr(IsoDateTime), hasPendingApprovals: Schema.Boolean, @@ -553,19 +545,6 @@ const ThreadUnarchiveCommand = Schema.Struct({ threadId: ThreadId, }); -const ThreadSettleCommand = Schema.Struct({ - type: Schema.Literal("thread.settle"), - commandId: CommandId, - threadId: ThreadId, -}); - -const ThreadUnsettleCommand = Schema.Struct({ - type: Schema.Literal("thread.unsettle"), - commandId: CommandId, - threadId: ThreadId, - reason: Schema.Literals(["user", "activity"]), -}); - const ThreadMetaUpdateCommand = Schema.Struct({ type: Schema.Literal("thread.meta.update"), commandId: CommandId, @@ -708,8 +687,6 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ ThreadDeleteCommand, ThreadArchiveCommand, ThreadUnarchiveCommand, - ThreadSettleCommand, - ThreadUnsettleCommand, ThreadMetaUpdateCommand, ThreadRuntimeModeSetCommand, ThreadInteractionModeSetCommand, @@ -731,8 +708,6 @@ export const ClientOrchestrationCommand = Schema.Union([ ThreadDeleteCommand, ThreadArchiveCommand, ThreadUnarchiveCommand, - ThreadSettleCommand, - ThreadUnsettleCommand, ThreadMetaUpdateCommand, ThreadRuntimeModeSetCommand, ThreadInteractionModeSetCommand, @@ -835,8 +810,6 @@ export const OrchestrationEventType = Schema.Literals([ "thread.deleted", "thread.archived", "thread.unarchived", - "thread.settled", - "thread.unsettled", "thread.meta-updated", "thread.runtime-mode-set", "thread.interaction-mode-set", @@ -916,18 +889,6 @@ export const ThreadUnarchivedPayload = Schema.Struct({ updatedAt: IsoDateTime, }); -export const ThreadSettledPayload = Schema.Struct({ - threadId: ThreadId, - settledAt: IsoDateTime, - updatedAt: IsoDateTime, -}); - -export const ThreadUnsettledPayload = Schema.Struct({ - threadId: ThreadId, - reason: Schema.Literals(["user", "activity"]), - updatedAt: IsoDateTime, -}); - export const ThreadMetaUpdatedPayload = Schema.Struct({ threadId: ThreadId, title: Schema.optional(TrimmedNonEmptyString), @@ -1095,16 +1056,6 @@ export const OrchestrationEvent = Schema.Union([ type: Schema.Literal("thread.unarchived"), payload: ThreadUnarchivedPayload, }), - Schema.Struct({ - ...EventBaseFields, - type: Schema.Literal("thread.settled"), - payload: ThreadSettledPayload, - }), - Schema.Struct({ - ...EventBaseFields, - type: Schema.Literal("thread.unsettled"), - payload: ThreadUnsettledPayload, - }), Schema.Struct({ ...EventBaseFields, type: Schema.Literal("thread.meta-updated"), From af880a274015e795bf03c7f95afc8bf79c270b86 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 15 Jul 2026 21:58:52 -0700 Subject: [PATCH 10/14] Address PR #4026 review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - settleThread inherits archive's running-turn guard on web and mobile: settling can never interrupt an active turn - unsettleThread no-ops on auto-settled (unarchived) threads instead of sending an unarchive the server would reject - Mobile v2 partition now receives per-row PR state (rows report up via onChangeRequestState, mirroring web) so merged/closed PRs auto-settle - Mobile empty state accounts for archived-only workspaces when v2 is on - Optimistic settled hold on web + mobile: the shell being settled is kept visible (marked archived) between the live stream dropping it and the archived snapshot returning it — no flicker, and holds are created only at settle time so deletes are never resurrected - Hover-only settle buttons in web SidebarV2 get focus-visible:opacity-100 - v2 full swipe commits the advertised primary action (Settle/Un-settle), with the stretch animation following; delete remains a tap target only. v1 keeps full-swipe delete via the new fullSwipeAction prop default - Android long-press on v2 cards works: PressableScaleCard is the menu's direct child and forwards the injected onLongPress to its Pressable Co-Authored-By: Claude Fable 5 --- apps/mobile/src/features/home/HomeScreen.tsx | 100 +++++++++++++++--- .../features/home/thread-swipe-actions.tsx | 20 +++- .../src/features/home/useThreadListActions.ts | 18 ++++ .../features/threads/thread-list-v2-items.tsx | 51 +++++++-- .../src/features/threads/threadListV2.ts | 8 +- apps/web/src/components/SidebarV2.tsx | 83 ++++++++++++--- apps/web/src/hooks/useThreadActions.ts | 32 +++++- 7 files changed, 262 insertions(+), 50 deletions(-) diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index c06d42f1ce1..e0b56999417 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -14,7 +14,7 @@ import type { } from "@t3tools/contracts"; import { useAtomSet, useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/unstable/reactivity"; -import { useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ActivityIndicator, Platform, View } from "react-native"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -294,22 +294,85 @@ export function HomeScreen(props: HomeScreenProps) { [props.environments, threadListV2Enabled], ); const { snapshots: archivedSnapshots } = useArchivedThreadSnapshots(archivedEnvironmentIds); + // PR states stream in per-row (rows own the VCS subscriptions); a merged or + // closed PR auto-settles its thread on the next partition (mirrors web). + const [changeRequestStateByKey, setChangeRequestStateByKey] = useState< + ReadonlyMap + >(() => new Map()); + const handleChangeRequestState = useCallback( + (threadKey: string, state: "open" | "closed" | "merged" | null) => { + setChangeRequestStateByKey((current) => { + if ((current.get(threadKey) ?? null) === state) return current; + const next = new Map(current); + if (state === null) { + next.delete(threadKey); + } else { + next.set(threadKey, state); + } + return next; + }); + }, + [], + ); + // Bridge the gap between the live stream dropping a just-settled thread + // and the archived snapshot returning it: hold the shell we settled, + // marked archived, until the snapshot carries it. Held explicitly at + // settle time so deleted threads are never resurrected. + const [settledHolds, setSettledHolds] = useState>( + () => new Map(), + ); + const handleSettleThread = useCallback( + (thread: EnvironmentThreadShell) => { + setSettledHolds((current) => + new Map(current).set(scopedThreadKey(thread.environmentId, thread.id), { + ...thread, + archivedAt: thread.archivedAt ?? new Date().toISOString(), + }), + ); + props.onSettleThread(thread); + }, + [props.onSettleThread], + ); + useEffect(() => { + if (settledHolds.size === 0) return; + const covered = new Set(); + for (const { environmentId, snapshot } of archivedSnapshots) { + for (const thread of snapshot.threads) { + covered.add(scopedThreadKey(environmentId, thread.id)); + } + } + if ([...settledHolds.keys()].some((threadKey) => covered.has(threadKey))) { + setSettledHolds((current) => { + const next = new Map(current); + for (const threadKey of covered) next.delete(threadKey); + return next; + }); + } + }, [archivedSnapshots, settledHolds]); const threadListV2Items = useMemo(() => { if (!threadListV2Enabled) return []; - const liveKeys = new Set( - props.threads.map((thread) => scopedThreadKey(thread.environmentId, thread.id)), - ); - const archived = archivedSnapshots.flatMap(({ environmentId, snapshot }) => - snapshot.threads - .map((thread) => ({ ...thread, environmentId })) - .filter((thread) => !liveKeys.has(scopedThreadKey(thread.environmentId, thread.id))), - ); + const merged = new Map(); + for (const { environmentId, snapshot } of archivedSnapshots) { + for (const thread of snapshot.threads) { + merged.set(scopedThreadKey(environmentId, thread.id), { ...thread, environmentId }); + } + } + for (const thread of props.threads) { + merged.set(scopedThreadKey(thread.environmentId, thread.id), thread); + } + for (const [threadKey, shell] of settledHolds) { + if (merged.has(threadKey)) continue; + merged.set(threadKey, shell); + } return buildThreadListV2Items({ - threads: [...props.threads, ...archived], + threads: [...merged.values()], environmentId: props.selectedEnvironmentId, searchQuery: props.searchQuery, + changeRequestStateByKey, }); }, [ + changeRequestStateByKey, + settledHolds, archivedSnapshots, props.searchQuery, props.selectedEnvironmentId, @@ -330,20 +393,27 @@ export function HomeScreen(props: HomeScreenProps) { onSelectThread={props.onSelectThread} onArchiveThread={props.onArchiveThread} onDeleteThread={props.onDeleteThread} - onSettleThread={props.onSettleThread} + onSettleThread={handleSettleThread} onUnsettleThread={props.onUnsettleThread} + onChangeRequestState={handleChangeRequestState} + projectCwd={ + projectCwdByKey.get(scopedProjectKey(item.thread.environmentId, item.thread.projectId)) ?? + null + } onSwipeableClose={handleSwipeableClose} onSwipeableWillOpen={handleSwipeableWillOpen} /> ), [ + handleChangeRequestState, + handleSettleThread, handleSwipeableClose, handleSwipeableWillOpen, projectByKey, + projectCwdByKey, props.onArchiveThread, props.onDeleteThread, props.onSelectThread, - props.onSettleThread, props.onUnsettleThread, ], ); @@ -445,8 +515,12 @@ export function HomeScreen(props: HomeScreenProps) { const keyExtractor = useCallback((item: HomeListItem) => item.key, []); /* Empty states */ + // v2 shows archived threads as its settled tail, so an archived-only + // workspace still has a list to render there. const hasAnyThreads = - props.threads.some((thread) => thread.archivedAt === null) || props.pendingTasks.length > 0; + props.threads.some((thread) => thread.archivedAt === null) || + props.pendingTasks.length > 0 || + (threadListV2Enabled && threadListV2Items.length > 0); const hasResults = projectGroups.length > 0; const selectedEnvironmentLabel = props.selectedEnvironmentId === null diff --git a/apps/mobile/src/features/home/thread-swipe-actions.tsx b/apps/mobile/src/features/home/thread-swipe-actions.tsx index 84a9f32ee5e..666169f3039 100644 --- a/apps/mobile/src/features/home/thread-swipe-actions.tsx +++ b/apps/mobile/src/features/home/thread-swipe-actions.tsx @@ -167,6 +167,13 @@ export function ThreadSwipeable(props: { /** Disables NEW swipe activations (e.g. while the list scrolls). */ readonly enabled?: boolean; readonly enableTrackpadSwipe?: boolean; + /** + * What a full swipe commits: "delete" (default, v1 behavior — the Delete + * button stretches) or "primary" — the advertised primary action fires and + * its button stretches instead. A full swipe must always match the action + * the stretching button advertises. + */ + readonly fullSwipeAction?: "delete" | "primary"; readonly fullSwipeWidth: number; readonly onDelete: () => void; readonly onSwipeableClose?: (methods: SwipeableMethods) => void; @@ -239,7 +246,11 @@ export function ThreadSwipeable(props: { if (fullSwipeArmedRef.current) { fullSwipeArmedRef.current = false; methods.close(); - props.onDelete(); + if (props.fullSwipeAction === "primary") { + props.primaryAction.onPress(); + } else { + props.onDelete(); + } } }} overshootFriction={1} @@ -247,6 +258,7 @@ export function ThreadSwipeable(props: { renderRightActions={(_progress, translation, methods) => ( void; readonly onFullSwipeArmedChange: (armed: boolean) => void; @@ -430,6 +443,7 @@ export function ThreadSwipeActions(props: { readonly threadTitle: string; readonly translation: SharedValue; }) { + const fullSwipeIsPrimary = props.fullSwipeAction === "primary"; useAnimatedReaction( () => -props.translation.value >= props.fullSwipeThreshold, (armed, previous) => { @@ -457,7 +471,7 @@ export function ThreadSwipeActions(props: { icon={props.primaryAction.icon} label={props.primaryAction.label} onPress={props.primaryAction.onPress} - stretchesOnFullSwipe={false} + stretchesOnFullSwipe={fullSwipeIsPrimary} translation={props.translation} /> diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index 29f54d4f4ab..20341441546 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -62,6 +62,24 @@ function useThreadActionExecutor( inFlightThreadKeys.current.add(key); selectionHaptic(); try { + // Settle rides archive, so it inherits archive's guard: never + // interrupt a thread mid-turn. + if ( + (action === "settle" || action === "archive") && + thread.session?.status === "running" && + thread.session.activeTurnId != null + ) { + Alert.alert( + actionFailureTitle(action), + "This thread is working. Interrupt it first, then try again.", + ); + return; + } + // Auto-settled rows (inactivity / merged PR) are not archived; + // unarchiving them would be rejected. Nothing to undo — no-op. + if (action === "unsettle" && thread.archivedAt === null) { + return; + } const mutation = action === "settle" ? settleMutation diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index a7496630d2b..d7dc900c4e2 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -15,8 +15,10 @@ import { ProjectFavicon } from "../../components/ProjectFavicon"; import { cn } from "../../lib/cn"; import { relativeTime } from "../../lib/time"; import { useThemeColor } from "../../lib/useThemeColor"; +import { useThreadPr } from "../../state/use-thread-pr"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; import { resolveThreadListV2Status, type ThreadListV2Status } from "./threadListV2"; +import { useEffect } from "react"; /** * Thread List v2 rows. The design language is the web sidebar v2 (status @@ -71,11 +73,17 @@ const PRESS_SPRING = { damping: 30, stiffness: 400 } as const; /** * Pressable that springs down to 97% while touched — the "this is a real * object under your finger" signal every native list app ships. + * + * Accepts an injected `onLongPress` and forwards it to the inner Pressable: + * on Android, ControlPillMenu opens its menu by cloning its immediate child + * with an onLongPress prop, so this component must be that child and must + * route the handler to something that actually handles presses. */ function PressableScaleCard(props: { readonly accessibilityHint: string; readonly accessibilityLabel: string; readonly onPress: () => void; + readonly onLongPress?: () => void; readonly children: ReactNode; }) { const scale = useSharedValue(1); @@ -88,6 +96,7 @@ function PressableScaleCard(props: { accessibilityLabel={props.accessibilityLabel} accessibilityRole="button" onPress={props.onPress} + {...(props.onLongPress ? { onLongPress: props.onLongPress } : {})} onPressIn={() => { scale.value = withSpring(0.97, PRESS_SPRING); }} @@ -128,6 +137,13 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void; readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; readonly onSwipeableClose: (methods: SwipeableMethods) => void; + /** Reports this row's live PR state up so the partition can auto-settle + merged/closed work (mirrors web's onChangeRequestState). */ + readonly onChangeRequestState?: ( + threadKey: string, + state: "open" | "closed" | "merged" | null, + ) => void; + readonly projectCwd?: string | null; readonly simultaneousSwipeGesture?: ComponentProps< typeof ThreadSwipeable >["simultaneousWithExternalGesture"]; @@ -141,8 +157,16 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { onDeleteThread, onSettleThread, onUnsettleThread, + onChangeRequestState, } = props; + const pr = useThreadPr(thread, props.projectCwd ?? props.project?.workspaceRoot ?? null); + const prState = pr?.state ?? null; + const threadKey = `${thread.environmentId}:${thread.id}`; + useEffect(() => { + onChangeRequestState?.(threadKey, prState); + }, [onChangeRequestState, prState, threadKey]); + const iconSubtleColor = useThemeColor("--color-icon-subtle"); const screenColor = useThemeColor("--color-screen"); @@ -187,15 +211,17 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const rowContent = (close: () => void) => variant === "card" ? ( - - { - close(); - onSelectThread(thread); - }} - > + // PressableScaleCard must be the ROOT here: ControlPillMenu injects + // its Android long-press by cloning this element. + { + close(); + onSelectThread(thread); + }} + > + {/* Solid raised card: bg-card with no border reads as an object, not a notification banner. */} - - + + ) : ( ; readonly environmentId: EnvironmentId | null; readonly searchQuery: string; + /** Per-row PR state reported up by visible rows ("env:threadId" keys). */ + readonly changeRequestStateByKey?: ReadonlyMap; readonly autoSettleAfterDays?: number; /** Injectable for tests; defaults to now. */ readonly now?: string; @@ -77,9 +79,9 @@ export function buildThreadListV2Items(input: { // archive IS settle, so they render as the settled tail. if (input.environmentId !== null && thread.environmentId !== input.environmentId) continue; if (query.length > 0 && !thread.title.toLocaleLowerCase().includes(query)) continue; - // PR state feeds the web partition per-row; mobile shells don't watch PRs - // from the list, so the partition here is archive/session/inactivity. - if (effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState: null })) { + const changeRequestState = + input.changeRequestStateByKey?.get(`${thread.environmentId}:${thread.id}`) ?? null; + if (effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState })) { settled.push(thread); } else { active.push(thread); diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index a97912ebf07..e5be113e313 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -415,7 +415,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { type="button" aria-label="Un-settle thread" onClick={handleUnsettleClick} - className="absolute inset-y-0 right-0 inline-flex items-center gap-1 border border-border bg-background px-2 text-[11px] text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover/v2-row:opacity-100" + className="absolute inset-y-0 right-0 inline-flex items-center gap-1 border border-border bg-background px-2 text-[11px] text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100" > @@ -424,7 +424,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { type="button" aria-label="Settle thread" onClick={handleSettleClick} - className="absolute inset-y-0 right-0 inline-flex items-center gap-1 border border-border bg-background px-2 text-[11px] text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover/v2-row:opacity-100" + className="absolute inset-y-0 right-0 inline-flex items-center gap-1 border border-border bg-background px-2 text-[11px] text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100" > @@ -504,7 +504,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { type="button" aria-label="Settle thread" onClick={handleSettleClick} - className="absolute inset-y-0 right-0 inline-flex items-center gap-1 border border-border bg-background px-2 text-[10px] text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover/v2-row:opacity-100" + className="absolute inset-y-0 right-0 inline-flex items-center gap-1 border border-border bg-background px-2 text-[10px] text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100" > Settle @@ -715,20 +715,53 @@ export default function SidebarV2() { [environments], ); const { snapshots: archivedSnapshots } = useArchivedThreadSnapshots(archivedEnvironmentIds); + // Bridge the gap between the live stream dropping a just-settled thread + // (thread.archived → thread-removed) and the archived snapshot returning + // it: hold the shell we settled, marked archived, until either source + // carries the thread again. Held explicitly at settle time — not inferred + // from disappearance — so deleted threads are never resurrected. + const [settledHolds, setSettledHolds] = useState>( + () => new Map(), + ); const allThreads = useMemo(() => { - const liveKeys = new Set( - threads.map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), - ); - const archived = archivedSnapshots.flatMap(({ environmentId, snapshot }) => - snapshot.threads - .map((thread) => ({ ...thread, environmentId })) - .filter( - (thread) => - !liveKeys.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), - ), - ); - return [...threads, ...archived]; - }, [archivedSnapshots, threads]); + const merged = new Map(); + for (const { environmentId, snapshot } of archivedSnapshots) { + for (const thread of snapshot.threads) { + merged.set(scopedThreadKey(scopeThreadRef(environmentId, thread.id)), { + ...thread, + environmentId, + }); + } + } + // Live shells win over snapshot copies. + for (const thread of threads) { + merged.set(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread); + } + for (const [threadKey, shell] of settledHolds) { + if (merged.has(threadKey)) continue; + merged.set(threadKey, shell); + } + return [...merged.values()]; + }, [archivedSnapshots, settledHolds, threads]); + // Drop a hold once the archived snapshot carries the thread — the steady + // state. (Not the live stream: the thread is still live when the hold is + // created, and holds are inert while a real source has the thread anyway.) + useEffect(() => { + if (settledHolds.size === 0) return; + const covered = new Set(); + for (const { environmentId, snapshot } of archivedSnapshots) { + for (const thread of snapshot.threads) { + covered.add(scopedThreadKey(scopeThreadRef(environmentId, thread.id))); + } + } + if ([...settledHolds.keys()].some((threadKey) => covered.has(threadKey))) { + setSettledHolds((current) => { + const next = new Map(current); + for (const threadKey of covered) next.delete(threadKey); + return next; + }); + } + }, [archivedSnapshots, settledHolds]); const { activeThreads, settledThreads } = useMemo(() => { const now = `${nowMinute}:00.000Z`; @@ -894,8 +927,26 @@ export default function SidebarV2() { const attemptSettle = useCallback( (threadRef: ScopedThreadRef) => { void (async () => { + // Hold the shell before dispatching: the live stream drops the + // thread on archive, and the settled tail must not flicker while + // the archived snapshot refresh is in flight. + const threadKey = scopedThreadKey(threadRef); + const shell = threadByKeyRef.current.get(threadKey); + if (shell) { + setSettledHolds((current) => + new Map(current).set(threadKey, { + ...shell, + archivedAt: shell.archivedAt ?? new Date().toISOString(), + }), + ); + } const result = await settleThread(threadRef); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + setSettledHolds((current) => { + const next = new Map(current); + next.delete(threadKey); + return next; + }); const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 47c7767bf66..1934cf44e17 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -360,6 +360,22 @@ export function useThreadActions() { const settleThread = useCallback( async (target: ScopedThreadRef) => { const resolved = resolveThreadTarget(target); + // Settle rides archive, so it inherits archive's guard: never + // interrupt a thread mid-turn. + if ( + resolved && + resolved.thread.session?.status === "running" && + resolved.thread.session.activeTurnId != null + ) { + return AsyncResult.failure( + Cause.fail( + new ThreadArchiveBlockedError({ + environmentId: resolved.threadRef.environmentId, + threadId: resolved.threadRef.threadId, + }), + ), + ); + } const settleResult = await settleThreadMutation({ environmentId: target.environmentId, input: { threadId: target.threadId }, @@ -484,12 +500,20 @@ export function useThreadActions() { ); const unsettleThread = useCallback( - (target: ScopedThreadRef) => - unsettleThreadMutation({ + async (target: ScopedThreadRef) => { + // Auto-settled rows (inactivity / merged PR) are not archived; sending + // unarchive for them would be rejected by the server. There is nothing + // to undo client-side, so succeed as a no-op. + const resolved = resolveThreadTarget(target); + if (resolved && resolved.thread.archivedAt === null) { + return AsyncResult.success(undefined); + } + return unsettleThreadMutation({ environmentId: target.environmentId, input: { threadId: target.threadId }, - }), - [unsettleThreadMutation], + }); + }, + [resolveThreadTarget, unsettleThreadMutation], ); const confirmAndDeleteThread = useCallback( From 535449600a001ab9f0ac5b9d9aee3fc9cf902828 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 15 Jul 2026 22:42:33 -0700 Subject: [PATCH 11/14] Address PR #4026 review findings, round two MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Worktree-removal offer works after settle: archived threads leave the live shell store, so the orphan check re-includes the settled shell and click-time revalidation treats absence-from-live as still-settled - Un-settle is only offered when there is an archive to undo: auto-settled rows (inactivity / merged PR, archivedAt null) get Settle instead, on web (hover button + context menu) and mobile (swipe + long-press menu) - Mobile optimistic settled hold rolls back when the settle is blocked or fails (settleThread now reports success); web and mobile both drop holds on delete and un-settle so ghosts can't linger - Archived snapshot refreshes after delete too, clearing stale settled rows sourced from the snapshot - v2 mobile list renders queued offline tasks above the cards (they were dropped entirely, leaving a blank screen for pending-task-only projects) On the chat.new/settings finding: not fixed here — the shortcut routes to the v2 picker only inside chat routes by design; on /settings it falls back to contextual create, matching v1's behavior on those routes. Co-Authored-By: Claude Fable 5 --- apps/mobile/src/features/home/HomeScreen.tsx | 75 +++++++++++++++++-- .../src/features/home/useThreadListActions.ts | 30 ++++---- .../features/threads/thread-list-v2-items.tsx | 25 ++++--- apps/web/src/components/SidebarV2.tsx | 39 +++++++++- apps/web/src/hooks/useThreadActions.ts | 30 ++++++-- 5 files changed, 153 insertions(+), 46 deletions(-) diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index e0b56999417..34cf2e45668 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -76,7 +76,8 @@ interface HomeScreenProps { readonly onSelectThread: (thread: EnvironmentThreadShell) => void; readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; - readonly onSettleThread: (thread: EnvironmentThreadShell) => void; + /** Resolves true iff the settle was dispatched and succeeded. */ + readonly onSettleThread: (thread: EnvironmentThreadShell) => Promise; readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void; readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void; readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; @@ -323,16 +324,52 @@ export function HomeScreen(props: HomeScreenProps) { ); const handleSettleThread = useCallback( (thread: EnvironmentThreadShell) => { + const threadKey = scopedThreadKey(thread.environmentId, thread.id); setSettledHolds((current) => - new Map(current).set(scopedThreadKey(thread.environmentId, thread.id), { + new Map(current).set(threadKey, { ...thread, archivedAt: thread.archivedAt ?? new Date().toISOString(), }), ); - props.onSettleThread(thread); + void (async () => { + // Roll the optimistic hold back if the settle was blocked or failed — + // otherwise a never-archived thread would render settled forever. + const succeeded = await props.onSettleThread(thread); + if (!succeeded) { + setSettledHolds((current) => { + const next = new Map(current); + next.delete(threadKey); + return next; + }); + } + })(); }, [props.onSettleThread], ); + // Delete and un-settle both invalidate any hold for the thread. + const dropSettledHold = useCallback((thread: EnvironmentThreadShell) => { + setSettledHolds((current) => { + const threadKey = scopedThreadKey(thread.environmentId, thread.id); + if (!current.has(threadKey)) return current; + const next = new Map(current); + next.delete(threadKey); + return next; + }); + }, []); + const handleDeleteThread = useCallback( + (thread: EnvironmentThreadShell) => { + dropSettledHold(thread); + props.onDeleteThread(thread); + }, + [dropSettledHold, props.onDeleteThread], + ); + const handleUnsettleThread = useCallback( + (thread: EnvironmentThreadShell) => { + dropSettledHold(thread); + props.onUnsettleThread(thread); + }, + [dropSettledHold, props.onUnsettleThread], + ); useEffect(() => { if (settledHolds.size === 0) return; const covered = new Set(); @@ -392,9 +429,9 @@ export function HomeScreen(props: HomeScreenProps) { } onSelectThread={props.onSelectThread} onArchiveThread={props.onArchiveThread} - onDeleteThread={props.onDeleteThread} + onDeleteThread={handleDeleteThread} onSettleThread={handleSettleThread} - onUnsettleThread={props.onUnsettleThread} + onUnsettleThread={handleUnsettleThread} onChangeRequestState={handleChangeRequestState} projectCwd={ projectCwdByKey.get(scopedProjectKey(item.thread.environmentId, item.thread.projectId)) ?? @@ -406,15 +443,15 @@ export function HomeScreen(props: HomeScreenProps) { ), [ handleChangeRequestState, + handleDeleteThread, handleSettleThread, handleSwipeableClose, handleSwipeableWillOpen, + handleUnsettleThread, projectByKey, projectCwdByKey, props.onArchiveThread, - props.onDeleteThread, props.onSelectThread, - props.onUnsettleThread, ], ); const v2KeyExtractor = useCallback( @@ -586,6 +623,28 @@ export function HomeScreen(props: HomeScreenProps) { ); + // v2 renders queued offline tasks above the thread cards — they are not + // thread shells, so the v2 item builder never sees them, but they must + // stay visible and deletable while their environment is offline. + const v2ListHeader = ( + <> + {listHeader} + {props.pendingTasks.map((pendingTask, index) => ( + + ))} + + ); + const listEmpty = !hasResults ? ( hasSearchQuery ? ( @@ -610,7 +669,7 @@ export function HomeScreen(props: HomeScreenProps) { drawDistance={500} estimatedItemSize={ESTIMATED_THREAD_ROW_HEIGHT} extraData={projectByKey} - ListHeaderComponent={listHeader} + ListHeaderComponent={v2ListHeader} ListEmptyComponent={listEmpty} style={{ flex: 1 }} automaticallyAdjustsScrollIndicatorInsets={Platform.OS === "ios"} diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index 20341441546..1b347df2c55 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -40,6 +40,7 @@ function actionFailureTitle(action: ThreadListAction): string { return "Could not delete thread"; } +/** Resolves to true iff the action was dispatched and succeeded. */ function useThreadActionExecutor( onCompleted?: (action: ThreadListAction, thread: EnvironmentThreadShell) => void, ) { @@ -56,7 +57,7 @@ function useThreadActionExecutor( async (action: ThreadListAction, thread: EnvironmentThreadShell) => { const key = scopedThreadKey(thread.environmentId, thread.id); if (inFlightThreadKeys.current.has(key)) { - return; + return false; } inFlightThreadKeys.current.add(key); @@ -73,12 +74,12 @@ function useThreadActionExecutor( actionFailureTitle(action), "This thread is working. Interrupt it first, then try again.", ); - return; + return false; } // Auto-settled rows (inactivity / merged PR) are not archived; // unarchiving them would be rejected. Nothing to undo — no-op. if (action === "unsettle" && thread.archivedAt === null) { - return; + return false; } const mutation = action === "settle" @@ -96,15 +97,16 @@ function useThreadActionExecutor( }); if (result._tag === "Failure") { Alert.alert(actionFailureTitle(action), actionFailureMessage(action, result.cause)); - return; - } - // Settle rides archive, and archiving drops the thread from the live - // shell stream — refresh the archived snapshot so the v2 list can - // show it as a settled row without waiting for a manual reload. - if (action === "settle" || action === "unsettle" || action === "archive") { - refreshArchivedThreadsForEnvironment(thread.environmentId); + return false; } + // Archived threads leave the live shell stream, and the v2 list + // renders them from the archived snapshot — keep it fresh for every + // action that changes what that snapshot should contain (delete + // included, or a deleted settled row lingers until some later + // refresh). + refreshArchivedThreadsForEnvironment(thread.environmentId); onCompleted?.(action, thread); + return true; } finally { inFlightThreadKeys.current.delete(key); } @@ -123,7 +125,7 @@ function useThreadActionExecutor( } function useConfirmDeleteThread( - executeAction: (action: ThreadListAction, thread: EnvironmentThreadShell) => Promise, + executeAction: (action: ThreadListAction, thread: EnvironmentThreadShell) => Promise, ) { return useCallback( (thread: EnvironmentThreadShell) => { @@ -159,7 +161,7 @@ function useConfirmDeleteThread( export function useThreadListActions(): { readonly archiveThread: (thread: EnvironmentThreadShell) => void; readonly confirmDeleteThread: (thread: EnvironmentThreadShell) => void; - readonly settleThread: (thread: EnvironmentThreadShell) => void; + readonly settleThread: (thread: EnvironmentThreadShell) => Promise; readonly unsettleThread: (thread: EnvironmentThreadShell) => void; } { const executeAction = useThreadActionExecutor(); @@ -171,9 +173,7 @@ export function useThreadListActions(): { [executeAction], ); const settleThread = useCallback( - (thread: EnvironmentThreadShell) => { - void executeAction("settle", thread); - }, + async (thread: EnvironmentThreadShell) => (await executeAction("settle", thread)) === true, [executeAction], ); const unsettleThread = useCallback( diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index d7dc900c4e2..99667a1b5c6 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -189,24 +189,27 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { [handleArchive, handleDelete, handleSettle, handleUnsettle], ); - // Swipe: the v2 primary action is Settle (cards) / Un-settle (slim) — - // the lifecycle transition IS the organizing gesture of this list. + // Swipe: the v2 primary action is the lifecycle transition. Un-settle only + // exists when there is an archive to undo; an auto-settled slim row + // (inactivity / merged PR, archivedAt null) offers Settle, which archives + // it — the explicit "keep it settled" the row can actually deliver. + const canUnsettle = variant === "slim" && thread.archivedAt !== null; const primaryAction = useMemo( () => - variant === "card" + canUnsettle ? { - accessibilityLabel: `Settle ${thread.title}`, - icon: "checkmark" as const, - label: "Settle", - onPress: handleSettle, - } - : { accessibilityLabel: `Un-settle ${thread.title}`, icon: "arrow.uturn.backward" as const, label: "Un-settle", onPress: handleUnsettle, + } + : { + accessibilityLabel: `Settle ${thread.title}`, + icon: "checkmark" as const, + label: "Settle", + onPress: handleSettle, }, - [handleSettle, handleUnsettle, thread.title, variant], + [canUnsettle, handleSettle, handleUnsettle, thread.title], ); const rowContent = (close: () => void) => @@ -366,7 +369,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { > {(close) => ( diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index e5be113e313..c14a78e8679 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -979,6 +979,14 @@ export default function SidebarV2() { ); return; } + // Un-settling invalidates any optimistic settled hold for the thread. + setSettledHolds((current) => { + const threadKey = scopedThreadKey(threadRef); + if (!current.has(threadKey)) return current; + const next = new Map(current); + next.delete(threadKey); + return next; + }); refreshArchivedThreadsForEnvironment(threadRef.environmentId); })(); }, @@ -1053,6 +1061,15 @@ export default function SidebarV2() { } return; } + // Deleted threads must not survive as settled holds or stale + // archived-snapshot rows. + setSettledHolds((current) => { + if (!current.has(threadKey)) return current; + const next = new Map(current); + next.delete(threadKey); + return next; + }); + refreshArchivedThreadsForEnvironment(thread.environmentId); } removeFromSelection(threadKeys); }, @@ -1079,9 +1096,9 @@ export default function SidebarV2() { } const thread = threadByKeyRef.current.get(threadKey); if (!thread) return; - // Match what the user sees: a row renders slim iff it is in the - // settled partition, so the menu label mirrors that exact state. - const isSettled = settledThreadKeysRef.current.has(threadKey); + // Un-settle appears only when there is an archive to undo; an + // auto-settled (unarchived) slim row gets Settle, which archives it. + const isSettled = settledThreadKeysRef.current.has(threadKey) && thread.archivedAt !== null; const clicked = await settlePromise(() => api.contextMenu.show( [ @@ -1146,7 +1163,17 @@ export default function SidebarV2() { description: error instanceof Error ? error.message : "An error occurred.", }), ); + return; } + // A deleted thread must not survive as a settled hold or a stale + // archived-snapshot row. + setSettledHolds((current) => { + if (!current.has(threadKey)) return current; + const next = new Map(current); + next.delete(threadKey); + return next; + }); + refreshArchivedThreadsForEnvironment(threadRef.environmentId); return; } default: @@ -1445,7 +1472,11 @@ export default function SidebarV2() { key={threadKey} thread={thread} variant={isCard ? "card" : "slim"} - variantAction={isSettledRow ? "unsettle" : "settle"} + // Un-settle only when there is an archive to undo. An + // auto-settled row (inactivity / merged PR, archivedAt + // null) offers Settle: archiving is the explicit "keep it + // settled" the row can actually deliver. + variantAction={isSettledRow && thread.archivedAt !== null ? "unsettle" : "settle"} isActive={routeThreadKey === threadKey} jumpLabel={showJumpHints ? (jumpLabelByKey.get(threadKey) ?? null) : null} currentEnvironmentId={primaryEnvironmentId} diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 1934cf44e17..b5877c0af03 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -391,10 +391,16 @@ export function useThreadActions() { if (thread.worktreePath === null) { return settleResult; } - const threads = readEnvironmentThreadRefs(threadRef.environmentId).flatMap((ref) => { + // Settle = archive drops the thread from the live shell store, so the + // orphan computation must re-include the shell we just settled — the + // live threads are only the OTHER worktree users. + const liveThreads = readEnvironmentThreadRefs(threadRef.environmentId).flatMap((ref) => { const shell = readThreadShell(ref); return shell === null ? [] : [shell]; }); + const threads = liveThreads.some((candidate) => candidate.id === threadRef.threadId) + ? liveThreads + : [...liveThreads, thread]; const orphanedWorktreePath = getOrphanedWorktreePathForThread(threads, threadRef.threadId); const threadProject = readProject({ environmentId: threadRef.environmentId, @@ -425,16 +431,24 @@ export function useThreadActions() { // thread can have woken up or another thread adopted the // worktree since the settle happened. const currentShell = readThreadShell(threadRef); - const currentThreads = readEnvironmentThreadRefs(threadRef.environmentId).flatMap( - (ref) => { - const shell = readThreadShell(ref); - return shell === null ? [] : [shell]; - }, - ); + const currentLiveThreads = readEnvironmentThreadRefs( + threadRef.environmentId, + ).flatMap((ref) => { + const shell = readThreadShell(ref); + return shell === null ? [] : [shell]; + }); + // Archived threads leave the live store entirely, so absence + // IS the settled state; a live shell means it woke back up + // (unarchive puts it back in the stream). + const stillSettled = currentShell === null || currentShell.archivedAt != null; + const currentThreads = currentLiveThreads.some( + (candidate) => candidate.id === threadRef.threadId, + ) + ? currentLiveThreads + : [...currentLiveThreads, thread]; const stillOrphaned = getOrphanedWorktreePathForThread(currentThreads, threadRef.threadId) === orphanedWorktreePath; - const stillSettled = currentShell?.archivedAt != null; if (!stillOrphaned || !stillSettled) { toastManager.add( stackedThreadToast({ From b9cebff07267b0eb788410b109593b63639f4edd Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 16 Jul 2026 00:17:40 -0700 Subject: [PATCH 12/14] Remove the "Thread settled" worktree-removal toast Settling is a high-frequency lifecycle action; announcing it with a notification (plus a disk-cleanup offer) was noise. Settle is silent now. Worktree cleanup still exists on the delete path, which asks explicitly. Co-Authored-By: Claude Fable 5 --- apps/web/src/hooks/useThreadActions.ts | 138 ++----------------------- 1 file changed, 6 insertions(+), 132 deletions(-) diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index b5877c0af03..3a2270c35b5 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -23,7 +23,6 @@ import { readEnvironmentThreadRefs, readProject, readThreadShell } from "../stat import { useTerminalUiStateStore } from "../terminalUiStateStore"; import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; import { - canOfferWorktreeRemoval, formatWorktreePathForDisplay, getOrphanedWorktreePathForThread, } from "../worktreeCleanup"; @@ -376,141 +375,16 @@ export function useThreadActions() { ), ); } - const settleResult = await settleThreadMutation({ + // Settle is a high-frequency lifecycle action and stays silent — no + // toast. (The old "Thread settled" toast offering worktree removal was + // noise; disk cleanup belongs in an explicit surface, not a + // notification.) + return settleThreadMutation({ environmentId: target.environmentId, input: { threadId: target.threadId }, }); - if (settleResult._tag === "Failure" || !resolved) { - return settleResult; - } - - // Settling is the natural moment to reclaim disk: offer (never force) a - // worktree removal when this thread is the only one using it and there is - // no unsaved or unpushed work at stake. - const { thread, threadRef } = resolved; - if (thread.worktreePath === null) { - return settleResult; - } - // Settle = archive drops the thread from the live shell store, so the - // orphan computation must re-include the shell we just settled — the - // live threads are only the OTHER worktree users. - const liveThreads = readEnvironmentThreadRefs(threadRef.environmentId).flatMap((ref) => { - const shell = readThreadShell(ref); - return shell === null ? [] : [shell]; - }); - const threads = liveThreads.some((candidate) => candidate.id === threadRef.threadId) - ? liveThreads - : [...liveThreads, thread]; - const orphanedWorktreePath = getOrphanedWorktreePathForThread(threads, threadRef.threadId); - const threadProject = readProject({ - environmentId: threadRef.environmentId, - projectId: thread.projectId, - }); - if (orphanedWorktreePath === null || threadProject === null) { - return settleResult; - } - const statusResult = await refreshVcsStatus({ - environmentId: threadRef.environmentId, - input: { cwd: orphanedWorktreePath }, - }); - // If status cannot be verified, keep the worktree and skip the prompt. - if (statusResult._tag === "Failure" || !canOfferWorktreeRemoval(statusResult.value)) { - return settleResult; - } - const displayWorktreePath = formatWorktreePathForDisplay(orphanedWorktreePath); - toastManager.add( - stackedThreadToast({ - type: "info", - title: "Thread settled", - description: `Worktree ${displayWorktreePath} is no longer used by any thread.`, - actionProps: { - children: "Remove worktree", - onClick: () => { - void (async () => { - // Re-validate at click time: the toast may be stale — the - // thread can have woken up or another thread adopted the - // worktree since the settle happened. - const currentShell = readThreadShell(threadRef); - const currentLiveThreads = readEnvironmentThreadRefs( - threadRef.environmentId, - ).flatMap((ref) => { - const shell = readThreadShell(ref); - return shell === null ? [] : [shell]; - }); - // Archived threads leave the live store entirely, so absence - // IS the settled state; a live shell means it woke back up - // (unarchive puts it back in the stream). - const stillSettled = currentShell === null || currentShell.archivedAt != null; - const currentThreads = currentLiveThreads.some( - (candidate) => candidate.id === threadRef.threadId, - ) - ? currentLiveThreads - : [...currentLiveThreads, thread]; - const stillOrphaned = - getOrphanedWorktreePathForThread(currentThreads, threadRef.threadId) === - orphanedWorktreePath; - if (!stillOrphaned || !stillSettled) { - toastManager.add( - stackedThreadToast({ - type: "warning", - title: "Worktree kept", - description: "This worktree is in use again and was not removed.", - }), - ); - return; - } - const currentStatusResult = await refreshVcsStatus({ - environmentId: threadRef.environmentId, - input: { cwd: orphanedWorktreePath }, - }); - if ( - currentStatusResult._tag === "Failure" || - !canOfferWorktreeRemoval(currentStatusResult.value) - ) { - toastManager.add( - stackedThreadToast({ - type: "warning", - title: "Worktree kept", - description: - "This worktree has uncommitted or unpushed changes, or its status could not be verified.", - }), - ); - return; - } - // force stays false as an additional guard against changes - // created between the status check and the removal request. - const removeResult = await removeWorktree({ - environmentId: threadRef.environmentId, - input: { - cwd: threadProject.workspaceRoot, - path: orphanedWorktreePath, - force: false, - }, - }); - if (removeResult._tag === "Failure") { - const error = squashAtomCommandFailure(removeResult); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Worktree removal failed", - description: - error instanceof Error ? error.message : "Could not remove the worktree.", - }), - ); - return; - } - await refreshVcsStatus({ - environmentId: threadRef.environmentId, - input: { cwd: threadProject.workspaceRoot }, - }); - })(); - }, - }, - }), - ); - return settleResult; }, - [refreshVcsStatus, removeWorktree, resolveThreadTarget, settleThreadMutation], + [resolveThreadTarget, settleThreadMutation], ); const unsettleThread = useCallback( From 856a097a5e30af9ed01625131f6c7148e20e615d Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 16 Jul 2026 00:18:25 -0700 Subject: [PATCH 13/14] Format import after toast removal Co-Authored-By: Claude Fable 5 --- apps/web/src/hooks/useThreadActions.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 3a2270c35b5..f4d4fa8d363 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -22,10 +22,7 @@ import { readLocalApi } from "../localApi"; import { readEnvironmentThreadRefs, readProject, readThreadShell } from "../state/entities"; import { useTerminalUiStateStore } from "../terminalUiStateStore"; import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; -import { - formatWorktreePathForDisplay, - getOrphanedWorktreePathForThread, -} from "../worktreeCleanup"; +import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; import { stackedThreadToast, toastManager } from "../components/ui/toast"; import { useClientSettings } from "./useSettings"; import { useAtomCommand } from "../state/use-atom-command"; From fc5065c2f16204d92325c1839431b9469743138c Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 16 Jul 2026 00:46:57 -0700 Subject: [PATCH 14/14] Settle advances to the next thread; settled tail pages behind Show more - Settling the active thread on web navigates to the next remaining card (wrapping, never a settled row), or to the new-thread screen when it was the last active one. Background settles never navigate. - The settled tail renders 10 rows, then an explicit Show more reveals 25 at a time (web + mobile). Expansion resets when scope, environment, or search changes. Mobile's builder counts hidden rows instead of building them. Co-Authored-By: Claude Fable 5 --- apps/mobile/src/features/home/HomeScreen.tsx | 44 +++++++++++- .../src/features/threads/threadListV2.test.ts | 39 ++++++++++- .../src/features/threads/threadListV2.ts | 17 ++++- apps/web/src/components/SidebarV2.tsx | 68 ++++++++++++++++++- 4 files changed, 156 insertions(+), 12 deletions(-) diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 34cf2e45668..f7cc02b96df 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -15,11 +15,12 @@ import type { import { useAtomSet, useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/unstable/reactivity"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { ActivityIndicator, Platform, View } from "react-native"; +import { ActivityIndicator, Platform, Pressable, View } from "react-native"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColor } from "../../lib/useThemeColor"; +import { AppText as Text } from "../../components/AppText"; import { EmptyState } from "../../components/EmptyState"; import type { WorkspaceState } from "../../state/workspaceModel"; import type { SavedRemoteConnection } from "../../lib/connection"; @@ -87,6 +88,10 @@ interface HomeScreenProps { /* ─── Layout constants ───────────────────────────────────────────────── */ const ESTIMATED_THREAD_ROW_HEIGHT = 72; +// v2 settled-tail paging: recent history is the common lookup; the deep +// tail stays behind an explicit Show more. +const THREAD_LIST_V2_SETTLED_INITIAL_COUNT = 10; +const THREAD_LIST_V2_SETTLED_PAGE_COUNT = 25; /** * Top spacing between the list and the Android custom header. The Android * header (AndroidHomeHeader) is rendered in-flow above this screen and @@ -386,8 +391,23 @@ export function HomeScreen(props: HomeScreenProps) { }); } }, [archivedSnapshots, settledHolds]); - const threadListV2Items = useMemo(() => { - if (!threadListV2Enabled) return []; + // The settled tail renders in pages; expansion resets when the filter + // context changes so environment/search flips never inherit a deep page. + const [settledVisibleCount, setSettledVisibleCount] = useState( + THREAD_LIST_V2_SETTLED_INITIAL_COUNT, + ); + const settledResetKey = `${props.selectedEnvironmentId ?? "all"}:${props.searchQuery.trim()}`; + const lastSettledResetKeyRef = useRef(settledResetKey); + if (lastSettledResetKeyRef.current !== settledResetKey) { + lastSettledResetKeyRef.current = settledResetKey; + setSettledVisibleCount(THREAD_LIST_V2_SETTLED_INITIAL_COUNT); + } + const showMoreSettled = useCallback( + () => setSettledVisibleCount((count) => count + THREAD_LIST_V2_SETTLED_PAGE_COUNT), + [], + ); + const threadListV2Layout = useMemo(() => { + if (!threadListV2Enabled) return { items: [], hiddenSettledCount: 0 }; const merged = new Map(); for (const { environmentId, snapshot } of archivedSnapshots) { for (const thread of snapshot.threads) { @@ -406,16 +426,19 @@ export function HomeScreen(props: HomeScreenProps) { environmentId: props.selectedEnvironmentId, searchQuery: props.searchQuery, changeRequestStateByKey, + settledLimit: settledVisibleCount, }); }, [ changeRequestStateByKey, settledHolds, + settledVisibleCount, archivedSnapshots, props.searchQuery, props.selectedEnvironmentId, props.threads, threadListV2Enabled, ]); + const threadListV2Items = threadListV2Layout.items; const renderV2Item = useCallback( ({ item }: LegendListRenderItemProps) => ( @@ -670,6 +693,21 @@ export function HomeScreen(props: HomeScreenProps) { estimatedItemSize={ESTIMATED_THREAD_ROW_HEIGHT} extraData={projectByKey} ListHeaderComponent={v2ListHeader} + ListFooterComponent={ + threadListV2Layout.hiddenSettledCount > 0 ? ( + ({ opacity: pressed ? 0.6 : 1 })} + > + + Show more ({threadListV2Layout.hiddenSettledCount} settled hidden) + + + ) : null + } ListEmptyComponent={listEmpty} style={{ flex: 1 }} automaticallyAdjustsScrollIndicatorInsets={Platform.OS === "ios"} diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index ebac77760ab..3a656d0d201 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -76,7 +76,7 @@ describe("sortThreadsForListV2", () => { describe("buildThreadListV2Items", () => { it("partitions archived (settled) threads into a slim tail with one divider", () => { - const items = buildThreadListV2Items({ + const { items } = buildThreadListV2Items({ threads: [ makeThread({ id: ThreadId.make("active"), title: "Active" }), makeThread({ @@ -105,7 +105,7 @@ describe("buildThreadListV2Items", () => { }); it("keeps cards in creation order while settled sorts by recency", () => { - const items = buildThreadListV2Items({ + const { items } = buildThreadListV2Items({ threads: [ makeThread({ id: ThreadId.make("older-created"), @@ -128,7 +128,7 @@ describe("buildThreadListV2Items", () => { }); it("keeps archived threads in the tail and filters by search query", () => { - const items = buildThreadListV2Items({ + const { items } = buildThreadListV2Items({ threads: [ makeThread({ id: ThreadId.make("match"), title: "Fix login bug" }), makeThread({ id: ThreadId.make("miss"), title: "Greeting" }), @@ -149,3 +149,36 @@ describe("buildThreadListV2Items", () => { ]); }); }); + +describe("buildThreadListV2Items settled paging", () => { + it("caps the settled tail at settledLimit and reports the hidden count", () => { + const threads = [ + makeThread({ id: ThreadId.make("active"), title: "Active" }), + ...Array.from({ length: 4 }, (_, index) => + makeThread({ + id: ThreadId.make(`settled-${index}`), + title: `Settled ${index}`, + archivedAt: NOW, + latestUserMessageAt: `2026-06-01T0${index}:00:00.000Z`, + }), + ), + ]; + + const layout = buildThreadListV2Items({ + threads, + environmentId: null, + searchQuery: "", + settledLimit: 2, + now: NOW, + }); + + expect(layout.hiddenSettledCount).toBe(2); + expect(layout.items.filter((item) => item.variant === "slim")).toHaveLength(2); + // Most recent settled first — the hidden ones are the oldest. + expect(layout.items.map((item) => item.thread.id)).toEqual([ + "active", + "settled-3", + "settled-2", + ]); + }); +}); diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 2bd30f43616..8a62da608fe 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -52,6 +52,12 @@ export interface ThreadListV2Item { readonly isLast: boolean; } +export interface ThreadListV2Layout { + readonly items: ThreadListV2Item[]; + /** Settled threads beyond the render limit (behind "Show more"). */ + readonly hiddenSettledCount: number; +} + /** * Partitions visible threads into the active card block (creation order) and * the settled recency tail, matching the web v2 list. `autoSettleAfterDays` @@ -65,9 +71,11 @@ export function buildThreadListV2Items(input: { /** Per-row PR state reported up by visible rows ("env:threadId" keys). */ readonly changeRequestStateByKey?: ReadonlyMap; readonly autoSettleAfterDays?: number; + /** Max settled rows to render; the rest are counted, not built. */ + readonly settledLimit?: number; /** Injectable for tests; defaults to now. */ readonly now?: string; -}): ThreadListV2Item[] { +}): ThreadListV2Layout { const now = input.now ?? new Date().toISOString(); const autoSettleAfterDays = input.autoSettleAfterDays ?? 3; const query = input.searchQuery.trim().toLocaleLowerCase(); @@ -94,12 +102,15 @@ export function buildThreadListV2Items(input: { Date.parse(right.latestUserMessageAt ?? right.updatedAt) - Date.parse(left.latestUserMessageAt ?? left.updatedAt), ); + const settledLimit = input.settledLimit ?? Number.POSITIVE_INFINITY; + const visibleSettled = + orderedSettled.length > settledLimit ? orderedSettled.slice(0, settledLimit) : orderedSettled; const items: ThreadListV2Item[] = []; for (const thread of orderedActive) { items.push({ thread, variant: "card", showSettledDivider: false, isLast: false }); } - for (const [index, thread] of orderedSettled.entries()) { + for (const [index, thread] of visibleSettled.entries()) { items.push({ thread, variant: "slim", @@ -111,5 +122,5 @@ export function buildThreadListV2Items(input: { if (last) { items[items.length - 1] = { ...last, isLast: true }; } - return items; + return { items, hiddenSettledCount: orderedSettled.length - visibleSettled.length }; } diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index c14a78e8679..436b5ab2d38 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -105,6 +105,11 @@ const CARD_EDGE_BY_STATUS: Partial> = { failed: "bg-red-500", }; +// Settled-tail paging: recent history is the common lookup; the deep tail +// stays behind an explicit Show more. +const SETTLED_TAIL_INITIAL_COUNT = 10; +const SETTLED_TAIL_PAGE_COUNT = 25; + const STATUS_WORD_BY_STATUS: Partial< Record > = { @@ -792,9 +797,30 @@ export default function SidebarV2() { }; }, [allThreads, autoSettleAfterDays, changeRequestStateByKey, nowMinute, scopedProject]); + // The settled tail renders in pages: history shouldn't dominate the + // sidebar, and the common lookups are recent. Expansion resets when the + // filter context changes so a scope/search flip never inherits a deep + // page state. + const [settledVisibleCount, setSettledVisibleCount] = useState(SETTLED_TAIL_INITIAL_COUNT); + const settledResetKey = `${projectScopeKey ?? "all"}`; + const lastSettledResetKeyRef = useRef(settledResetKey); + if (lastSettledResetKeyRef.current !== settledResetKey) { + lastSettledResetKeyRef.current = settledResetKey; + setSettledVisibleCount(SETTLED_TAIL_INITIAL_COUNT); + } + const hiddenSettledCount = Math.max(0, settledThreads.length - settledVisibleCount); + const visibleSettledThreads = useMemo( + () => (hiddenSettledCount > 0 ? settledThreads.slice(0, settledVisibleCount) : settledThreads), + [hiddenSettledCount, settledThreads, settledVisibleCount], + ); + const showMoreSettled = useCallback( + () => setSettledVisibleCount((count) => count + SETTLED_TAIL_PAGE_COUNT), + [], + ); + const orderedThreads = useMemo( - () => [...activeThreads, ...settledThreads], - [activeThreads, settledThreads], + () => [...activeThreads, ...visibleSettledThreads], + [activeThreads, visibleSettledThreads], ); const orderedThreadKeys = useMemo( () => @@ -940,6 +966,27 @@ export default function SidebarV2() { }), ); } + // Settling the thread you're looking at moves you forward: the next + // remaining card (never a settled row), or the new-thread screen + // when this was the last active one. Snapshot the target before the + // settle mutates the partition. Background settles never navigate. + let navigateAfterSettle: (() => void) | null = null; + if (routeThreadKey === threadKey) { + const orderedKeys = orderedThreadKeysRef.current; + const settledKeys = settledThreadKeysRef.current; + const currentIndex = orderedKeys.indexOf(threadKey); + const nextCardKey = + currentIndex === -1 + ? null + : ([ + ...orderedKeys.slice(currentIndex + 1), + ...orderedKeys.slice(0, currentIndex), + ].find((key) => !settledKeys.has(key)) ?? null); + const nextThread = nextCardKey ? threadByKeyRef.current.get(nextCardKey) : null; + navigateAfterSettle = nextThread + ? () => navigateToThread(scopeThreadRef(nextThread.environmentId, nextThread.id)) + : () => void router.navigate({ to: "/" }); + } const result = await settleThread(threadRef); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { setSettledHolds((current) => { @@ -960,9 +1007,10 @@ export default function SidebarV2() { // Settle = archive: the shell stream drops the thread, so pull the // archived snapshot immediately to keep it visible as a settled row. refreshArchivedThreadsForEnvironment(threadRef.environmentId); + navigateAfterSettle?.(); })(); }, - [settleThread], + [navigateToThread, routeThreadKey, router, settleThread], ); const attemptUnsettle = useCallback( (threadRef: ScopedThreadRef) => { @@ -1503,6 +1551,20 @@ export default function SidebarV2() { /> ); })} + {hiddenSettledCount > 0 ? ( +
      • + +
      • + ) : null}
      {orderedThreads.length === 0 ? (