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 visible — session.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.
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.
+
+
+
+
+
⌕ Search ⌘K
+
+
+
+
Settled · worktree fix-pr-titles is orphaned
+
+
+
+
+
+
T3Normalize open PR titleswaiting 2m
+
APPROVALfix-pr-titles✳opus-4.8
+
+
+
+
+
+
+
+
T3Sidebar v2 UX Redesign14m 22s
+
WORKINGsidebar-v2-beta✳fable-5
+
+
+
+
+
+
+
FHFix image upload stalls18m
+
main✳sonnet-5⏻ mac-studio
+
+
+
+
+
+
+
T3Review default experience plan9m
+
plan-ux-review✳opus-4.8
+
+
+
+
+
+
+
T3Disable pairing codes by default+214−3812m
+
disable-pairing-codes◈gpt-5.6-sol
+
+
+
+
+
+
+
+
T3iOS Simulator Remote Setup34m
+
FAILEDmain◈gpt-5.6-sol⏻ mbp-16
+
+
+
+
+
T3Default new worktrees from origin main41m
+
T3Fix performance bottlenecks#3825 merged1d
+
T3Stress test long thread rendering#3822 open2d
+
FHCLI File Hosting Options2d
+
+
+
+
+
+
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.
+
+
+
+
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.
+
+
+
+
+
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.
contracts/orchestration.ts — settledOverride + 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).
“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
+
+
Question
Current thinking
+
Server-side activity observation
If 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 projection
settledOverride/settledAtmust be in the thread shell stream — the sidebar never loads details. Verify first thing in P1.
+
Diff stats on cards
Requires checkpoint data in the shell; if absent, defer diff stats rather than loading details per row.
+
Project grouping in v2
Not 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/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/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..d4ecfba6a40
--- /dev/null
+++ b/apps/server/src/orchestration/decider.settled.test.ts
@@ -0,0 +1,322 @@
+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 9c95b42269d..1f9cc311ce5 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: 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,
@@ -464,7 +519,24 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
createdAt: command.createdAt,
},
};
- return [userMessageEvent, turnStartRequestedEvent];
+ 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];
}
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 !== "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": {
@@ -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 !== "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
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..9ff0c90d293 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)
@@ -82,7 +90,14 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
@@ -90,7 +105,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
storageKey: THREAD_SIDEBAR_WIDTH_STORAGE_KEY,
}}
>
-
+ {useSidebarV2 ? : }
{children}
diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx
index 958cb3743c7..2ccaf4144e9 100644
--- a/apps/web/src/components/BranchToolbar.tsx
+++ b/apps/web/src/components/BranchToolbar.tsx
@@ -24,6 +24,7 @@ import {
import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector";
import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector";
import { BranchToolbarEnvModeSelector } from "./BranchToolbarEnvModeSelector";
+import { ProjectFavicon } from "./ProjectFavicon";
import { Button } from "./ui/button";
import {
Menu,
@@ -240,6 +241,21 @@ export const BranchToolbar = memo(function BranchToolbar({
return (
+ {/* The submit target leads the run-context strip: this is the one
+ surface where "which project am I about to send this to" must be
+ answered before the first keystroke, especially on drafts.
+ Mirrors the sidebar cards' title bar — favicon + mono name. */}
+
+
+
+ {activeProject.title}
+
+
+
{isMobile ? (
= {}): 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/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index f5ea5bb1eba..a3f33def446 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -5040,6 +5040,7 @@ function ChatViewContent(props: ChatViewProps) {
{...(routeKind === "draft" && draftId ? { draftId } : {})}
activeThreadTitle={activeThread.title}
activeProjectName={activeProject?.title}
+ activeProjectCwd={activeProject?.workspaceRoot ?? null}
openInCwd={gitCwd}
activeProjectScripts={activeProject?.scripts}
preferredScriptId={
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..9ed6f8e748b 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,84 @@ 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 while the session status is error", () => {
+ expect(
+ resolveSidebarV2Status({
+ hasPendingApprovals: false,
+ session: { ...session, status: "error" as const, lastError: "boom" },
+ }),
+ ).toBe("failed");
+ expect(
+ resolveSidebarV2Status({
+ hasPendingApprovals: false,
+ session: { ...session, status: "stopped" as const, lastError: "persisted" },
+ }),
+ ).toBe("ready");
+ expect(
+ resolveSidebarV2Status({
+ hasPendingApprovals: false,
+ session: { ...session, status: "ready" as const, lastError: "persisted" },
+ }),
+ ).toBe("ready");
+ });
+
+ it("defaults to ready with no session", () => {
+ expect(resolveSidebarV2Status({ hasPendingApprovals: false, session: null })).toBe("ready");
+ });
+});
+
+describe("sortThreadsForSidebarV2", () => {
+ const sortable = (input: { id: string; createdAt: string }) => ({
+ id: input.id,
+ createdAt: input.createdAt,
+ });
+
+ it("orders by creation time, newest first, ignoring activity", () => {
+ const sorted = sortThreadsForSidebarV2([
+ sortable({ id: "oldest", createdAt: "2026-03-09T08:00:00.000Z" }),
+ sortable({ id: "newest", createdAt: "2026-03-09T12:00:00.000Z" }),
+ sortable({ id: "middle", createdAt: "2026-03-09T10:00:00.000Z" }),
+ ]);
+
+ expect(sorted.map((thread) => thread.id)).toEqual(["newest", "middle", "oldest"]);
+ });
+
+ it("breaks creation-time ties by id so the order is stable", () => {
+ const sorted = sortThreadsForSidebarV2([
+ sortable({ id: "b", createdAt: "2026-03-09T10:00:00.000Z" }),
+ sortable({ id: "a", createdAt: "2026-03-09T10:00:00.000Z" }),
+ ]);
+
+ expect(sorted.map((thread) => thread.id)).toEqual(["a", "b"]);
+ });
+});
+
describe("resolveThreadStatusPill", () => {
const baseThread = {
hasActionableProposedPlan: false,
@@ -830,6 +910,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..d240b57d7d5 100644
--- a/apps/web/src/components/Sidebar.logic.ts
+++ b/apps/web/src/components/Sidebar.logic.ts
@@ -357,6 +357,41 @@ 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?.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. Status (including pending
+// approval) is carried by each card's edge strip, not by position.
+export function sortThreadsForSidebarV2<
+ T extends { readonly id: string; readonly createdAt: string },
+>(threads: readonly T[]): T[] {
+ return [...threads].toSorted(
+ (left, right) =>
+ Date.parse(right.createdAt) - Date.parse(left.createdAt) || 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..b5bab645768
--- /dev/null
+++ b/apps/web/src/components/SidebarV2.tsx
@@ -0,0 +1,1500 @@
+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 KeyboardEvent as ReactKeyboardEvent,
+ 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 { useHandleNewThread } from "../hooks/useHandleNewThread";
+import { useOpenAddProjectCommandPalette } from "../commandPaletteContext";
+import { onOpenNewThreadPicker } from "../newThreadPickerBus";
+import { Dialog, DialogHeader, DialogPopup, DialogTitle } from "./ui/dialog";
+import {
+ resolveThreadActionProjectRef,
+ startNewThreadFromContext,
+ startNewThreadInProjectFromContext,
+} from "../lib/chatThreadActions";
+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 { 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";
+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.
+// Cards are square, hard-edged blocks: a solid full-saturation edge strip
+// carries the state color; the border stays neutral and high-contrast.
+const CARD_EDGE_BY_STATUS: Partial> = {
+ approval: "bg-amber-500 dark:bg-amber-400",
+ working: "bg-sky-500 animate-status-pulse 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" },
+};
+
+// 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") {
+ // 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);
+}
+
+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 settled row after a group's card
+ // block, so active work and the history tail read as separate zones
+ // inside each project section.
+ showQuietDivider?: boolean;
+ isActive: boolean;
+ jumpLabel: string | null;
+ currentEnvironmentId: string | null;
+ environmentLabel: string | null;
+ projectCwd: string | null;
+ projectTitle: 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,
+ } = 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 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();
+ 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 text-left",
+ props.isActive
+ ? "bg-foreground/10 dark:bg-white/[0.12]"
+ : isSelected
+ ? "bg-primary/15 dark:bg-primary/20"
+ : "hover:bg-foreground/5 dark:hover:bg-white/[0.06]",
+ );
+
+ 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 border border-border bg-background px-1 text-[13px] text-foreground outline-none focus:border-foreground"
+ />
+ ) : (
+
+ {thread.title}
+
+ );
+
+ const prBadge =
+ prStatus && pr ? (
+
+ ) : null;
+
+ if (variant === "slim") {
+ return (
+