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

Sidebar v2 — beta plan

+

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

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

01What we're building

+

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

+
+
+

Changes

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

Deliberately not

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

02The mock

+

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

+ +
+ + +
+

Card anatomy (active threads, ~52px)

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

Four states, three colors

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

Harness · model · machine

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

Slim row (settled, ~28px)

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

Sort

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

03The settled model

+

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

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

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

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

04Swap boundary

+

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

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

05Phases

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

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

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

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

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

06Open questions

+ + + + + + +
QuestionCurrent thinking
Server-side activity observationIf decider activity paths are too scattered for unsettle-on-activity, fall back to client-side: compute with activity timestamps, override only stores manual state (activity newer than settledAt wins). Decide in P1.
Shell projectionsettledOverride/settledAt must be in the thread shell stream — the sidebar never loads details. Verify first thing in P1.
Diff stats on cardsRequires checkpoint data in the shell; if absent, defer diff stats rather than loading details per row.
Project grouping in v2Not planned; v1 stays available for users who want groups. Revisit only if beta feedback demands it.
+ +
T3 Code · Sidebar v2 beta · plan doc pair: .plans/21-sidebar-v2-beta.md (implementation detail) + this page (design + rationale). Mock data mirrors the original concepts page (from live state.sqlite, Jul 13).
+
+ + + + diff --git a/.plans/21-sidebar-v2-beta.md b/.plans/21-sidebar-v2-beta.md new file mode 100644 index 00000000000..8cda132e80a --- /dev/null +++ b/.plans/21-sidebar-v2-beta.md @@ -0,0 +1,285 @@ +# Sidebar v2 (beta): flat adaptive-density list with settled threads + +Status: planned +Mocks: https://hsyscdqldmk5.postplan.dev/ (concept 4 base + concept 1 card layout + concept 3 needs-you pinning) + +## Summary + +A new thread sidebar behind a client-settings beta toggle. Core changes vs. the +current sidebar: + +- **Flat recency list.** Project group headers are gone; project identity moves + onto the row as the existing `ProjectFavicon` (environmentId + cwd). This + matches the session lists users know from Claude Code, Codex, and Cursor. +- **Adaptive density via a "settled" lifecycle state.** Active threads render + as two-line cards: favicon + title + time, then a structured meta line + (status word · branch · harness glyph + model · machine). Settled threads + collapse to slim one-liners, roughly today's row height. No generated or + free-text summaries anywhere — every field on a card is data the shell + already carries. +- **Settled is an explicit state**, not a derived one: users settle threads + manually, or threads auto-settle (PR merged/closed, inactivity). Any real + activity auto-unsettles. Settled is a lifecycle stage between active and + archived — it stays in the list, just quiet. +- **Four visual states, three colors.** Needs approval (amber, pinned above + the recency flow with "waiting Xm"), Working (sky, pulsing, elapsed timer), + **Ready** (uncolored and unlabeled — the normal resting state: the agent + stopped and is waiting on you, whether it finished, asked a question, or + proposed a plan; bold title until visited is the only signal), Failed (red, + `session.lastError` — a dead session shows nothing today). Today's Awaiting + Input / Plan Ready / Completed-unseen pills all collapse into Ready: they + differ in detail, not in what the user should do (look at the thread). + Color is reserved for "act now" (amber), "in motion" (sky), "broken" (red). +- **Harness / model / machine as quiet metadata.** A tinted glyph before the + model name distinguishes Claude Code / Codex / other; a machine label + renders only when the thread lives on a different computer than the one + you're looking at. Display-only lookups from shell data, no new plumbing. +- **Settled feeds cleanup.** Settling a thread offers/permits worktree cleanup; + long-settled threads are candidates for future auto-archive. + +The v2 component is a sibling of the current sidebar, swapped at a single mount +point. The current sidebar is untouched except for the swap seam. + +## Rationale + +Full design discussion lives in the session that produced the mocks; the short +version: + +- We aren't in a position to teach users a new interaction model. Every v2 + mechanic maps to an existing habit: flat recency list (Claude Code / Codex / + Cursor), settle (Gmail archive / GitHub notifications "Done"), unread-bold + (email). Zero new gestures. +- "Settled" makes the adaptive-density row-height rule *stable*: height changes + only at real lifecycle transitions, never from ambient re-rendering. This + kills concept 4's "jumpy list" risk. +- Viewing a thread does **not** settle it. Visiting clears unseen (existing + `lastVisitedAt` mechanics); settling asserts "this work is concluded." If + viewing settled, auto-unsettle would fight the user and the state would stop + meaning anything. +- Settling is optional by design. The auto rules are the hedge against + inbox-zero fatigue: a user who never touches the affordance still gets a + naturally tidy list. Beta telemetry question: what fraction of settles are + manual vs. auto? + +## The settled model + +``` +Active ──(user settles | PR merged/closed | inactivity ≥ threshold)──▶ Settled +Settled ──(new user message | session starts | approval requested | + PR reopened | user un-settles)──▶ Active +Settled ──(existing archive flow, or future auto-archive)──▶ Archived +``` + +Settled is a **computed property with one stored override**: + +``` +effectiveSettled(thread) = + override === "settled" → true (manual settle) + override === "active" → false (manual keep-active) + pr.state ∈ {merged, closed} → true (auto) + lastActivityAt < now − inactivityThreshold → true (auto) + otherwise → false +``` + +- The stored field is a tri-state: `settledOverride: "settled" | "active" | null` + plus `settledAt: IsoDateTime | null` (set when override becomes "settled", + used for display and future auto-archive aging). +- The auto cases need no background job and no events — they fall out of data + the sidebar already streams (`vcs.status` change request state, + `latestUserMessageAt` / turn timestamps). Auto-unsettle for the time-based + case is free: activity moves the timestamp, the predicate flips. +- **Activity clears the override.** Any thread activity event (new user + message, session start, approval request) resets `settledOverride` to null + server-side, so a manually settled thread that wakes up becomes active again + and later auto-rules apply fresh. Likewise, manually un-settling a merged-PR + thread sets `settledOverride: "active"`, which beats the PR auto-rule. +- Inactivity threshold: default 3 days, client setting, `null` = never + auto-settle by time. + +Server-side storage (next to `archivedAt` on the thread) rather than +client-local: settled must sync across desktop/remote environments, and the +worktree-cleanup hook needs the server to know. This mirrors the +`archivedAt` / `thread.archived` / `thread.unarchived` pattern exactly. + +Consequences of being settled: + +- Row collapses to the slim one-liner. +- Excluded from status rollups (any badge counts, aggregate status dots). +- Eligible for worktree cleanup prompting (see below). +- Future: settled ≥ 30 days → auto-archive candidate (not in this phase). + +## Swap strategy: component swap at the mount point + +Decision: **swap the whole sidebar component**, not internals. + +The current `Sidebar.tsx` (~3750 lines) takes no props — it reads everything +from hooks/atoms/stores — and `AppSidebarLayout.tsx:93` is its single mount +point. The truly shared behavior (thread-jump keybindings, selection store, +uiStateStore, `useThreadActions`, context-menu via `readLocalApi()`) already +lives *outside* the component in hooks and stores, so a sibling component +inherits all of it by calling the same hooks. What lives inside Sidebar.tsx +(project grouping, drag-and-drop, show-more, per-project collapse) is exactly +the stuff v2 deletes — threading a variant flag through it would mean touching +hundreds of conditional branches for no benefit. + +```tsx +// AppSidebarLayout.tsx — the entire seam +const sidebarV2 = useClientSettings((s) => s.sidebarV2Enabled); +... +{sidebarV2 ? : } +``` + +Rules for the seam: + +- `SidebarV2.tsx` imports freely from `Sidebar.logic.ts`, + `ThreadStatusIndicators.tsx`, `useThreadActions`, `threadSelectionStore`, + `uiStateStore` — shared logic stays shared. +- `SidebarV2.tsx` must not import from `Sidebar.tsx`, and vice versa. Anything + both need moves into `Sidebar.logic.ts` (or a new shared module) first. +- The settled *data model* (contracts, server, reducer) is flag-independent — + it ships dark and is simply unused by the v1 component. Only the *UI* is + gated. This keeps the toggle a pure view swap: flipping it never migrates + data, so switching back and forth is always safe. +- v1 remains the default. Deleting v1 is a separate future decision once beta + feedback lands. + +## Implementation phases + +### Phase 1 — Settled data model (ships dark, no UI) + +Mirror the archived pattern end-to-end: + +1. `packages/contracts/src/settings.ts`: add to `ClientSettingsSchema`: + - `sidebarV2Enabled: boolean` (default false) + - `sidebarAutoSettleAfterDays: number | null` (default 3) +2. `packages/contracts/src/orchestration.ts`: + - Thread shell: `settledOverride: "settled" | "active" | null`, + `settledAt: IsoDateTime | null` (both with decoding defaults of null — + old persisted threads decode unchanged). + - Events: `thread.settled` (threadId, settledAt, updatedAt), + `thread.unsettled` (threadId, updatedAt). Payload schemas alongside + `ThreadArchivedPayload` / `ThreadUnarchivedPayload`. + - Commands: `thread.settle`, `thread.unsettle`. +3. `apps/server/src/orchestration/decider.ts`: handle both commands, mirroring + archive/unarchive. Invariants in `commandInvariants.ts` + (`requireThreadNotArchived` applies; settle of an already-settled thread is + a no-op rather than an error — idempotent for bulk operations). + In the decider paths that record thread activity (user message appended, + session started, approval requested): if `settledOverride !== null`, also + emit `thread.unsettled` to clear it. +4. `packages/client-runtime/src/state/threadReducer.ts`: reduce both events + (`threadReducer.ts:87-101` is the archive template). Tests alongside the + archive reducer tests. +5. `packages/client-runtime` (new `threadSettled.ts` or in `threadSort.ts`): + pure `effectiveSettled(shell, { now, autoSettleAfterDays })` implementing + the predicate above, plus `lastActivityAt(shell)` (max of + latestUserMessageAt, latest turn completion, session start). Unit-test the + truth table: each override state × PR state × inactivity. + +Verification: `bun run typecheck`, reducer + predicate unit tests. No visible +change anywhere. + +### Phase 2 — SidebarV2 component + beta toggle + +1. Settings UI: "Beta features" section (new panel in + `apps/web/src/components/settings/SettingsPanels.tsx` + route, matching the + existing settings-page pattern) with the v2 toggle and, indented under it, + the auto-settle threshold control. +2. Swap seam in `AppSidebarLayout.tsx` as above. +3. `apps/web/src/components/SidebarV2.tsx`, reusing shared modules. Structure: + - **One flat virtualized list** of all non-archived threads across + projects. Sort key: (needs-approval first, by wait time) → then recency + (`latestUserMessageAt`, reusing `threadSort.ts`). No show-more, no + project collapse, no dnd. + - **Row variants** (single `SidebarV2Row` with a `variant` prop, both + variants rendered from the same shell data): + - `card` (~52px, threads where `effectiveSettled` is false): line 1 + `ProjectFavicon` + title (+ diff stats when present) + time ("waiting + Xm" in amber when approval-blocked, live elapsed for working); line 2 + structured meta — status word (only for the colored states) · branch · + harness glyph + model · machine (only when the thread's environment is + not the current machine). Left rail color only for approval (amber), + working (sky, pulsing), failed (red) — Ready rows carry no color or + label, just a bold title while unread. No free-text/generated content. + - `slim` (~28px, settled): `ProjectFavicon` · title · PR badge when + notable · time. Visually close to today's v1 row. + - **Status derivation** consolidates `Sidebar.logic.ts`'s pill logic into + four visual states: Needs Approval, Working (incl. connecting), Ready + (today's Awaiting Input + Plan Ready + Completed-unseen — same user + action, different payload), and new Failed (`session.lastError` on a + non-running session). Keep it in `Sidebar.logic.ts` so v1 can adopt + Failed later too. + - **Unread**: bold title until visited, from existing + `uiStateStore.threadLastVisitedAtById` — same mechanics as today's + Completed-unseen pill. + - **Row height changes only on lifecycle transitions** (settle/unsettle), + animated with the existing auto-animate pattern. Streaming/status + updates within a variant never change height. + - Preserved v1 behaviors, via the shared hooks: click-to-open, multi-select + (threadSelectionStore), context menu (add Settle/Un-settle entries), + inline rename, thread-jump shortcut labels, archive affordance, PR icon + link, port/terminal/remote indicators (moved into line 3 / slim row + trailing icons). +4. Settle affordances: + - Hover action on the row (next to today's archive hover button) + + context-menu entry + bulk via multi-select. + - `useThreadActions`: add `settleThread` / `unsettleThread` mirroring + `archiveThread` (`useThreadActions.ts:91-134`), minus the navigation + dance — settling never navigates away. + - No keyboard shortcut in the first cut; add via keybindings once the + affordance proves out. + +Verification: typecheck/lint; toggle on → v2 renders, toggle off → v1 +identical to before; settle/unsettle round-trips including across a +desktop+remote pair; kill a session mid-run → Failed card appears. + +### Phase 3 — Worktree cleanup hook + +Settling is the natural moment to reclaim disk: + +1. On **manual settle** of a thread whose worktree is orphaned + (`getOrphanedWorktreePathForThread`, `worktreeCleanup.ts:11-33`): show a + non-blocking inline prompt/toast — "Worktree kept · Remove?" — that calls + `vcsEnvironment.removeWorktree`. Never remove without an explicit click; + never block the settle on the answer. Skip the prompt entirely when the + worktree has uncommitted changes or unpushed commits (check via the + existing vcs status the sidebar already has). +2. **Auto-settle does not touch worktrees** in this phase. A settings-page + "Storage" affordance listing orphaned worktrees of settled threads (with + sizes, bulk-remove) is the follow-up; tracked but not in scope. + +### Phase 4 — Beta telemetry & exit criteria + +Instrument (whatever the existing analytics path is — if none, a lightweight +local counter surfaced in diagnostics is enough for the beta): + +- settle events by source: manual / auto-PR / auto-inactivity / bulk +- un-settle events: manual vs. activity-driven (activity-driven un-settles of + *manual* settles = the model fighting users) +- toggle-off rate after trying v2 + +Decision inputs for exiting beta: if manual settling is ~0%, shrink the +affordance and lean on auto rules; if activity-unsettle-of-manual-settle is +high, the inactivity threshold or unsettle triggers need retuning. + +## Explicitly out of scope + +- Inline approve/reject in the sidebar (concept 3) — needs a safety story. +- Message snippets in rows (concept 2) — streaming churn unsolved. +- Ops-grid density mode (concept 5). +- Auto-archive of long-settled threads. +- Removing v1 / making v2 the default. +- Project grouping inside v2 (v1 remains available for users who want groups). + +## Open questions + +- Where thread "activity" is centrally observable server-side for the + unsettle-on-activity rule — if the decider paths are too scattered, an + acceptable fallback is client-side: compute `effectiveSettled` with the + activity timestamps and only use the override for manual state (activity + newer than `settledAt` wins). Decide during Phase 1 once in the decider. +- Whether `settledAt` belongs in the thread *shell* stream (sidebar reads + shells only) — it must, or v2 can't sort/collapse without detail loads. + Verify shell projection includes it. +- Diff stats on cards require checkpoint data in the shell; if absent, defer + diff stats rather than loading details for every row. diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 405103450e8..7d44d7c9969 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -146,6 +146,8 @@ describe("OrchestrationEngine", () => { createdAt: "2026-03-03T00:00:02.000Z", updatedAt: "2026-03-03T00:00:03.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, messages: [], proposedPlans: [], diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index f12df850941..3ceae0ea43b 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -607,6 +607,8 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti createdAt: event.payload.createdAt, updatedAt: event.payload.updatedAt, archivedAt: null, + settledOverride: null, + settledAt: null, latestUserMessageAt: null, pendingApprovalCount: 0, pendingUserInputCount: 0, @@ -645,6 +647,38 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti return; } + case "thread.settled": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + settledOverride: "settled", + settledAt: event.payload.settledAt, + updatedAt: event.payload.updatedAt, + }); + return; + } + + case "thread.unsettled": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + settledOverride: event.payload.reason === "user" ? "active" : null, + settledAt: null, + updatedAt: event.payload.updatedAt, + }); + return; + } + case "thread.meta-updated": { const existingRow = yield* projectionThreadRepository.getById({ threadId: event.payload.threadId, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 9a136b06872..220665c557c 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -308,6 +308,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { createdAt: "2026-02-24T00:00:02.000Z", updatedAt: "2026-02-24T00:00:03.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, messages: [ { @@ -418,6 +420,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { createdAt: "2026-02-24T00:00:02.000Z", updatedAt: "2026-02-24T00:00:03.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, session: { threadId: ThreadId.make("thread-1"), status: "running", diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 32210436e67..155e9ab0013 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -334,6 +334,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", + settled_override AS "settledOverride", + settled_at AS "settledAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -362,6 +364,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", + settled_override AS "settledOverride", + settled_at AS "settledAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -392,6 +396,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", + settled_override AS "settledOverride", + settled_at AS "settledAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -754,6 +760,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", + settled_override AS "settledOverride", + settled_at AS "settledAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -1186,6 +1194,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { createdAt: row.createdAt, updatedAt: row.updatedAt, archivedAt: row.archivedAt, + settledOverride: row.settledOverride, + settledAt: row.settledAt, deletedAt: row.deletedAt, messages: messagesByThread.get(row.threadId) ?? [], proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], @@ -1384,6 +1394,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { createdAt: row.createdAt, updatedAt: row.updatedAt, archivedAt: row.archivedAt, + settledOverride: row.settledOverride, + settledAt: row.settledAt, deletedAt: row.deletedAt, messages: [], proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], @@ -1513,6 +1525,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { createdAt: row.createdAt, updatedAt: row.updatedAt, archivedAt: row.archivedAt, + settledOverride: row.settledOverride, + settledAt: row.settledAt, session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, hasPendingApprovals: row.pendingApprovalCount > 0, @@ -1647,6 +1661,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { createdAt: row.createdAt, updatedAt: row.updatedAt, archivedAt: row.archivedAt, + settledOverride: row.settledOverride, + settledAt: row.settledAt, session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, hasPendingApprovals: row.pendingApprovalCount > 0, @@ -1887,6 +1903,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { createdAt: threadRow.value.createdAt, updatedAt: threadRow.value.updatedAt, archivedAt: threadRow.value.archivedAt, + settledOverride: threadRow.value.settledOverride, + settledAt: threadRow.value.settledAt, session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null, latestUserMessageAt: threadRow.value.latestUserMessageAt, hasPendingApprovals: threadRow.value.pendingApprovalCount > 0, @@ -1981,6 +1999,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { createdAt: threadRow.value.createdAt, updatedAt: threadRow.value.updatedAt, archivedAt: threadRow.value.archivedAt, + settledOverride: threadRow.value.settledOverride, + settledAt: threadRow.value.settledAt, deletedAt: null, messages: messageRows.map((row) => { const message = { diff --git a/apps/server/src/orchestration/Schemas.ts b/apps/server/src/orchestration/Schemas.ts index f7ebf693440..0d0d7bdd5e4 100644 --- a/apps/server/src/orchestration/Schemas.ts +++ b/apps/server/src/orchestration/Schemas.ts @@ -4,11 +4,13 @@ import { ProjectDeletedPayload as ContractsProjectDeletedPayloadSchema, ThreadCreatedPayload as ContractsThreadCreatedPayloadSchema, ThreadArchivedPayload as ContractsThreadArchivedPayloadSchema, + ThreadSettledPayload as ContractsThreadSettledPayloadSchema, ThreadMetaUpdatedPayload as ContractsThreadMetaUpdatedPayloadSchema, ThreadRuntimeModeSetPayload as ContractsThreadRuntimeModeSetPayloadSchema, ThreadInteractionModeSetPayload as ContractsThreadInteractionModeSetPayloadSchema, ThreadDeletedPayload as ContractsThreadDeletedPayloadSchema, ThreadUnarchivedPayload as ContractsThreadUnarchivedPayloadSchema, + ThreadUnsettledPayload as ContractsThreadUnsettledPayloadSchema, ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema, ThreadProposedPlanUpsertedPayload as ContractsThreadProposedPlanUpsertedPayloadSchema, ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema, @@ -29,11 +31,13 @@ export const ProjectDeletedPayload = ContractsProjectDeletedPayloadSchema; export const ThreadCreatedPayload = ContractsThreadCreatedPayloadSchema; export const ThreadArchivedPayload = ContractsThreadArchivedPayloadSchema; +export const ThreadSettledPayload = ContractsThreadSettledPayloadSchema; export const ThreadMetaUpdatedPayload = ContractsThreadMetaUpdatedPayloadSchema; export const ThreadRuntimeModeSetPayload = ContractsThreadRuntimeModeSetPayloadSchema; export const ThreadInteractionModeSetPayload = ContractsThreadInteractionModeSetPayloadSchema; export const ThreadDeletedPayload = ContractsThreadDeletedPayloadSchema; export const ThreadUnarchivedPayload = ContractsThreadUnarchivedPayloadSchema; +export const ThreadUnsettledPayload = ContractsThreadUnsettledPayloadSchema; export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema; export const ThreadProposedPlanUpsertedPayload = ContractsThreadProposedPlanUpsertedPayloadSchema; diff --git a/apps/server/src/orchestration/commandInvariants.test.ts b/apps/server/src/orchestration/commandInvariants.test.ts index 9c6c8bd2a18..9531cd5c3af 100644 --- a/apps/server/src/orchestration/commandInvariants.test.ts +++ b/apps/server/src/orchestration/commandInvariants.test.ts @@ -68,6 +68,8 @@ const readModel: OrchestrationReadModel = { createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, latestTurn: null, messages: [], session: null, @@ -91,6 +93,8 @@ const readModel: OrchestrationReadModel = { createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, latestTurn: null, messages: [], session: null, diff --git a/apps/server/src/orchestration/decider.settled.test.ts b/apps/server/src/orchestration/decider.settled.test.ts new file mode 100644 index 00000000000..dca1100e286 --- /dev/null +++ b/apps/server/src/orchestration/decider.settled.test.ts @@ -0,0 +1,276 @@ +import { + CommandId, + EventId, + MessageId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationReadModel, + type OrchestrationSession, + type OrchestrationThread, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { decideOrchestrationCommand } from "./decider.ts"; + +const NOW = "2026-01-01T00:00:00.000Z"; +const SETTLED_AT = "2025-12-30T00:00:00.000Z"; + +function makeReadModel( + settledOverride: OrchestrationThread["settledOverride"], + archivedAt: string | null = null, +): OrchestrationReadModel { + return { + snapshotSequence: 0, + projects: [], + threads: [ + { + id: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: NOW, + updatedAt: NOW, + archivedAt, + settledOverride, + settledAt: settledOverride === "settled" ? SETTLED_AT : null, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, + }, + ], + updatedAt: NOW, + }; +} + +function makeSession(status: OrchestrationSession["status"]): OrchestrationSession { + return { + threadId: ThreadId.make("thread-1"), + status, + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: NOW, + }; +} + +it.layer(NodeServices.layer)("settled thread decider", (it) => { + it.effect("settles active threads and re-emits idempotently for settled ones", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-settle"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(null), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events).toHaveLength(1); + expect(events[0]?.type).toBe("thread.settled"); + if (events[0]?.type === "thread.settled") { + expect(events[0].payload.settledAt).toBe(events[0].payload.updatedAt); + } + + // Already settled: the engine rejects zero-event commands, so idempotency + // is by re-emission — preserving the original settledAt. + const reEmit = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-settle-again"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel("settled"), + }); + const reEmitEvents = Array.isArray(reEmit) ? reEmit : [reEmit]; + expect(reEmitEvents).toHaveLength(1); + expect(reEmitEvents[0]?.type).toBe("thread.settled"); + if (reEmitEvents[0]?.type === "thread.settled") { + expect(reEmitEvents[0].payload.settledAt).toBe(SETTLED_AT); + } + }), + ); + + it.effect("rejects settling and unsettling archived threads", () => + Effect.gen(function* () { + const settleError = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-settle-archived"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(null, NOW), + }).pipe(Effect.flip); + expect(settleError._tag).toBe("OrchestrationCommandInvariantError"); + + const unsettleError = yield* decideOrchestrationCommand({ + command: { + type: "thread.unsettle", + commandId: CommandId.make("cmd-unsettle-archived"), + threadId: ThreadId.make("thread-1"), + reason: "user", + }, + readModel: makeReadModel("settled", NOW), + }).pipe(Effect.flip); + expect(unsettleError._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); + + it.effect("maps unsettle reasons to overrides and re-emits idempotently", () => + Effect.gen(function* () { + const userEvent = yield* decideOrchestrationCommand({ + command: { + type: "thread.unsettle", + commandId: CommandId.make("cmd-unsettle-user"), + threadId: ThreadId.make("thread-1"), + reason: "user", + }, + readModel: makeReadModel("settled"), + }); + const userEvents = Array.isArray(userEvent) ? userEvent : [userEvent]; + expect(userEvents).toHaveLength(1); + expect(userEvents[0]?.type).toBe("thread.unsettled"); + if (userEvents[0]?.type === "thread.unsettled") { + expect(userEvents[0].payload.reason).toBe("user"); + } + + // Re-dispatching against the already-reached state re-emits rather than + // producing zero events (the engine rejects empty commands). + const userAgain = yield* decideOrchestrationCommand({ + command: { + type: "thread.unsettle", + commandId: CommandId.make("cmd-unsettle-user-again"), + threadId: ThreadId.make("thread-1"), + reason: "user", + }, + readModel: makeReadModel("active"), + }); + const userAgainEvents = Array.isArray(userAgain) ? userAgain : [userAgain]; + expect(userAgainEvents).toHaveLength(1); + expect(userAgainEvents[0]?.type).toBe("thread.unsettled"); + }), + ); + + it.effect("prepends activity unsets for turn starts and live session updates", () => + Effect.gen(function* () { + const turnResult = yield* decideOrchestrationCommand({ + command: { + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: MessageId.make("message-1"), + role: "user", + text: "Continue", + attachments: [], + }, + runtimeMode: "full-access", + interactionMode: "default", + createdAt: NOW, + }, + readModel: makeReadModel("settled"), + }); + const turnEvents = Array.isArray(turnResult) ? turnResult : [turnResult]; + expect(turnEvents.map((event) => event.type)).toEqual([ + "thread.unsettled", + "thread.message-sent", + "thread.turn-start-requested", + ]); + + const sessionResult = yield* decideOrchestrationCommand({ + command: { + type: "thread.session.set", + commandId: CommandId.make("cmd-session-set"), + threadId: ThreadId.make("thread-1"), + session: makeSession("running"), + createdAt: NOW, + }, + readModel: makeReadModel("active"), + }); + const sessionEvents = Array.isArray(sessionResult) ? sessionResult : [sessionResult]; + expect(sessionEvents.map((event) => event.type)).toEqual([ + "thread.unsettled", + "thread.session-set", + ]); + }), + ); + + it.effect("does not unsettle for session stop/error status writes", () => + Effect.gen(function* () { + for (const status of ["stopped", "error", "ready", "idle"] as const) { + const result = yield* decideOrchestrationCommand({ + command: { + type: "thread.session.set", + commandId: CommandId.make(`cmd-session-${status}`), + threadId: ThreadId.make("thread-1"), + session: makeSession(status), + createdAt: NOW, + }, + readModel: makeReadModel("settled"), + }); + const events = Array.isArray(result) ? result : [result]; + expect(events.map((event) => event.type)).toEqual(["thread.session-set"]); + } + }), + ); + + it.effect("unsettles for approval and user-input activities but not others", () => + Effect.gen(function* () { + const approvalResult = yield* decideOrchestrationCommand({ + command: { + type: "thread.activity.append", + commandId: CommandId.make("cmd-activity-approval"), + threadId: ThreadId.make("thread-1"), + activity: { + id: EventId.make("activity-1"), + tone: "approval", + kind: "approval.requested", + summary: "Command approval requested", + payload: null, + turnId: null, + createdAt: NOW, + }, + createdAt: NOW, + }, + readModel: makeReadModel("settled"), + }); + const approvalEvents = Array.isArray(approvalResult) ? approvalResult : [approvalResult]; + expect(approvalEvents.map((event) => event.type)).toEqual([ + "thread.unsettled", + "thread.activity-appended", + ]); + + const routineResult = yield* decideOrchestrationCommand({ + command: { + type: "thread.activity.append", + commandId: CommandId.make("cmd-activity-routine"), + threadId: ThreadId.make("thread-1"), + activity: { + id: EventId.make("activity-2"), + tone: "info", + kind: "tool.completed", + summary: "Tool completed", + payload: null, + turnId: null, + createdAt: NOW, + }, + createdAt: NOW, + }, + readModel: makeReadModel("settled"), + }); + const routineEvents = Array.isArray(routineResult) ? routineResult : [routineResult]; + expect(routineEvents.map((event) => event.type)).toEqual(["thread.activity-appended"]); + }), + ); +}); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 9c95b42269d..ad84090da79 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -312,6 +312,61 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.settle": { + const thread = yield* requireThreadNotArchived({ + readModel, + command, + threadId: command.threadId, + }); + const occurredAt = yield* nowIso; + // Settling an already-settled thread re-emits with the original + // settledAt: the engine rejects zero-event commands, and bulk-settle / + // double-click must stay silent no-ops rather than surface errors. + const settledAt = + thread.settledOverride === "settled" && thread.settledAt !== null + ? thread.settledAt + : occurredAt; + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.settled", + payload: { + threadId: command.threadId, + settledAt, + updatedAt: occurredAt, + }, + }; + } + + case "thread.unsettle": { + yield* requireThreadNotArchived({ + readModel, + command, + threadId: command.threadId, + }); + // Idempotent by re-emission (see thread.settle): reducing the event a + // second time lands on the same override state. + const occurredAt = yield* nowIso; + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.unsettled", + payload: { + threadId: command.threadId, + reason: command.reason, + updatedAt: occurredAt, + }, + }; + } + case "thread.meta.update": { const thread = yield* requireThread({ readModel, @@ -464,7 +519,24 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" createdAt: command.createdAt, }, }; - return [userMessageEvent, turnStartRequestedEvent]; + if (targetThread.settledOverride === null) { + return [userMessageEvent, turnStartRequestedEvent]; + } + const unsettledEvent: Omit = { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.unsettled", + payload: { + threadId: command.threadId, + reason: "activity", + updatedAt: command.createdAt, + }, + }; + return [unsettledEvent, userMessageEvent, turnStartRequestedEvent]; } case "thread.turn.interrupt": { @@ -585,12 +657,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" } case "thread.session.set": { - yield* requireThread({ + const thread = yield* requireThread({ readModel, command, threadId: command.threadId, }); - return { + const sessionSetEvent: Omit = { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, @@ -604,6 +676,29 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" session: command.session, }, }; + // Only a session coming alive is activity worth waking a settled thread + // for — status writes like ready/stopped/error arrive after the fact and + // must not fight a user's explicit settle. + const isSessionActivity = + command.session.status === "starting" || command.session.status === "running"; + if (thread.settledOverride === null || !isSessionActivity) { + return sessionSetEvent; + } + const unsettledEvent: Omit = { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.unsettled", + payload: { + threadId: command.threadId, + reason: "activity", + updatedAt: command.createdAt, + }, + }; + return [unsettledEvent, sessionSetEvent]; } case "thread.message.assistant.delta": { @@ -730,7 +825,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" } case "thread.activity.append": { - yield* requireThread({ + const thread = yield* requireThread({ readModel, command, threadId: command.threadId, @@ -743,7 +838,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ? ((command.activity.payload as { requestId: string }) .requestId as OrchestrationEvent["metadata"]["requestId"]) : undefined; - return { + const activityAppendedEvent: Omit = { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, @@ -757,6 +852,29 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" activity: command.activity, }, }; + // An approval or user-input request is blocked-on-you work — it must + // never stay hidden inside a settled slim row. + const wakesSettledThread = + command.activity.kind === "approval.requested" || + command.activity.kind === "user-input.requested"; + if (thread.settledOverride === null || !wakesSettledThread) { + return activityAppendedEvent; + } + const unsettledEvent: Omit = { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.unsettled", + payload: { + threadId: command.threadId, + reason: "activity", + updatedAt: command.createdAt, + }, + }; + return [unsettledEvent, activityAppendedEvent]; } default: { diff --git a/apps/server/src/orchestration/projector.settled.test.ts b/apps/server/src/orchestration/projector.settled.test.ts new file mode 100644 index 00000000000..2070c44418a --- /dev/null +++ b/apps/server/src/orchestration/projector.settled.test.ts @@ -0,0 +1,88 @@ +import { + CommandId, + EventId, + ProjectId, + ThreadId, + type OrchestrationEvent, +} from "@t3tools/contracts"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { createEmptyReadModel, projectEvent } from "./projector.ts"; + +function makeEvent(input: { + readonly sequence: number; + readonly type: OrchestrationEvent["type"]; + readonly payload: unknown; +}): OrchestrationEvent { + return { + sequence: input.sequence, + eventId: EventId.make(`event-${input.sequence}`), + type: input.type, + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + occurredAt: "2026-01-01T00:00:00.000Z", + commandId: CommandId.make(`command-${input.sequence}`), + causationEventId: null, + correlationId: null, + metadata: {}, + payload: input.payload as never, + } as OrchestrationEvent; +} + +it.effect("projects settled lifecycle events", () => + Effect.gen(function* () { + const now = "2026-01-01T00:00:00.000Z"; + const created = yield* projectEvent( + createEmptyReadModel(now), + makeEvent({ + sequence: 1, + type: "thread.created", + payload: { + threadId: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { provider: "codex", model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }), + ); + const settled = yield* projectEvent( + created, + makeEvent({ + sequence: 2, + type: "thread.settled", + payload: { threadId: ThreadId.make("thread-1"), settledAt: now, updatedAt: now }, + }), + ); + expect(settled.threads[0]?.settledOverride).toBe("settled"); + expect(settled.threads[0]?.settledAt).toBe(now); + + const userUnsettled = yield* projectEvent( + settled, + makeEvent({ + sequence: 3, + type: "thread.unsettled", + payload: { threadId: ThreadId.make("thread-1"), reason: "user", updatedAt: now }, + }), + ); + expect(userUnsettled.threads[0]?.settledOverride).toBe("active"); + expect(userUnsettled.threads[0]?.settledAt).toBeNull(); + + const activityUnsettled = yield* projectEvent( + userUnsettled, + makeEvent({ + sequence: 4, + type: "thread.unsettled", + payload: { threadId: ThreadId.make("thread-1"), reason: "activity", updatedAt: now }, + }), + ); + expect(activityUnsettled.threads[0]?.settledOverride).toBeNull(); + expect(activityUnsettled.threads[0]?.settledAt).toBeNull(); + }), +); diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index fadd5078026..e9a55c0796b 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -89,6 +89,8 @@ describe("orchestration projector", () => { createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, messages: [], proposedPlans: [], diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index fc6ab8f6fcf..c8f47dcebbf 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -22,7 +22,9 @@ import { ThreadMetaUpdatedPayload, ThreadProposedPlanUpsertedPayload, ThreadRuntimeModeSetPayload, + ThreadSettledPayload, ThreadUnarchivedPayload, + ThreadUnsettledPayload, ThreadRevertedPayload, ThreadSessionSetPayload, ThreadTurnDiffCompletedPayload, @@ -286,6 +288,8 @@ export function projectEvent( createdAt: payload.createdAt, updatedAt: payload.updatedAt, archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, messages: [], activities: [], @@ -337,6 +341,30 @@ export function projectEvent( })), ); + case "thread.settled": + return decodeForEvent(ThreadSettledPayload, event.payload, event.type, "payload").pipe( + Effect.map((payload) => ({ + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + settledOverride: "settled", + settledAt: payload.settledAt, + updatedAt: payload.updatedAt, + }), + })), + ); + + case "thread.unsettled": + return decodeForEvent(ThreadUnsettledPayload, event.payload, event.type, "payload").pipe( + Effect.map((payload) => ({ + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + settledOverride: payload.reason === "user" ? "active" : null, + settledAt: null, + updatedAt: payload.updatedAt, + }), + })), + ); + case "thread.meta-updated": return decodeForEvent(ThreadMetaUpdatedPayload, event.payload, event.type, "payload").pipe( Effect.map((payload) => ({ diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index a2069e62a14..0403e55d9ee 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -91,6 +91,8 @@ projectionRepositoriesLayer("Projection repositories", (it) => { createdAt: "2026-03-24T00:00:00.000Z", updatedAt: "2026-03-24T00:00:00.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, latestUserMessageAt: null, pendingApprovalCount: 0, pendingUserInputCount: 0, diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 1baeb375c15..e0c85e91494 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -43,6 +43,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { created_at, updated_at, archived_at, + settled_override, + settled_at, latest_user_message_at, pending_approval_count, pending_user_input_count, @@ -62,6 +64,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.createdAt}, ${row.updatedAt}, ${row.archivedAt}, + ${row.settledOverride}, + ${row.settledAt}, ${row.latestUserMessageAt}, ${row.pendingApprovalCount}, ${row.pendingUserInputCount}, @@ -81,6 +85,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { created_at = excluded.created_at, updated_at = excluded.updated_at, archived_at = excluded.archived_at, + settled_override = excluded.settled_override, + settled_at = excluded.settled_at, latest_user_message_at = excluded.latest_user_message_at, pending_approval_count = excluded.pending_approval_count, pending_user_input_count = excluded.pending_user_input_count, @@ -107,6 +113,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", + settled_override AS "settledOverride", + settled_at AS "settledAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -135,6 +143,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", + settled_override AS "settledOverride", + settled_at AS "settledAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index ba1131ee259..f0183fd26c3 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -45,6 +45,7 @@ import Migration0029 from "./Migrations/029_ProjectionThreadDetailOrderingIndexe import Migration0030 from "./Migrations/030_ProjectionThreadShellArchiveIndexes.ts"; import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts"; import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts"; +import Migration0033 from "./Migrations/033_ProjectionThreadsSettled.ts"; /** * Migration loader with all migrations defined inline. @@ -89,6 +90,7 @@ export const migrationEntries = [ [30, "ProjectionThreadShellArchiveIndexes", Migration0030], [31, "AuthAuthorizationScopes", Migration0031], [32, "AuthPairingProofKeyThumbprint", Migration0032], + [33, "ProjectionThreadsSettled", Migration0033], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/033_ProjectionThreadsSettled.ts b/apps/server/src/persistence/Migrations/033_ProjectionThreadsSettled.ts new file mode 100644 index 00000000000..e93407defe2 --- /dev/null +++ b/apps/server/src/persistence/Migrations/033_ProjectionThreadsSettled.ts @@ -0,0 +1,23 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + + if (!columns.some((column) => column.name === "settled_override")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN settled_override TEXT + `; + } + + if (!columns.some((column) => column.name === "settled_at")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN settled_at TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index 44fdc147a4a..8057d434950 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -36,6 +36,8 @@ export const ProjectionThread = Schema.Struct({ createdAt: IsoDateTime, updatedAt: IsoDateTime, archivedAt: Schema.NullOr(IsoDateTime), + settledOverride: Schema.NullOr(Schema.Literals(["settled", "active"])), + settledAt: Schema.NullOr(IsoDateTime), latestUserMessageAt: Schema.NullOr(IsoDateTime), pendingApprovalCount: NonNegativeInt, pendingUserInputCount: NonNegativeInt, diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index 166a88ef17b..3843c8acbcd 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -100,6 +100,8 @@ function makeReadModel( createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, latestUserMessageAt: null, hasPendingApprovals: false, hasPendingUserInput: false, diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 9ca17dbc2dd..3ec6f4d0435 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -305,6 +305,8 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, session: null, latestUserMessageAt: null, hasPendingApprovals: false, @@ -451,6 +453,8 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, session: { threadId, status: "running", @@ -606,6 +610,8 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, session: { threadId, status: "running", diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 347c2920792..29bd0d440ed 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -163,6 +163,8 @@ const makeDefaultOrchestrationReadModel = () => { createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, latestTurn: null, messages: [], session: null, @@ -192,6 +194,8 @@ const makeDefaultOrchestrationThreadShell = ( createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, session: null, latestUserMessageAt: null, hasPendingApprovals: false, @@ -5495,6 +5499,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, latestTurn: null, messages: [], session: null, diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 0f1a8f9d429..104340cb963 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -1,12 +1,14 @@ import { useAtomValue } from "@effect/atom-react"; import { useEffect, type CSSProperties, type ReactNode } from "react"; -import { useNavigate } from "@tanstack/react-router"; +import { useLocation, useNavigate } from "@tanstack/react-router"; import { isElectron } from "../env"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import { isMacPlatform } from "../lib/utils"; import { primaryServerKeybindingsAtom } from "../state/server"; +import { useClientSettings } from "../hooks/useSettings"; import ThreadSidebar from "./Sidebar"; +import ThreadSidebarV2 from "./SidebarV2"; import { Sidebar, SidebarProvider, SidebarRail, SidebarTrigger, useSidebar } from "./ui/sidebar"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; @@ -55,6 +57,12 @@ function SidebarControl() { export function AppSidebarLayout({ children }: { children: ReactNode }) { const navigate = useNavigate(); + const sidebarV2Enabled = useClientSettings((settings) => settings.sidebarV2Enabled); + // Settings routes render the settings nav, which lives in the v1 component + // and is identical for both sidebars — so v1 stays mounted there. + const pathname = useLocation({ select: (location) => location.pathname }); + const isOnSettings = pathname === "/settings" || pathname.startsWith("/settings/"); + const useSidebarV2 = sidebarV2Enabled && !isOnSettings; const macosWindowControlsStyle = isElectron && isMacPlatform(navigator.platform) ? ({ "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET } as CSSProperties) @@ -90,7 +98,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { storageKey: THREAD_SIDEBAR_WIDTH_STORAGE_KEY, }} > - + {useSidebarV2 ? : } {children} diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 0a0103df183..bf0664bc4aa 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -42,6 +42,8 @@ function makeThread(overrides: Partial = {}): Thread { createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, latestTurn: null, branch: null, diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 705793ec77e..b629e9f9c15 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -45,6 +45,8 @@ export function buildLocalDraftThread( createdAt: draftThread.createdAt, updatedAt: draftThread.createdAt, archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, latestTurn: null, branch: draftThread.branch, diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 651fe34e4b4..6609017f91c 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -24,6 +24,8 @@ function makeThread(overrides: Partial = {}): Thread { proposedPlans: [], createdAt: "2026-03-01T00:00:00.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, updatedAt: "2026-03-01T00:00:00.000Z", latestTurn: null, diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 574e33d4dab..5348aa67cb3 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -16,8 +16,10 @@ import { resolveSidebarNewThreadEnvMode, resolveSidebarStageBadgeLabel, resolveThreadRowClassName, + resolveSidebarV2Status, resolveThreadStatusPill, shouldClearThreadSelectionOnMouseDown, + sortThreadsForSidebarV2, sortProjectsForSidebar, THREAD_JUMP_HINT_SHOW_DELAY_MS, } from "./Sidebar.logic"; @@ -564,6 +566,91 @@ describe("isContextMenuPointerDown", () => { }); }); +describe("resolveSidebarV2Status", () => { + const session = { + threadId: ThreadId.make("thread-1"), + status: "running" as const, + providerName: "Codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: DEFAULT_RUNTIME_MODE, + activeTurnId: "turn-1" as never, + lastError: null, + updatedAt: "2026-03-09T10:00:00.000Z", + }; + + it("prioritizes approval over a running session", () => { + expect(resolveSidebarV2Status({ hasPendingApprovals: true, session })).toBe("approval"); + }); + + it("reports working for running and starting sessions", () => { + expect(resolveSidebarV2Status({ hasPendingApprovals: false, session })).toBe("working"); + expect( + resolveSidebarV2Status({ + hasPendingApprovals: false, + session: { ...session, status: "starting" as const }, + }), + ).toBe("working"); + }); + + it("reports failed only for stopped sessions with an error", () => { + expect( + resolveSidebarV2Status({ + hasPendingApprovals: false, + session: { ...session, status: "stopped" as const, lastError: "boom" }, + }), + ).toBe("failed"); + expect( + resolveSidebarV2Status({ + hasPendingApprovals: false, + session: { ...session, status: "stopped" as const }, + }), + ).toBe("ready"); + }); + + it("defaults to ready with no session", () => { + expect(resolveSidebarV2Status({ hasPendingApprovals: false, session: null })).toBe("ready"); + }); +}); + +describe("sortThreadsForSidebarV2", () => { + const sortable = (input: { + id: string; + hasPendingApprovals?: boolean; + latestUserMessageAt: string; + }) => ({ + id: input.id, + hasPendingApprovals: input.hasPendingApprovals ?? false, + session: null, + createdAt: "2026-03-09T10:00:00.000Z", + updatedAt: input.latestUserMessageAt, + latestUserMessageAt: input.latestUserMessageAt, + }); + + it("pins approval-blocked threads above the recency flow, longest wait first", () => { + const sorted = sortThreadsForSidebarV2([ + sortable({ id: "fresh", latestUserMessageAt: "2026-03-09T12:00:00.000Z" }), + sortable({ + id: "approval-new", + hasPendingApprovals: true, + latestUserMessageAt: "2026-03-09T11:00:00.000Z", + }), + sortable({ + id: "approval-old", + hasPendingApprovals: true, + latestUserMessageAt: "2026-03-09T09:00:00.000Z", + }), + sortable({ id: "stale", latestUserMessageAt: "2026-03-09T08:00:00.000Z" }), + ]); + + expect(sorted.map((thread) => thread.id)).toEqual([ + "approval-old", + "approval-new", + "fresh", + "stale", + ]); + }); +}); + describe("resolveThreadStatusPill", () => { const baseThread = { hasActionableProposedPlan: false, @@ -830,6 +917,8 @@ function makeThread(overrides: Partial = {}): Thread { proposedPlans: [], createdAt: "2026-03-09T10:00:00.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, updatedAt: "2026-03-09T10:00:00.000Z", latestTurn: null, diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 4e7614ed551..40e9e89fbfb 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -357,6 +357,50 @@ export function resolveThreadRowClassName(input: { return cn(baseClassName, "text-muted-foreground hover:bg-accent hover:text-foreground"); } +// ── Sidebar v2 status model ───────────────────────────────────────── +// Four visual states, three colors: color is reserved for "act now" +// (approval), "in motion" (working), and "broken" (failed). Ready is the +// unlabeled resting state — the agent stopped and is waiting on the user, +// whether it finished, asked a question, or proposed a plan. +export type SidebarV2Status = "approval" | "working" | "failed" | "ready"; + +type SidebarV2StatusInput = Pick; + +export function resolveSidebarV2Status(thread: SidebarV2StatusInput): SidebarV2Status { + if (thread.hasPendingApprovals) { + return "approval"; + } + if (thread.session?.status === "running" || thread.session?.status === "starting") { + return "working"; + } + if (thread.session != null && thread.session.lastError !== null) { + return "failed"; + } + return "ready"; +} + +// v2 sort: approval-blocked threads pin above the recency flow, longest wait +// first, so nothing actionable can scroll away. Everything else is recency. +export function sortThreadsForSidebarV2< + T extends SidebarV2StatusInput & ThreadSortInput & { readonly id: string }, +>(threads: readonly T[]): T[] { + return [...threads].toSorted((left, right) => { + const leftApproval = resolveSidebarV2Status(left) === "approval"; + const rightApproval = resolveSidebarV2Status(right) === "approval"; + if (leftApproval !== rightApproval) { + return leftApproval ? -1 : 1; + } + const byRecency = + getThreadSortTimestamp(right, "updated_at") - getThreadSortTimestamp(left, "updated_at"); + // Approval wait time is "how long you've been the bottleneck", which is + // oldest-activity-first — the inverse of recency. + if (leftApproval && rightApproval) { + return -byRecency || left.id.localeCompare(right.id); + } + return byRecency || left.id.localeCompare(right.id); + }); +} + export function resolveThreadStatusPill(input: { thread: ThreadStatusInput; }): ThreadStatusPill | null { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index bb6b2752a17..b02cc23ad1d 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -8,7 +8,6 @@ import { Globe2Icon, LoaderIcon, SearchIcon, - SettingsIcon, SquarePenIcon, TerminalIcon, TriangleAlertIcon, @@ -63,7 +62,7 @@ import { settlePromise, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import { Link, useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router"; +import { useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router"; import { MAX_SIDEBAR_THREAD_PREVIEW_COUNT, MIN_SIDEBAR_THREAD_PREVIEW_COUNT, @@ -74,7 +73,6 @@ import { import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; import { isElectron } from "../env"; -import { APP_STAGE_LABEL } from "../branding"; import { useOpenPrLink } from "../lib/openPullRequestLink"; import { isTerminalFocused } from "../lib/terminalFocus"; import { isMacPlatform } from "../lib/utils"; @@ -168,9 +166,7 @@ import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./u import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { SidebarContent, - SidebarFooter, SidebarGroup, - SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem, @@ -178,7 +174,6 @@ import { SidebarMenuSubButton, SidebarMenuSubItem, SidebarSeparator, - SidebarTrigger, useSidebar, } from "./ui/sidebar"; import { useThreadSelectionStore } from "../threadSelectionStore"; @@ -191,7 +186,6 @@ import { resolveProjectStatusIndicator, resolveSidebarNewThreadSeedContext, resolveSidebarNewThreadEnvMode, - resolveSidebarStageBadgeLabel, resolveThreadRowClassName, resolveThreadStatusPill, orderItemsByPreferredIds, @@ -201,12 +195,12 @@ import { ThreadStatusPill, } from "./Sidebar.logic"; import { sortThreads } from "../lib/threadSort"; -import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; +import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useIsMobile } from "~/hooks/useMediaQuery"; import { CommandDialogTrigger } from "./ui/command"; import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; -import { primaryServerConfigAtom, primaryServerKeybindingsAtom } from "../state/server"; +import { primaryServerKeybindingsAtom } from "../state/server"; import { derivePhysicalProjectKey, deriveProjectGroupingOverrideKey, @@ -220,7 +214,6 @@ import { type SidebarProjectGroupMember, type SidebarProjectSnapshot, } from "../sidebarProjectGrouping"; -import { SidebarProviderUpdatePill } from "./sidebar/SidebarProviderUpdatePill"; const SIDEBAR_SORT_LABELS: Record = { updated_at: "Last user message", created_at: "Created at", @@ -2739,100 +2732,6 @@ function SortableProjectItem({ ); } -const SidebarChromeHeader = memo(function SidebarChromeHeader({ - isElectron, -}: { - isElectron: boolean; -}) { - return isElectron ? ( - - - - - ) : ( - - - - - ); -}); - -function SidebarBrand() { - const stageLabel = useSidebarStageLabel(); - - return ( - - - - Code - - - {stageLabel} - - - ); -} - -function useSidebarStageLabel() { - const primaryServerVersion = - useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null; - - return resolveSidebarStageBadgeLabel({ - primaryServerVersion, - fallbackStageLabel: APP_STAGE_LABEL, - }); -} - -function T3Wordmark() { - return ( - - - - ); -} - -const SidebarChromeFooter = memo(function SidebarChromeFooter() { - const navigate = useNavigate(); - const { isMobile, setOpenMobile } = useSidebar(); - const handleSettingsClick = useCallback(() => { - if (isMobile) { - setOpenMobile(false); - } - void navigate({ to: "/settings" }); - }, [isMobile, navigate, setOpenMobile]); - - return ( - - - - - - - - Settings - - - - - ); -}); - interface SidebarProjectsContentProps { showArm64IntelBuildWarning: boolean; arm64IntelBuildWarningDescription: string | null; diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx new file mode 100644 index 00000000000..15ee72a2f19 --- /dev/null +++ b/apps/web/src/components/SidebarV2.tsx @@ -0,0 +1,978 @@ +import { autoAnimate } from "@formkit/auto-animate"; +import { useAtomValue } from "@effect/atom-react"; +import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; +import { + scopeProjectRef, + scopeThreadRef, + scopedThreadKey, +} from "@t3tools/client-runtime/environment"; +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { CheckIcon, CloudIcon, PlusIcon, SearchIcon, Undo2Icon } from "lucide-react"; +import { + memo, + useCallback, + useEffect, + useMemo, + useRef, + useState, + type MouseEvent as ReactMouseEvent, +} from "react"; +import { useParams, useRouter } from "@tanstack/react-router"; + +import { + isAtomCommandInterrupted, + settlePromise, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { isElectron } from "../env"; +import { + resolveShortcutCommand, + shortcutLabelForCommand, + threadJumpCommandForIndex, + threadJumpIndexFromCommand, + threadTraversalDirectionFromCommand, +} from "../keybindings"; +import { isTerminalFocused } from "../lib/terminalFocus"; +import { isModelPickerOpen } from "../modelPickerVisibility"; +import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; +import { isMacPlatform } from "~/lib/utils"; +import { useOpenPrLink } from "../lib/openPullRequestLink"; +import { readLocalApi } from "../localApi"; +import { useUiStateStore } from "../uiStateStore"; +import { useThreadSelectionStore } from "../threadSelectionStore"; +import { useThreadActions } from "../hooks/useThreadActions"; +import { useNewThreadHandler } from "../hooks/useHandleNewThread"; +import { useClientSettings } from "../hooks/useSettings"; +import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; +import { useProjects, useThreadShells } from "../state/entities"; +import { primaryServerKeybindingsAtom } from "../state/server"; +import { vcsEnvironment } from "../state/vcs"; +import { useEnvironmentQuery } from "../state/query"; +import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; +import { formatElapsedDurationLabel, formatRelativeTimeLabel } from "../timestampFormat"; +import type { SidebarThreadSummary } from "../types"; +import { cn } from "~/lib/utils"; +import { + isTrailingDoubleClick, + resolveAdjacentThreadId, + resolveSidebarV2Status, + sortThreadsForSidebarV2, + type SidebarV2Status, +} from "./Sidebar.logic"; +import { prStatusIndicator, resolveThreadPr } from "./ThreadStatusIndicators"; +import { ProjectFavicon } from "./ProjectFavicon"; +import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; +import { deriveProviderInstanceEntries, type ProviderInstanceEntry } from "../providerInstances"; +import { primaryServerProvidersAtom } from "../state/server"; +import { stackedThreadToast, toastManager } from "./ui/toast"; +import { CommandDialogTrigger } from "./ui/command"; +import { Kbd } from "./ui/kbd"; +import { + SidebarContent, + SidebarGroup, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarSeparator, + useSidebar, +} from "./ui/sidebar"; +import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; + +// Row heights are fixed per variant so the list only changes shape at +// lifecycle transitions (settle/unsettle), never from streaming updates. +const RAIL_CLASS_BY_STATUS: Partial> = { + approval: "bg-amber-500 dark:bg-amber-300/90", + working: "bg-sky-500 dark:bg-sky-300/80 animate-pulse", + failed: "bg-red-500 dark:bg-red-400/90", +}; + +const STATUS_WORD_BY_STATUS: Partial< + Record +> = { + approval: { label: "Approval", className: "text-amber-600 dark:text-amber-300/90" }, + working: { label: "Working", className: "text-sky-600 dark:text-sky-300/80" }, + failed: { label: "Failed", className: "text-red-600 dark:text-red-400/90" }, +}; + +// The working timer re-renders once per second only for rows that show it. +function useTickWhile(active: boolean): number { + const [, setTick] = useState(0); + useEffect(() => { + if (!active) return; + const id = window.setInterval(() => setTick((value) => value + 1), 1_000); + return () => window.clearInterval(id); + }, [active]); + return active ? Date.now() : 0; +} + +function threadTimeLabel(thread: SidebarThreadSummary, status: SidebarV2Status): string { + if (status === "working" && thread.latestTurn?.startedAt) { + return formatElapsedDurationLabel(thread.latestTurn.startedAt); + } + if (status === "approval") { + const waitingSince = thread.latestTurn?.startedAt ?? thread.updatedAt; + return `waiting ${formatElapsedDurationLabel(waitingSince)}`; + } + const timestamp = thread.latestUserMessageAt ?? thread.updatedAt; + return formatRelativeTimeLabel(timestamp); +} + +const SidebarV2Row = memo(function SidebarV2Row(props: { + thread: SidebarThreadSummary; + variant: "card" | "slim"; + isActive: boolean; + jumpLabel: string | null; + currentEnvironmentId: string | null; + environmentLabel: string | null; + projectCwd: string | null; + providerEntryByInstanceId: ReadonlyMap; + onThreadClick: (event: ReactMouseEvent, threadRef: ScopedThreadRef) => void; + onContextMenu: (threadRef: ScopedThreadRef, position: { x: number; y: number }) => void; + onSettle: (threadRef: ScopedThreadRef) => void; + onUnsettle: (threadRef: ScopedThreadRef) => void; + onChangeRequestState: (threadKey: string, state: "open" | "closed" | "merged" | null) => void; +}) { + const { + onChangeRequestState, + onContextMenu, + onSettle, + onThreadClick, + onUnsettle, + thread, + variant, + } = props; + const threadRef = useMemo( + () => scopeThreadRef(thread.environmentId, thread.id), + [thread.environmentId, thread.id], + ); + const threadKey = scopedThreadKey(threadRef); + const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); + const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); + const openPrLink = useOpenPrLink(); + + const status = resolveSidebarV2Status(thread); + useTickWhile(variant === "card" && (status === "working" || status === "approval")); + + const gitCwd = thread.worktreePath ?? props.projectCwd; + const gitStatus = useEnvironmentQuery( + thread.branch != null && gitCwd !== null + ? vcsEnvironment.status({ + environmentId: thread.environmentId, + input: { cwd: gitCwd }, + }) + : null, + ); + const pr = resolveThreadPr(thread.branch, gitStatus.data); + const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); + // Report the PR state up: the parent partitions rows with effectiveSettled, + // and a merged/closed PR auto-settles a thread — data only rows have. + const prState = pr?.state ?? null; + useEffect(() => { + onChangeRequestState(threadKey, prState); + }, [onChangeRequestState, prState, threadKey]); + + const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; + const driverKind = props.providerEntryByInstanceId.get(modelInstanceId)?.driverKind ?? null; + + const isUnread = + thread.latestTurn?.completedAt != null && + (lastVisitedAt == null || + Date.parse(thread.latestTurn.completedAt) > Date.parse(lastVisitedAt)); + + const isRemote = + props.currentEnvironmentId !== null && thread.environmentId !== props.currentEnvironmentId; + + const handleClick = useCallback( + (event: ReactMouseEvent) => { + onThreadClick(event, threadRef); + }, + [onThreadClick, threadRef], + ); + const handleContextMenu = useCallback( + (event: ReactMouseEvent) => { + event.preventDefault(); + onContextMenu(threadRef, { x: event.clientX, y: event.clientY }); + }, + [onContextMenu, threadRef], + ); + const handleSettleClick = useCallback( + (event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + onSettle(threadRef); + }, + [onSettle, threadRef], + ); + const handleUnsettleClick = useCallback( + (event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + onUnsettle(threadRef); + }, + [onUnsettle, threadRef], + ); + const handlePrClick = useCallback( + (event: ReactMouseEvent) => { + if (pr?.url) openPrLink(event, pr.url); + }, + [openPrLink, pr], + ); + + const rowClassName = cn( + "group/v2-row relative w-full cursor-pointer select-none rounded-md text-left", + props.isActive + ? "bg-accent/85 dark:bg-accent/55" + : isSelected + ? "bg-primary/15 dark:bg-primary/22" + : "hover:bg-accent/60 dark:hover:bg-accent/40", + ); + + const favicon = ( + + ); + + const prBadge = + prStatus && pr ? ( + + ) : null; + + if (variant === "slim") { + return ( +
  • +
    + {favicon} + + {thread.title} + + {prBadge} + + + {props.jumpLabel ?? + formatRelativeTimeLabel(thread.latestUserMessageAt ?? thread.updatedAt)} + +
    +
  • + ); + } + + const rail = RAIL_CLASS_BY_STATUS[status]; + const statusWord = STATUS_WORD_BY_STATUS[status]; + const diff = latestTurnDiff(thread); + + return ( +
  • +
    + {rail ? ( + + ) : null} +
    + {favicon} + + {thread.title} + + {diff ? ( + + +{diff.insertions}{" "} + −{diff.deletions} + + ) : null} + + + {props.jumpLabel ?? threadTimeLabel(thread, status)} + +
    +
    + {statusWord ? ( + {statusWord.label} + ) : null} + {status === "failed" && thread.session?.lastError ? ( + + {thread.session.lastError} + + ) : null} + {thread.branch ? ( + {thread.branch} + ) : null} + {prBadge} + + {driverKind ? ( + + ) : null} + {thread.modelSelection.model} + + {isRemote ? ( + + + } + > + + {props.environmentLabel ?? "Remote"} + + + Running on {props.environmentLabel ?? "a remote environment"} + + + ) : null} +
    +
    +
  • + ); +}); + +function latestTurnDiff( + thread: SidebarThreadSummary, +): { insertions: number; deletions: number } | null { + // Shells don't carry checkpoint summaries; diff stats render only when the + // shell projection grows them. Kept as a seam so the row layout is ready. + void thread; + return null; +} + +export default function SidebarV2() { + const projects = useProjects(); + const threads = useThreadShells(); + const router = useRouter(); + const { isMobile, setOpenMobile } = useSidebar(); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); + const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); + const { settleThread, unsettleThread, archiveThread, deleteThread } = useThreadActions(); + const handleNewThread = useNewThreadHandler(); + const { environments } = useEnvironments(); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const clearSelection = useThreadSelectionStore((s) => s.clearSelection); + const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); + const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread); + const rangeSelectTo = useThreadSelectionStore((s) => s.rangeSelectTo); + const markThreadUnread = useUiStateStore((s) => s.markThreadUnread); + const routeThreadRef = useParams({ + strict: false, + select: (params) => resolveThreadRouteRef(params), + }); + const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; + + const environmentLabelById = useMemo( + () => + new Map( + environments.map((environment) => [environment.environmentId, environment.label] as const), + ), + [environments], + ); + const serverProviders = useAtomValue(primaryServerProvidersAtom); + const providerEntryByInstanceId = useMemo( + () => + new Map( + deriveProviderInstanceEntries(serverProviders).map( + (entry) => [entry.instanceId as string, entry] as const, + ), + ), + [serverProviders], + ); + const projectCwdByKey = useMemo( + () => + new Map( + projects.map((project) => [ + `${project.environmentId}:${project.id}`, + project.workspaceRoot, + ]), + ), + [projects], + ); + + // now is quantized to the minute so effectiveSettled memoization doesn't + // churn on every render; auto-settle thresholds are day-granular anyway. + const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16)); + useEffect(() => { + const id = window.setInterval( + () => setNowMinute(new Date().toISOString().slice(0, 16)), + 60_000, + ); + return () => window.clearInterval(id); + }, []); + + // PR states stream in per-row (rows own the VCS subscriptions); a merged or + // closed PR auto-settles its thread on the next partition. + const [changeRequestStateByKey, setChangeRequestStateByKey] = useState< + ReadonlyMap + >(() => new Map()); + const handleChangeRequestState = useCallback( + (threadKey: string, state: "open" | "closed" | "merged" | null) => { + setChangeRequestStateByKey((current) => { + if ((current.get(threadKey) ?? null) === state) return current; + const next = new Map(current); + if (state === null) { + next.delete(threadKey); + } else { + next.set(threadKey, state); + } + return next; + }); + }, + [], + ); + + const { activeThreads, settledThreads } = useMemo(() => { + const now = `${nowMinute}:00.000Z`; + const visible = threads.filter((thread) => thread.archivedAt === null); + const active: EnvironmentThreadShell[] = []; + const settled: EnvironmentThreadShell[] = []; + for (const thread of visible) { + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + const changeRequestState = changeRequestStateByKey.get(threadKey) ?? null; + if (effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState })) { + settled.push(thread); + } else { + active.push(thread); + } + } + return { + activeThreads: sortThreadsForSidebarV2(active), + settledThreads: settled.toSorted( + (left, right) => + Date.parse(right.latestUserMessageAt ?? right.updatedAt) - + Date.parse(left.latestUserMessageAt ?? left.updatedAt), + ), + }; + }, [autoSettleAfterDays, changeRequestStateByKey, nowMinute, threads]); + + const orderedThreads = useMemo( + () => [...activeThreads, ...settledThreads], + [activeThreads, settledThreads], + ); + const orderedThreadKeys = useMemo( + () => + orderedThreads.map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ), + [orderedThreads], + ); + // Rows call back into the click handler without carrying the ordered list as + // a prop — a fresh array identity per shell update would defeat every row's + // memoization. The ref keeps shift-range-select working against the list as + // rendered at click time. + const orderedThreadKeysRef = useRef(orderedThreadKeys); + orderedThreadKeysRef.current = orderedThreadKeys; + const threadByKey = useMemo( + () => + new Map( + orderedThreads.map( + (thread) => + [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const, + ), + ), + [orderedThreads], + ); + // Handlers read these through refs: depending on per-update Map/Set + // identities would give every row a fresh callback prop on each shell + // event and defeat row memoization during streaming. + const threadByKeyRef = useRef(threadByKey); + threadByKeyRef.current = threadByKey; + const settledThreadKeys = useMemo( + () => + new Set( + settledThreads.map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ), + ), + [settledThreads], + ); + const settledThreadKeysRef = useRef(settledThreadKeys); + settledThreadKeysRef.current = settledThreadKeys; + + const jumpLabelByKey = useMemo(() => { + const mapping = new Map(); + for (const [index, threadKey] of orderedThreadKeys.entries()) { + const jumpCommand = threadJumpCommandForIndex(index); + if (!jumpCommand) break; + const label = shortcutLabelForCommand(keybindings, jumpCommand); + if (label) mapping.set(threadKey, label); + } + return mapping; + }, [keybindings, orderedThreadKeys]); + const [showJumpHints, setShowJumpHints] = useState(false); + + const navigateToThread = useCallback( + (threadRef: ScopedThreadRef) => { + if (useThreadSelectionStore.getState().selectedThreadKeys.size > 0) { + clearSelection(); + } + setSelectionAnchor(scopedThreadKey(threadRef)); + if (isMobile) { + setOpenMobile(false); + } + void router.navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(threadRef), + }); + }, + [clearSelection, isMobile, router, setOpenMobile, setSelectionAnchor], + ); + + const handleThreadClick = useCallback( + (event: ReactMouseEvent, threadRef: ScopedThreadRef) => { + const isMac = isMacPlatform(navigator.platform); + const isModClick = isMac ? event.metaKey : event.ctrlKey; + const threadKey = scopedThreadKey(threadRef); + if (isModClick) { + event.preventDefault(); + toggleThreadSelection(threadKey); + return; + } + if (event.shiftKey) { + event.preventDefault(); + rangeSelectTo(threadKey, orderedThreadKeysRef.current); + return; + } + if (isTrailingDoubleClick(event.detail)) { + return; + } + navigateToThread(threadRef); + }, + [navigateToThread, rangeSelectTo, toggleThreadSelection], + ); + + const attemptSettle = useCallback( + (threadRef: ScopedThreadRef) => { + void (async () => { + const result = await settleThread(threadRef); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to settle thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + })(); + }, + [settleThread], + ); + const attemptUnsettle = useCallback( + (threadRef: ScopedThreadRef) => { + void (async () => { + const result = await unsettleThread(threadRef); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to un-settle thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + })(); + }, + [unsettleThread], + ); + + const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection); + const handleMultiSelectContextMenu = useCallback( + async (position: { x: number; y: number }) => { + const api = readLocalApi(); + if (!api) return; + const threadKeys = [...useThreadSelectionStore.getState().selectedThreadKeys]; + if (threadKeys.length === 0) return; + const count = threadKeys.length; + const clicked = await settlePromise(() => + api.contextMenu.show( + [ + { id: "settle", label: `Settle (${count})` }, + { id: "mark-unread", label: `Mark unread (${count})` }, + { id: "delete", label: `Delete (${count})`, destructive: true }, + ], + position, + ), + ); + if (clicked._tag === "Failure") return; + if (clicked.value === "settle") { + for (const threadKey of threadKeys) { + const thread = threadByKeyRef.current.get(threadKey); + if (!thread) continue; + attemptSettle(scopeThreadRef(thread.environmentId, thread.id)); + } + clearSelection(); + return; + } + if (clicked.value === "mark-unread") { + for (const threadKey of threadKeys) { + const thread = threadByKeyRef.current.get(threadKey); + markThreadUnread(threadKey, thread?.latestTurn?.completedAt); + } + clearSelection(); + return; + } + if (clicked.value !== "delete") return; + if (confirmThreadDelete) { + const confirmed = await settlePromise(() => + api.dialogs.confirm( + [ + `Delete ${count} thread${count === 1 ? "" : "s"}?`, + "This permanently clears conversation history for these threads.", + ].join("\n"), + ), + ); + if (confirmed._tag === "Failure" || !confirmed.value) return; + } + const deletedThreadKeys = new Set(threadKeys); + for (const threadKey of threadKeys) { + const thread = threadByKeyRef.current.get(threadKey); + if (!thread) continue; + const result = await deleteThread(scopeThreadRef(thread.environmentId, thread.id), { + deletedThreadKeys, + }); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to delete threads", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + return; + } + } + removeFromSelection(threadKeys); + }, + [ + attemptSettle, + clearSelection, + confirmThreadDelete, + deleteThread, + markThreadUnread, + removeFromSelection, + ], + ); + + const handleThreadContextMenu = useCallback( + (threadRef: ScopedThreadRef, position: { x: number; y: number }) => { + void (async () => { + const api = readLocalApi(); + if (!api) return; + const threadKey = scopedThreadKey(threadRef); + const selectionState = useThreadSelectionStore.getState(); + if (selectionState.hasSelection() && selectionState.selectedThreadKeys.has(threadKey)) { + await handleMultiSelectContextMenu(position); + return; + } + const thread = threadByKeyRef.current.get(threadKey); + if (!thread) return; + // Match what the user sees: a row renders slim iff it is in the + // settled partition, so the menu label mirrors that exact state. + const isSettled = settledThreadKeysRef.current.has(threadKey); + const clicked = await settlePromise(() => + api.contextMenu.show( + [ + isSettled + ? { id: "unsettle", label: "Un-settle thread" } + : { id: "settle", label: "Settle thread" }, + { id: "mark-unread", label: "Mark unread" }, + { id: "archive", label: "Archive" }, + { id: "delete", label: "Delete", destructive: true, icon: "trash" }, + ], + position, + ), + ); + if (clicked._tag === "Failure") return; + switch (clicked.value) { + case "settle": + attemptSettle(threadRef); + return; + case "unsettle": + attemptUnsettle(threadRef); + return; + case "mark-unread": + markThreadUnread(threadKey, thread.latestTurn?.completedAt); + return; + case "archive": { + const result = await archiveThread(threadRef); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to archive thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + return; + } + case "delete": { + if (confirmThreadDelete) { + const confirmed = await settlePromise(() => + api.dialogs.confirm( + [ + `Delete thread "${thread.title}"?`, + "This permanently clears conversation history for this thread.", + ].join("\n"), + ), + ); + if (confirmed._tag === "Failure" || !confirmed.value) return; + } + const result = await deleteThread(threadRef); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to delete thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + return; + } + default: + return; + } + })(); + }, + [ + archiveThread, + attemptSettle, + attemptUnsettle, + confirmThreadDelete, + deleteThread, + handleMultiSelectContextMenu, + markThreadUnread, + ], + ); + + // Thread jump (cmd+1..9) and prev/next traversal reuse the same commands as + // v1 — the keybinding layer is shared, only the ordered list differs. + const routeTerminalOpen = useTerminalUiStateStore((state) => + routeThreadRef + ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen + : false, + ); + useEffect(() => { + const onWindowKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented || event.repeat) return; + const command = resolveShortcutCommand(event, keybindings, { + platform: navigator.platform, + context: { + terminalFocus: isTerminalFocused(), + terminalOpen: routeTerminalOpen, + modelPickerOpen: isModelPickerOpen(), + }, + }); + const navigateToThreadKey = (targetThreadKey: string | null) => { + if (!targetThreadKey) return false; + const targetThread = threadByKey.get(targetThreadKey); + if (!targetThread) return false; + event.preventDefault(); + event.stopPropagation(); + navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); + return true; + }; + const traversalDirection = threadTraversalDirectionFromCommand(command); + if (traversalDirection !== null) { + navigateToThreadKey( + resolveAdjacentThreadId({ + threadIds: orderedThreadKeys, + currentThreadId: routeThreadKey, + direction: traversalDirection, + }), + ); + return; + } + const jumpIndex = threadJumpIndexFromCommand(command ?? ""); + if (jumpIndex === null) return; + navigateToThreadKey(orderedThreadKeys[jumpIndex] ?? null); + }; + window.addEventListener("keydown", onWindowKeyDown); + return () => window.removeEventListener("keydown", onWindowKeyDown); + }, [ + keybindings, + navigateToThread, + orderedThreadKeys, + routeTerminalOpen, + routeThreadKey, + threadByKey, + ]); + + useEffect(() => { + const sync = (event: KeyboardEvent) => setShowJumpHints(event.metaKey || event.ctrlKey); + const clear = () => setShowJumpHints(false); + window.addEventListener("keydown", sync); + window.addEventListener("keyup", sync); + window.addEventListener("blur", clear); + return () => { + window.removeEventListener("keydown", sync); + window.removeEventListener("keyup", sync); + window.removeEventListener("blur", clear); + }; + }, []); + + const attachListAutoAnimateRef = useCallback((node: HTMLUListElement | null) => { + if (!node) return; + autoAnimate(node, { duration: 150, easing: "ease-out" }); + }, []); + + const handleNewThreadClick = useCallback(() => { + const firstProject = projects[0]; + if (!firstProject) return; + if (isMobile) setOpenMobile(false); + void handleNewThread(scopeProjectRef(firstProject.environmentId, firstProject.id)); + }, [handleNewThread, isMobile, projects, setOpenMobile]); + + const commandPaletteShortcutLabel = shortcutLabelForCommand(keybindings, "commandPalette.toggle"); + const newThreadShortcutLabel = shortcutLabelForCommand(keybindings, "chat.new"); + + return ( + <> + + + + + + + } + > + + Search + {commandPaletteShortcutLabel ? ( + + {commandPaletteShortcutLabel} + + ) : null} + + + + + + New thread + {newThreadShortcutLabel ? ( + + {newThreadShortcutLabel} + + ) : null} + + + + + +
      + {orderedThreads.map((thread) => { + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + const isSettledRow = settledThreadKeys.has(threadKey); + return ( + + ); + })} +
    + {orderedThreads.length === 0 ? ( +
    + No threads yet +
    + ) : null} +
    +
    + + + + ); +} diff --git a/apps/web/src/components/settings/BetaSettingsPanel.tsx b/apps/web/src/components/settings/BetaSettingsPanel.tsx new file mode 100644 index 00000000000..4c30cdb053c --- /dev/null +++ b/apps/web/src/components/settings/BetaSettingsPanel.tsx @@ -0,0 +1,105 @@ +import { useEffect, useState } from "react"; + +import { useClientSettings, useUpdateClientSettings } from "../../hooks/useSettings"; +import { Input } from "../ui/input"; +import { Switch } from "../ui/switch"; +import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout"; + +const AUTO_SETTLE_MIN_DAYS = 1; +const AUTO_SETTLE_MAX_DAYS = 90; +const AUTO_SETTLE_DEFAULT_DAYS = 3; + +function AutoSettleDaysInput({ + value, + onCommit, +}: { + value: number; + onCommit: (days: number) => void; +}) { + // Local draft so the field can be emptied mid-edit; the setting only moves + // on valid input and snaps back to the persisted value on blur. + const [draft, setDraft] = useState(String(value)); + useEffect(() => { + setDraft(String(value)); + }, [value]); + + return ( + { + setDraft(event.target.value); + const parsed = Number.parseInt(event.target.value, 10); + if ( + Number.isFinite(parsed) && + parsed >= AUTO_SETTLE_MIN_DAYS && + parsed <= AUTO_SETTLE_MAX_DAYS + ) { + onCommit(parsed); + } + }} + onBlur={() => setDraft(String(value))} + aria-label="Days of inactivity before auto-settle" + /> + ); +} + +export function BetaSettingsPanel() { + const sidebarV2Enabled = useClientSettings((settings) => settings.sidebarV2Enabled); + const sidebarAutoSettleAfterDays = useClientSettings( + (settings) => settings.sidebarAutoSettleAfterDays, + ); + const updateSettings = useUpdateClientSettings(); + + return ( + + + updateSettings({ sidebarV2Enabled: Boolean(checked) })} + aria-label="Enable the sidebar v2 beta" + /> + } + /> + {sidebarV2Enabled ? ( + <> + + updateSettings({ + sidebarAutoSettleAfterDays: checked ? AUTO_SETTLE_DEFAULT_DAYS : null, + }) + } + aria-label="Auto-settle inactive threads" + /> + } + /> + {sidebarAutoSettleAfterDays !== null ? ( + updateSettings({ sidebarAutoSettleAfterDays: days })} + /> + } + /> + ) : null} + + ) : null} + + + ); +} diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index 6774b6f333f..9d690f2a4e4 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -3,6 +3,7 @@ import { ArchiveIcon, ArrowLeftIcon, BotIcon, + FlaskConicalIcon, GitBranchIcon, KeyboardIcon, Link2Icon, @@ -28,6 +29,7 @@ export type SettingsSectionPath = | "/settings/providers" | "/settings/source-control" | "/settings/connections" + | "/settings/beta" | "/settings/archived"; export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ @@ -40,6 +42,7 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ { label: "Providers", to: "/settings/providers", icon: BotIcon }, { label: "Source Control", to: "/settings/source-control", icon: GitBranchIcon }, { label: "Connections", to: "/settings/connections", icon: Link2Icon }, + { label: "Beta", to: "/settings/beta", icon: FlaskConicalIcon }, { label: "Archive", to: "/settings/archived", icon: ArchiveIcon }, ]; diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx new file mode 100644 index 00000000000..87181ec42cc --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -0,0 +1,113 @@ +import { useAtomValue } from "@effect/atom-react"; +import { SettingsIcon } from "lucide-react"; +import { memo, useCallback } from "react"; +import { Link, useNavigate } from "@tanstack/react-router"; + +import { APP_STAGE_LABEL } from "../../branding"; +import { primaryServerConfigAtom } from "../../state/server"; +import { resolveSidebarStageBadgeLabel } from "../Sidebar.logic"; +import { + SidebarFooter, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarTrigger, + useSidebar, +} from "../ui/sidebar"; +import { SidebarProviderUpdatePill } from "./SidebarProviderUpdatePill"; +import { SidebarUpdatePill } from "./SidebarUpdatePill"; + +export const SidebarChromeHeader = memo(function SidebarChromeHeader({ + isElectron, +}: { + isElectron: boolean; +}) { + return isElectron ? ( + + + + + ) : ( + + + + + ); +}); + +function SidebarBrand() { + const stageLabel = useSidebarStageLabel(); + + return ( + + + + Code + + + {stageLabel} + + + ); +} + +function useSidebarStageLabel() { + const primaryServerVersion = + useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null; + + return resolveSidebarStageBadgeLabel({ + primaryServerVersion, + fallbackStageLabel: APP_STAGE_LABEL, + }); +} + +function T3Wordmark() { + return ( + + + + ); +} + +export const SidebarChromeFooter = memo(function SidebarChromeFooter() { + const navigate = useNavigate(); + const { isMobile, setOpenMobile } = useSidebar(); + const handleSettingsClick = useCallback(() => { + if (isMobile) { + setOpenMobile(false); + } + void navigate({ to: "/settings" }); + }, [isMobile, navigate, setOpenMobile]); + + return ( + + + + + + + + Settings + + + + + ); +}); diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 07655ad30d7..396c9927113 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -50,6 +50,12 @@ export function useThreadActions() { const deleteThreadMutation = useAtomCommand(threadEnvironment.delete, { reportFailure: false, }); + const settleThreadMutation = useAtomCommand(threadEnvironment.settle, { + reportFailure: false, + }); + const unsettleThreadMutation = useAtomCommand(threadEnvironment.unsettle, { + reportFailure: false, + }); const stopThreadSession = useAtomCommand(threadEnvironment.stopSession); const removeWorktree = useAtomCommand(vcsEnvironment.removeWorktree, { reportFailure: false, @@ -345,6 +351,116 @@ export function useThreadActions() { ], ); + const settleThread = useCallback( + async (target: ScopedThreadRef) => { + const resolved = resolveThreadTarget(target); + const settleResult = await settleThreadMutation({ + environmentId: target.environmentId, + input: { threadId: target.threadId }, + }); + if (settleResult._tag === "Failure" || !resolved) { + return settleResult; + } + + // Settling is the natural moment to reclaim disk: offer (never force) a + // worktree removal when this thread is the only one using it and there is + // no unsaved or unpushed work at stake. + const { thread, threadRef } = resolved; + if (thread.worktreePath === null) { + return settleResult; + } + const threads = readEnvironmentThreadRefs(threadRef.environmentId).flatMap((ref) => { + const shell = readThreadShell(ref); + return shell === null ? [] : [shell]; + }); + const orphanedWorktreePath = getOrphanedWorktreePathForThread(threads, threadRef.threadId); + const threadProject = readProject({ + environmentId: threadRef.environmentId, + projectId: thread.projectId, + }); + if (orphanedWorktreePath === null || threadProject === null) { + return settleResult; + } + const displayWorktreePath = formatWorktreePathForDisplay(orphanedWorktreePath); + toastManager.add( + stackedThreadToast({ + type: "info", + title: "Thread settled", + description: `Worktree ${displayWorktreePath} is no longer used by any thread.`, + actionProps: { + children: "Remove worktree", + onClick: () => { + void (async () => { + // Re-validate at click time: the toast may be stale — the + // thread can have woken up or another thread adopted the + // worktree since the settle happened. + const currentShell = readThreadShell(threadRef); + const currentThreads = readEnvironmentThreadRefs(threadRef.environmentId).flatMap( + (ref) => { + const shell = readThreadShell(ref); + return shell === null ? [] : [shell]; + }, + ); + const stillOrphaned = + getOrphanedWorktreePathForThread(currentThreads, threadRef.threadId) === + orphanedWorktreePath; + const stillSettled = currentShell?.settledOverride === "settled"; + if (!stillOrphaned || !stillSettled) { + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "Worktree kept", + description: "This worktree is in use again and was not removed.", + }), + ); + return; + } + // force stays false: git refuses to remove a worktree with + // uncommitted changes, which is the backstop for work created + // after the settle. Branch commits survive worktree removal. + const removeResult = await removeWorktree({ + environmentId: threadRef.environmentId, + input: { + cwd: threadProject.workspaceRoot, + path: orphanedWorktreePath, + force: false, + }, + }); + if (removeResult._tag === "Failure") { + const error = squashAtomCommandFailure(removeResult); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Worktree removal failed", + description: + error instanceof Error ? error.message : "Could not remove the worktree.", + }), + ); + return; + } + await refreshVcsStatus({ + environmentId: threadRef.environmentId, + input: { cwd: threadProject.workspaceRoot }, + }); + })(); + }, + }, + }), + ); + return settleResult; + }, + [refreshVcsStatus, removeWorktree, resolveThreadTarget, settleThreadMutation], + ); + + const unsettleThread = useCallback( + (target: ScopedThreadRef) => + unsettleThreadMutation({ + environmentId: target.environmentId, + input: { threadId: target.threadId, reason: "user" }, + }), + [unsettleThreadMutation], + ); + const confirmAndDeleteThread = useCallback( async (target: ScopedThreadRef) => { const localApi = readLocalApi(); @@ -379,7 +495,16 @@ export function useThreadActions() { unarchiveThread, deleteThread, confirmAndDeleteThread, + settleThread, + unsettleThread, }), - [archiveThread, confirmAndDeleteThread, deleteThread, unarchiveThread], + [ + archiveThread, + confirmAndDeleteThread, + deleteThread, + settleThread, + unarchiveThread, + unsettleThread, + ], ); } diff --git a/apps/web/src/lib/threadSort.test.ts b/apps/web/src/lib/threadSort.test.ts index b9981bc2e3e..ca9a5986c66 100644 --- a/apps/web/src/lib/threadSort.test.ts +++ b/apps/web/src/lib/threadSort.test.ts @@ -26,6 +26,8 @@ function makeThread(overrides: Partial = {}): Thread { proposedPlans: [], createdAt: "2026-03-09T10:00:00.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, updatedAt: "2026-03-09T10:00:00.000Z", latestTurn: null, diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 3a9140e278c..c48b10d2c12 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -19,6 +19,7 @@ import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybi import { Route as SettingsGeneralRouteImport } from './routes/settings.general' import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagnostics' import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections' +import { Route as SettingsBetaRouteImport } from './routes/settings.beta' import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId' @@ -72,6 +73,11 @@ const SettingsConnectionsRoute = SettingsConnectionsRouteImport.update({ path: '/connections', getParentRoute: () => SettingsRoute, } as any) +const SettingsBetaRoute = SettingsBetaRouteImport.update({ + id: '/beta', + path: '/beta', + getParentRoute: () => SettingsRoute, +} as any) const SettingsArchivedRoute = SettingsArchivedRouteImport.update({ id: '/archived', path: '/archived', @@ -94,6 +100,7 @@ export interface FileRoutesByFullPath { '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren '/settings/archived': typeof SettingsArchivedRoute + '/settings/beta': typeof SettingsBetaRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute '/settings/general': typeof SettingsGeneralRoute @@ -107,6 +114,7 @@ export interface FileRoutesByTo { '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren '/settings/archived': typeof SettingsArchivedRoute + '/settings/beta': typeof SettingsBetaRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute '/settings/general': typeof SettingsGeneralRoute @@ -123,6 +131,7 @@ export interface FileRoutesById { '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren '/settings/archived': typeof SettingsArchivedRoute + '/settings/beta': typeof SettingsBetaRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute '/settings/general': typeof SettingsGeneralRoute @@ -140,6 +149,7 @@ export interface FileRouteTypes { | '/pair' | '/settings' | '/settings/archived' + | '/settings/beta' | '/settings/connections' | '/settings/diagnostics' | '/settings/general' @@ -153,6 +163,7 @@ export interface FileRouteTypes { | '/pair' | '/settings' | '/settings/archived' + | '/settings/beta' | '/settings/connections' | '/settings/diagnostics' | '/settings/general' @@ -168,6 +179,7 @@ export interface FileRouteTypes { | '/pair' | '/settings' | '/settings/archived' + | '/settings/beta' | '/settings/connections' | '/settings/diagnostics' | '/settings/general' @@ -257,6 +269,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsConnectionsRouteImport parentRoute: typeof SettingsRoute } + '/settings/beta': { + id: '/settings/beta' + path: '/beta' + fullPath: '/settings/beta' + preLoaderRoute: typeof SettingsBetaRouteImport + parentRoute: typeof SettingsRoute + } '/settings/archived': { id: '/settings/archived' path: '/archived' @@ -297,6 +316,7 @@ const ChatRouteWithChildren = ChatRoute._addFileChildren(ChatRouteChildren) interface SettingsRouteChildren { SettingsArchivedRoute: typeof SettingsArchivedRoute + SettingsBetaRoute: typeof SettingsBetaRoute SettingsConnectionsRoute: typeof SettingsConnectionsRoute SettingsDiagnosticsRoute: typeof SettingsDiagnosticsRoute SettingsGeneralRoute: typeof SettingsGeneralRoute @@ -307,6 +327,7 @@ interface SettingsRouteChildren { const SettingsRouteChildren: SettingsRouteChildren = { SettingsArchivedRoute: SettingsArchivedRoute, + SettingsBetaRoute: SettingsBetaRoute, SettingsConnectionsRoute: SettingsConnectionsRoute, SettingsDiagnosticsRoute: SettingsDiagnosticsRoute, SettingsGeneralRoute: SettingsGeneralRoute, diff --git a/apps/web/src/routes/settings.beta.tsx b/apps/web/src/routes/settings.beta.tsx new file mode 100644 index 00000000000..a1e78f2dff7 --- /dev/null +++ b/apps/web/src/routes/settings.beta.tsx @@ -0,0 +1,11 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { BetaSettingsPanel } from "../components/settings/BetaSettingsPanel"; + +function SettingsBetaRoute() { + return ; +} + +export const Route = createFileRoute("/settings/beta")({ + component: SettingsBetaRoute, +}); diff --git a/apps/web/src/worktreeCleanup.test.ts b/apps/web/src/worktreeCleanup.test.ts index 13ff2f0f73e..89734357889 100644 --- a/apps/web/src/worktreeCleanup.test.ts +++ b/apps/web/src/worktreeCleanup.test.ts @@ -26,6 +26,8 @@ function makeThread(overrides: Partial = {}): Thread { createdAt: "2026-02-13T00:00:00.000Z", updatedAt: "2026-02-13T00:00:00.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, latestTurn: null, branch: null, diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index d9e19889721..4fa05f850e5 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -127,6 +127,10 @@ "types": "./src/state/threadSort.ts", "default": "./src/state/threadSort.ts" }, + "./state/thread-settled": { + "types": "./src/state/threadSettled.ts", + "default": "./src/state/threadSettled.ts" + }, "./state/vcs": { "types": "./src/state/vcs.ts", "default": "./src/state/vcs.ts" diff --git a/packages/client-runtime/src/operations/commands.test.ts b/packages/client-runtime/src/operations/commands.test.ts index 5cc3f0c1a86..0cb1650066c 100644 --- a/packages/client-runtime/src/operations/commands.test.ts +++ b/packages/client-runtime/src/operations/commands.test.ts @@ -21,7 +21,13 @@ import { import * as EnvironmentSupervisor from "../connection/supervisor.ts"; import * as RpcSession from "../rpc/session.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; -import { archiveThread, createProject, stopThreadSession } from "./commands.ts"; +import { + archiveThread, + createProject, + settleThread, + stopThreadSession, + unsettleThread, +} from "./commands.ts"; const TEST_CRYPTO_LAYER = Layer.succeed( Crypto.Crypto, @@ -134,4 +140,35 @@ describe("environment commands", () => { ]); }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), ); + + it.effect("dispatches settle and unsettle commands without timestamps", () => + Effect.gen(function* () { + const dispatched: ClientOrchestrationCommand[] = []; + const supervisor = yield* makeSupervisor(dispatched); + + yield* settleThread({ + commandId: CommandId.make("settle-command"), + threadId: ThreadId.make("thread-1"), + }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); + yield* unsettleThread({ + commandId: CommandId.make("unsettle-command"), + threadId: ThreadId.make("thread-1"), + reason: "user", + }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); + + expect(dispatched).toEqual([ + { + type: "thread.settle", + commandId: "settle-command", + threadId: "thread-1", + }, + { + type: "thread.unsettle", + commandId: "unsettle-command", + threadId: "thread-1", + reason: "user", + }, + ]); + }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), + ); }); diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index a0c3cbe771f..ef767854804 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -35,6 +35,8 @@ export type CreateThreadInput = CommandInput<"thread.create">; export type DeleteThreadInput = CommandInput<"thread.delete">; export type ArchiveThreadInput = CommandInput<"thread.archive">; export type UnarchiveThreadInput = CommandInput<"thread.unarchive">; +export type SettleThreadInput = CommandInput<"thread.settle">; +export type UnsettleThreadInput = CommandInput<"thread.unsettle">; export type UpdateThreadMetadataInput = CommandInput<"thread.meta.update">; export type SetThreadRuntimeModeInput = CommandInput<"thread.runtime-mode.set">; export type SetThreadInteractionModeInput = CommandInput<"thread.interaction-mode.set">; @@ -153,6 +155,26 @@ export const unarchiveThread: (input: UnarchiveThreadInput) => CommandEffect = E }); }); +export const settleThread: (input: SettleThreadInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.settleThread", +)(function* (input) { + return yield* dispatch({ + ...input, + type: "thread.settle", + commandId: yield* commandId(input), + }); +}); + +export const unsettleThread: (input: UnsettleThreadInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.unsettleThread", +)(function* (input) { + return yield* dispatch({ + ...input, + type: "thread.unsettle", + commandId: yield* commandId(input), + }); +}); + export const updateThreadMetadata: (input: UpdateThreadMetadataInput) => CommandEffect = Effect.fn( "EnvironmentCommands.updateThreadMetadata", )(function* (input) { diff --git a/packages/client-runtime/src/state/entities.test.ts b/packages/client-runtime/src/state/entities.test.ts index c772d134a67..e08fd9e552f 100644 --- a/packages/client-runtime/src/state/entities.test.ts +++ b/packages/client-runtime/src/state/entities.test.ts @@ -96,6 +96,8 @@ const THREAD_SHELL = { createdAt: "2026-06-01T00:00:00.000Z", updatedAt: "2026-06-01T00:00:00.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, session: null, latestUserMessageAt: null, hasPendingApprovals: false, diff --git a/packages/client-runtime/src/state/shellReducer.test.ts b/packages/client-runtime/src/state/shellReducer.test.ts index a069460e63c..fdccc4c47dd 100644 --- a/packages/client-runtime/src/state/shellReducer.test.ts +++ b/packages/client-runtime/src/state/shellReducer.test.ts @@ -36,6 +36,8 @@ const stubThread = { createdAt: "2026-04-01T00:00:00.000Z", updatedAt: "2026-04-01T00:00:00.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, latestUserMessageAt: null, hasPendingApprovals: false, hasPendingUserInput: false, diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index aab5110e9cf..ea87298e98f 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -12,9 +12,11 @@ import { type RevertThreadCheckpointInput, type SetThreadInteractionModeInput, type SetThreadRuntimeModeInput, + type SettleThreadInput, type StartThreadTurnInput, type StopThreadSessionInput, type UnarchiveThreadInput, + type UnsettleThreadInput, type UpdateThreadMetadataInput, archiveThread, createThread, @@ -25,9 +27,11 @@ import { revertThreadCheckpoint, setThreadInteractionMode, setThreadRuntimeMode, + settleThread, startThreadTurn, stopThreadSession, unarchiveThread, + unsettleThread, updateThreadMetadata, } from "../operations/commands.ts"; import type { EnvironmentRegistry } from "../connection/registry.ts"; @@ -42,9 +46,11 @@ export type { RevertThreadCheckpointInput, SetThreadInteractionModeInput, SetThreadRuntimeModeInput, + SettleThreadInput, StartThreadTurnInput, StopThreadSessionInput, UnarchiveThreadInput, + UnsettleThreadInput, UpdateThreadMetadataInput, } from "../operations/commands.ts"; @@ -82,6 +88,18 @@ export function createThreadEnvironmentAtoms( scheduler, concurrency, }), + settle: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:settle", + execute: (input: SettleThreadInput) => settleThread(input), + scheduler, + concurrency, + }), + unsettle: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:unsettle", + execute: (input: UnsettleThreadInput) => unsettleThread(input), + scheduler, + concurrency, + }), updateMetadata: createEnvironmentCommand(runtime, { label: "environment-data:commands:thread:update-metadata", execute: (input: UpdateThreadMetadataInput) => updateThreadMetadata(input), diff --git a/packages/client-runtime/src/state/threadDetail.ts b/packages/client-runtime/src/state/threadDetail.ts index 430900bdbb0..770738ad9bb 100644 --- a/packages/client-runtime/src/state/threadDetail.ts +++ b/packages/client-runtime/src/state/threadDetail.ts @@ -57,6 +57,8 @@ export function mergeEnvironmentThread( createdAt: shell.createdAt, updatedAt: shell.updatedAt, archivedAt: shell.archivedAt, + settledOverride: shell.settledOverride, + settledAt: shell.settledAt, session: shell.session, }; } diff --git a/packages/client-runtime/src/state/threadReducer.test.ts b/packages/client-runtime/src/state/threadReducer.test.ts index 94eb1c65370..211f8748f4e 100644 --- a/packages/client-runtime/src/state/threadReducer.test.ts +++ b/packages/client-runtime/src/state/threadReducer.test.ts @@ -34,6 +34,8 @@ const baseThread: OrchestrationThread = { createdAt: "2026-04-01T00:00:00.000Z", updatedAt: "2026-04-01T00:00:00.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, messages: [], proposedPlans: [], @@ -164,6 +166,62 @@ describe("applyThreadDetailEvent", () => { }); }); + describe("thread.settled / thread.unsettled", () => { + it("sets the settled override and timestamp", () => { + const settledAt = "2026-04-01T05:00:00.000Z"; + const result = applyThreadDetailEvent(baseThread, { + ...baseEventFields, + sequence: 5, + occurredAt: settledAt, + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.settled", + payload: { + threadId: ThreadId.make("thread-1"), + settledAt, + updatedAt: settledAt, + }, + }); + + expect(result.kind).toBe("updated"); + if (result.kind === "updated") { + expect(result.thread.settledOverride).toBe("settled"); + expect(result.thread.settledAt).toBe(settledAt); + } + }); + + it.each([ + ["user", "active"], + ["activity", null], + ] as const)("unsettles for %s with override %s", (reason, settledOverride) => { + const settledThread: OrchestrationThread = { + ...baseThread, + settledOverride: "settled", + settledAt: "2026-04-01T05:00:00.000Z", + }; + const updatedAt = "2026-04-01T06:00:00.000Z"; + const result = applyThreadDetailEvent(settledThread, { + ...baseEventFields, + sequence: 6, + occurredAt: updatedAt, + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.unsettled", + payload: { + threadId: ThreadId.make("thread-1"), + reason, + updatedAt, + }, + }); + + expect(result.kind).toBe("updated"); + if (result.kind === "updated") { + expect(result.thread.settledOverride).toBe(settledOverride); + expect(result.thread.settledAt).toBeNull(); + } + }); + }); + describe("thread.meta-updated", () => { it("patches title and branch", () => { const result = applyThreadDetailEvent(baseThread, { diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index 670540fee70..7fdee37c0e6 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -72,6 +72,8 @@ export function applyThreadDetailEvent( createdAt: event.payload.createdAt, updatedAt: event.payload.updatedAt, archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, messages: [], proposedPlans: [], @@ -100,6 +102,28 @@ export function applyThreadDetailEvent( thread: { ...thread, archivedAt: null, updatedAt: event.payload.updatedAt }, }; + case "thread.settled": + return { + kind: "updated", + thread: { + ...thread, + settledOverride: "settled", + settledAt: event.payload.settledAt, + updatedAt: event.payload.updatedAt, + }, + }; + + case "thread.unsettled": + return { + kind: "updated", + thread: { + ...thread, + settledOverride: event.payload.reason === "user" ? "active" : null, + settledAt: null, + updatedAt: event.payload.updatedAt, + }, + }; + // ── Thread metadata ───────────────────────────────────────────── case "thread.meta-updated": return { diff --git a/packages/client-runtime/src/state/threadSettled.test.ts b/packages/client-runtime/src/state/threadSettled.test.ts new file mode 100644 index 00000000000..558d8ab2537 --- /dev/null +++ b/packages/client-runtime/src/state/threadSettled.test.ts @@ -0,0 +1,178 @@ +import { + ProjectId, + ProviderInstanceId, + ThreadId, + TurnId, + type OrchestrationThreadShell, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + effectiveSettled, + threadLastActivityAt, + type ChangeRequestStateLike, +} from "./threadSettled.ts"; + +const NOW = "2026-04-10T00:00:00.000Z"; +const FRESH = "2026-04-09T00:00:00.000Z"; +const STALE = "2026-04-06T23:59:59.999Z"; + +function makeShell(input: { + readonly settledOverride: OrchestrationThreadShell["settledOverride"]; + readonly activityAt: string | null; + readonly sessionStatus?: "starting" | "running"; +}): OrchestrationThreadShell { + const threadId = ThreadId.make("thread-1"); + return { + id: threadId, + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: + input.activityAt === null + ? null + : { + turnId: TurnId.make("turn-1"), + state: "completed", + requestedAt: input.activityAt, + startedAt: null, + completedAt: null, + assistantMessageId: null, + }, + createdAt: "2026-04-01T00:00:00.000Z", + updatedAt: NOW, + archivedAt: null, + settledOverride: input.settledOverride, + settledAt: input.settledOverride === "settled" ? NOW : null, + session: + input.sessionStatus === undefined + ? null + : { + threadId, + status: input.sessionStatus, + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: NOW, + }, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + }; +} + +describe("threadLastActivityAt", () => { + it("returns the latest real user or turn activity and ignores thread/session updates", () => { + const shell = makeShell({ settledOverride: null, activityAt: null, sessionStatus: "running" }); + const withActivity: OrchestrationThreadShell = { + ...shell, + latestUserMessageAt: "2026-04-04T00:00:00.000Z", + latestTurn: { + turnId: TurnId.make("turn-1"), + state: "completed", + requestedAt: "2026-04-03T00:00:00.000Z", + startedAt: "2026-04-05T00:00:00.000Z", + completedAt: "2026-04-06T00:00:00.000Z", + assistantMessageId: null, + }, + }; + + expect(threadLastActivityAt(withActivity)).toBe("2026-04-06T00:00:00.000Z"); + expect(threadLastActivityAt(shell)).toBeNull(); + }); +}); + +describe("effectiveSettled", () => { + const overrides = ["settled", "active", null] as const; + const changeRequestStates = [undefined, "open", "merged"] as const; + const inactivityCases = [ + ["fresh", FRESH], + ["stale", STALE], + ["no-activity", null], + ] as const; + const runningCases = [false, true] as const; + const truthTable = overrides.flatMap((settledOverride) => + changeRequestStates.flatMap((changeRequestState) => + inactivityCases.flatMap(([inactivity, activityAt]) => + runningCases.map((running) => ({ + settledOverride, + changeRequestState, + inactivity, + activityAt, + running, + expected: + settledOverride === "settled" || + (settledOverride === null && + !running && + (changeRequestState === "merged" || inactivity === "stale")), + })), + ), + ), + ); + + it.each(truthTable)( + "override=$settledOverride pr=$changeRequestState inactivity=$inactivity running=$running", + ({ settledOverride, changeRequestState, activityAt, running, expected }) => { + const shell = makeShell({ + settledOverride, + activityAt, + ...(running ? { sessionStatus: "running" as const } : {}), + }); + const changeRequestOptions = + changeRequestState === undefined + ? {} + : { changeRequestState: changeRequestState as ChangeRequestStateLike }; + + expect( + effectiveSettled(shell, { + now: NOW, + autoSettleAfterDays: 3, + ...changeRequestOptions, + }), + ).toBe(expected); + }, + ); + + it("treats closed change requests like merged ones", () => { + const shell = makeShell({ settledOverride: null, activityAt: null }); + expect( + effectiveSettled(shell, { + now: NOW, + autoSettleAfterDays: null, + changeRequestState: "closed", + }), + ).toBe(true); + }); + + it("never auto-settles a starting session", () => { + const shell = makeShell({ + settledOverride: null, + activityAt: STALE, + sessionStatus: "starting", + }); + expect( + effectiveSettled(shell, { + now: NOW, + autoSettleAfterDays: 3, + changeRequestState: "merged", + }), + ).toBe(false); + }); + + it("uses a strict inactivity boundary and honors a null threshold", () => { + const boundary = makeShell({ + settledOverride: null, + activityAt: "2026-04-07T00:00:00.000Z", + }); + const stale = makeShell({ settledOverride: null, activityAt: STALE }); + + expect(effectiveSettled(boundary, { now: NOW, autoSettleAfterDays: 3 })).toBe(false); + expect(effectiveSettled(stale, { now: NOW, autoSettleAfterDays: null })).toBe(false); + }); +}); diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts new file mode 100644 index 00000000000..5d7e2d92369 --- /dev/null +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -0,0 +1,51 @@ +import type { OrchestrationThreadShell } from "@t3tools/contracts"; + +export type ChangeRequestStateLike = "open" | "closed" | "merged"; + +const DAY_MS = 24 * 60 * 60 * 1_000; + +export function threadLastActivityAt(shell: OrchestrationThreadShell): string | null { + const candidates = [ + shell.latestUserMessageAt, + shell.latestTurn?.requestedAt, + shell.latestTurn?.startedAt, + shell.latestTurn?.completedAt, + ]; + let latest: string | null = null; + let latestTimestamp = Number.NEGATIVE_INFINITY; + + for (const candidate of candidates) { + if (candidate === null || candidate === undefined) continue; + const timestamp = Date.parse(candidate); + if (timestamp > latestTimestamp) { + latest = candidate; + latestTimestamp = timestamp; + } + } + + return latest; +} + +export function effectiveSettled( + shell: OrchestrationThreadShell, + options: { + readonly now: string; + readonly autoSettleAfterDays: number | null; + readonly changeRequestState?: ChangeRequestStateLike | null; + }, +): boolean { + if (shell.settledOverride === "settled") return true; + if (shell.settledOverride === "active") return false; + if (shell.session?.status === "starting" || shell.session?.status === "running") return false; + if (options.changeRequestState === "merged" || options.changeRequestState === "closed") { + return true; + } + if (options.autoSettleAfterDays === null) return false; + + const lastActivityAt = threadLastActivityAt(shell); + if (lastActivityAt === null) return false; + + return ( + Date.parse(lastActivityAt) < Date.parse(options.now) - options.autoSettleAfterDays * DAY_MS + ); +} diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index 8e982723d22..2ce8026a62f 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -67,6 +67,8 @@ const BASE_THREAD: OrchestrationThread = { createdAt: "2026-04-01T00:00:00.000Z", updatedAt: "2026-04-01T00:00:00.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, messages: [], proposedPlans: [], diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 29a732ca69b..bdc7169821e 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -15,6 +15,8 @@ import { ProjectMetaUpdatedPayload, OrchestrationProposedPlan, OrchestrationSession, + OrchestrationThread, + OrchestrationThreadShell, ProjectCreateCommand, ThreadMetaUpdatedPayload, ThreadTurnStartCommand, @@ -37,6 +39,8 @@ const decodeThreadTurnStartRequestedPayload = Schema.decodeUnknownEffect( const decodeOrchestrationLatestTurn = Schema.decodeUnknownEffect(OrchestrationLatestTurn); const decodeOrchestrationProposedPlan = Schema.decodeUnknownEffect(OrchestrationProposedPlan); const decodeOrchestrationSession = Schema.decodeUnknownEffect(OrchestrationSession); +const decodeOrchestrationThread = Schema.decodeUnknownEffect(OrchestrationThread); +const decodeOrchestrationThreadShell = Schema.decodeUnknownEffect(OrchestrationThreadShell); const encodeThreadCreatedPayload = Schema.encodeEffect(ThreadCreatedPayload); function getOptionValue( @@ -344,6 +348,65 @@ it.effect("decodes thread archive and unarchive commands", () => }), ); +it.effect("decodes thread settle and unsettle commands", () => + Effect.gen(function* () { + const settle = yield* decodeOrchestrationCommand({ + type: "thread.settle", + commandId: "cmd-settle-1", + threadId: "thread-1", + }); + const unsettle = yield* decodeOrchestrationCommand({ + type: "thread.unsettle", + commandId: "cmd-unsettle-1", + threadId: "thread-1", + reason: "activity", + }); + + assert.strictEqual(settle.type, "thread.settle"); + assert.strictEqual(unsettle.type, "thread.unsettle"); + }), +); + +it.effect("defaults settled fields when decoding historical thread data", () => + Effect.gen(function* () { + const common = { + id: "thread-1", + projectId: "project-1", + title: "Historical thread", + modelSelection: { provider: "codex", model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + archivedAt: null, + session: null, + }; + const thread = yield* decodeOrchestrationThread({ + ...common, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + }); + const shell = yield* decodeOrchestrationThreadShell({ + ...common, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + }); + + assert.strictEqual(thread.settledOverride, null); + assert.strictEqual(thread.settledAt, null); + assert.strictEqual(shell.settledOverride, null); + assert.strictEqual(shell.settledAt, null); + }), +); + it.effect("decodes thread archived and unarchived events", () => Effect.gen(function* () { const archived = yield* decodeOrchestrationEvent({ @@ -388,6 +451,48 @@ it.effect("decodes thread archived and unarchived events", () => }), ); +it.effect("decodes thread settled and unsettled events", () => + Effect.gen(function* () { + const settled = yield* decodeOrchestrationEvent({ + sequence: 1, + eventId: "event-settle-1", + aggregateKind: "thread", + aggregateId: "thread-1", + type: "thread.settled", + occurredAt: "2026-01-01T00:00:00.000Z", + commandId: "cmd-settle-1", + causationEventId: null, + correlationId: "cmd-settle-1", + metadata: {}, + payload: { + threadId: "thread-1", + settledAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + }); + const unsettled = yield* decodeOrchestrationEvent({ + sequence: 2, + eventId: "event-unsettle-1", + aggregateKind: "thread", + aggregateId: "thread-1", + type: "thread.unsettled", + occurredAt: "2026-01-02T00:00:00.000Z", + commandId: "cmd-unsettle-1", + causationEventId: null, + correlationId: "cmd-unsettle-1", + metadata: {}, + payload: { + threadId: "thread-1", + reason: "user", + updatedAt: "2026-01-02T00:00:00.000Z", + }, + }); + + assert.strictEqual(settled.type, "thread.settled"); + assert.strictEqual(unsettled.type, "thread.unsettled"); + }), +); + it.effect("accepts provider-scoped model options in thread.turn.start", () => Effect.gen(function* () { const parsed = yield* decodeThreadTurnStartCommand({ diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 7e9c421b5df..80b7177f9b7 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -356,6 +356,10 @@ export const OrchestrationThread = Schema.Struct({ createdAt: IsoDateTime, updatedAt: IsoDateTime, archivedAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))), + settledOverride: Schema.NullOr(Schema.Literals(["settled", "active"])).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), + settledAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))), deletedAt: Schema.NullOr(IsoDateTime), messages: Schema.Array(OrchestrationMessage), proposedPlans: Schema.Array(OrchestrationProposedPlan).pipe( @@ -402,6 +406,10 @@ export const OrchestrationThreadShell = Schema.Struct({ createdAt: IsoDateTime, updatedAt: IsoDateTime, archivedAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))), + settledOverride: Schema.NullOr(Schema.Literals(["settled", "active"])).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), + settledAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))), session: Schema.NullOr(OrchestrationSession), latestUserMessageAt: Schema.NullOr(IsoDateTime), hasPendingApprovals: Schema.Boolean, @@ -545,6 +553,19 @@ const ThreadUnarchiveCommand = Schema.Struct({ threadId: ThreadId, }); +const ThreadSettleCommand = Schema.Struct({ + type: Schema.Literal("thread.settle"), + commandId: CommandId, + threadId: ThreadId, +}); + +const ThreadUnsettleCommand = Schema.Struct({ + type: Schema.Literal("thread.unsettle"), + commandId: CommandId, + threadId: ThreadId, + reason: Schema.Literals(["user", "activity"]), +}); + const ThreadMetaUpdateCommand = Schema.Struct({ type: Schema.Literal("thread.meta.update"), commandId: CommandId, @@ -687,6 +708,8 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ ThreadDeleteCommand, ThreadArchiveCommand, ThreadUnarchiveCommand, + ThreadSettleCommand, + ThreadUnsettleCommand, ThreadMetaUpdateCommand, ThreadRuntimeModeSetCommand, ThreadInteractionModeSetCommand, @@ -708,6 +731,8 @@ export const ClientOrchestrationCommand = Schema.Union([ ThreadDeleteCommand, ThreadArchiveCommand, ThreadUnarchiveCommand, + ThreadSettleCommand, + ThreadUnsettleCommand, ThreadMetaUpdateCommand, ThreadRuntimeModeSetCommand, ThreadInteractionModeSetCommand, @@ -810,6 +835,8 @@ export const OrchestrationEventType = Schema.Literals([ "thread.deleted", "thread.archived", "thread.unarchived", + "thread.settled", + "thread.unsettled", "thread.meta-updated", "thread.runtime-mode-set", "thread.interaction-mode-set", @@ -889,6 +916,18 @@ export const ThreadUnarchivedPayload = Schema.Struct({ updatedAt: IsoDateTime, }); +export const ThreadSettledPayload = Schema.Struct({ + threadId: ThreadId, + settledAt: IsoDateTime, + updatedAt: IsoDateTime, +}); + +export const ThreadUnsettledPayload = Schema.Struct({ + threadId: ThreadId, + reason: Schema.Literals(["user", "activity"]), + updatedAt: IsoDateTime, +}); + export const ThreadMetaUpdatedPayload = Schema.Struct({ threadId: ThreadId, title: Schema.optional(TrimmedNonEmptyString), @@ -1056,6 +1095,16 @@ export const OrchestrationEvent = Schema.Union([ type: Schema.Literal("thread.unarchived"), payload: ThreadUnarchivedPayload, }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.settled"), + payload: ThreadSettledPayload, + }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.unsettled"), + payload: ThreadUnsettledPayload, + }), Schema.Struct({ ...EventBaseFields, type: Schema.Literal("thread.meta-updated"), diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index ac2d47ca336..773a37f448b 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -31,6 +31,20 @@ describe("ClientSettings word wrap", () => { }); }); +describe("ClientSettings sidebar v2", () => { + it("defaults the beta off with a three-day auto-settle threshold", () => { + const settings = decodeClientSettings({}); + expect(settings.sidebarV2Enabled).toBe(false); + expect(settings.sidebarAutoSettleAfterDays).toBe(3); + }); + + it("allows auto-settle by inactivity to be disabled", () => { + expect( + decodeClientSettings({ sidebarAutoSettleAfterDays: null }).sidebarAutoSettleAfterDays, + ).toBeNull(); + }); +}); + describe("ServerSettings.providerInstances (slice-2 invariant)", () => { it("defaults to an empty record so legacy configs without the key still decode", () => { expect(DEFAULT_SERVER_SETTINGS.providerInstances).toEqual({}); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 6ccd65533dd..ecf7c47678c 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -72,6 +72,9 @@ export const ClientSettingsSchema = Schema.Struct({ modelOrder: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))), }), ).pipe(Schema.withDecodingDefault(Effect.succeed({}))), + sidebarAutoSettleAfterDays: Schema.NullOr(Schema.Number).pipe( + Schema.withDecodingDefault(Effect.succeed(3)), + ), sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)), ), @@ -88,6 +91,7 @@ export const ClientSettingsSchema = Schema.Struct({ sidebarThreadPreviewCount: SidebarThreadPreviewCount.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT)), ), + sidebarV2Enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), timestampFormat: TimestampFormat.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)), ), @@ -559,6 +563,7 @@ export const ClientSettingsPatch = Schema.Struct({ }), ), ), + sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(Schema.Number)), sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode), sidebarProjectGroupingOverrides: Schema.optionalKey( Schema.Record(TrimmedNonEmptyString, SidebarProjectGroupingMode), @@ -566,6 +571,7 @@ export const ClientSettingsPatch = Schema.Struct({ sidebarProjectSortOrder: Schema.optionalKey(SidebarProjectSortOrder), sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder), sidebarThreadPreviewCount: Schema.optionalKey(SidebarThreadPreviewCount), + sidebarV2Enabled: Schema.optionalKey(Schema.Boolean), timestampFormat: Schema.optionalKey(TimestampFormat), wordWrap: Schema.optionalKey(Schema.Boolean), }); From e17a333d3f356526799cdb10c2db65fef9f50328 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 14 Jul 2026 02:56:04 -0700 Subject: [PATCH 2/5] Iterate sidebar v2 from live feedback: card islands, project scope, creation picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design iteration after testing the beta over Tailscale: - Settled is the only collapse rule. Reverted the "seen Ready threads render slim" heuristic — it silently turned settled back into a derived state. Every unsettled thread is a full card; density is earned by settling. - Cards are bordered islands (rounded-xl, state-colored border: amber approval / sky working / red failed), status dot replaces the favicon on stateful cards, live elapsed timer on working. Slim rows grew to 34px with 13px titles; a hairline divider separates cards from the settled tail. - Harness identity is the provider glyph only (model name in tooltip). The full-row harness tint experiment is removed. - Hover actions (Settle/Un-settle) are absolutely-positioned overlays that fade over the timestamp — no layout shift. - Project scope chips above the list (All / per-project / + add): filters the flat list and informs the new-thread default. Session-local. - New thread opens a project picker dialog: contextual default preselected and focused (Enter confirms), all projects with workspace paths, Add project row. Single-project setups skip the dialog. chat.new (mod+shift+o) routes through the picker via newThreadPickerBus since the shortcut is handled in the _chat route layout. Arrow/Home/End navigation in the list. - chat.newLocal still bypasses the picker as the explicit escape hatch. Co-Authored-By: Claude Fable 5 --- apps/web/src/components/SidebarV2.tsx | 515 +++++++++++++++++++++----- apps/web/src/newThreadPickerBus.ts | 14 + apps/web/src/routes/_chat.tsx | 14 + 3 files changed, 452 insertions(+), 91 deletions(-) create mode 100644 apps/web/src/newThreadPickerBus.ts diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 15ee72a2f19..c46cb39fc9b 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -42,7 +42,14 @@ import { readLocalApi } from "../localApi"; import { useUiStateStore } from "../uiStateStore"; import { useThreadSelectionStore } from "../threadSelectionStore"; import { useThreadActions } from "../hooks/useThreadActions"; -import { useNewThreadHandler } from "../hooks/useHandleNewThread"; +import { useHandleNewThread } from "../hooks/useHandleNewThread"; +import { useOpenAddProjectCommandPalette } from "../commandPaletteContext"; +import { onOpenNewThreadPicker } from "../newThreadPickerBus"; +import { Dialog, DialogHeader, DialogPopup, DialogTitle } from "./ui/dialog"; +import { + startNewThreadFromContext, + startNewThreadInProjectFromContext, +} from "../lib/chatThreadActions"; import { useClientSettings } from "../hooks/useSettings"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { useProjects, useThreadShells } from "../state/entities"; @@ -82,7 +89,16 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; // Row heights are fixed per variant so the list only changes shape at // lifecycle transitions (settle/unsettle), never from streaming updates. -const RAIL_CLASS_BY_STATUS: Partial> = { +// Cards are bordered islands (the mock's language): the border carries the +// state color, faint enough to read as a glow rather than a box. +const CARD_BORDER_BY_STATUS: Record = { + approval: "border-amber-500/45 dark:border-amber-300/35", + working: "border-sky-500/40 dark:border-sky-300/30", + failed: "border-red-500/45 dark:border-red-400/35", + ready: "border-border", +}; + +const STATUS_DOT_BY_STATUS: Partial> = { approval: "bg-amber-500 dark:bg-amber-300/90", working: "bg-sky-500 dark:bg-sky-300/80 animate-pulse", failed: "bg-red-500 dark:bg-red-400/90", @@ -91,7 +107,7 @@ const RAIL_CLASS_BY_STATUS: Partial> = { const STATUS_WORD_BY_STATUS: Partial< Record > = { - approval: { label: "Approval", className: "text-amber-600 dark:text-amber-300/90" }, + approval: { label: "Needs approval", className: "text-amber-600 dark:text-amber-300/90" }, working: { label: "Working", className: "text-sky-600 dark:text-sky-300/80" }, failed: { label: "Failed", className: "text-red-600 dark:text-red-400/90" }, }; @@ -122,6 +138,13 @@ function threadTimeLabel(thread: SidebarThreadSummary, status: SidebarV2Status): const SidebarV2Row = memo(function SidebarV2Row(props: { thread: SidebarThreadSummary; variant: "card" | "slim"; + // Slim rows are either settled (action: un-settle) or merely quiet + // (seen Ready threads — action: settle). + variantAction: "settle" | "unsettle"; + // Draws a hairline above the first quiet row after the card block, so the + // active island and the history tail read as separate zones (mock's + // SETTLED rule) without a labeled section header. + showQuietDivider?: boolean; isActive: boolean; jumpLabel: string | null; currentEnvironmentId: string | null; @@ -142,6 +165,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { onUnsettle, thread, variant, + variantAction, } = props; const threadRef = useMemo( () => scopeThreadRef(thread.environmentId, thread.id), @@ -253,135 +277,180 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { return (
  • + {props.showQuietDivider ? ( +
    + ) : null}
    {favicon} - + {thread.title} {prBadge} - - - {props.jumpLabel ?? - formatRelativeTimeLabel(thread.latestUserMessageAt ?? thread.updatedAt)} + + + {props.jumpLabel ?? + formatRelativeTimeLabel(thread.latestUserMessageAt ?? thread.updatedAt)} + + {variantAction === "unsettle" ? ( + + ) : ( + + )}
  • ); } - const rail = RAIL_CLASS_BY_STATUS[status]; + const statusDot = STATUS_DOT_BY_STATUS[status]; const statusWord = STATUS_WORD_BY_STATUS[status]; const diff = latestTurnDiff(thread); + const workingTimer = + status === "working" && thread.latestTurn?.startedAt + ? formatElapsedDurationLabel(thread.latestTurn.startedAt) + : null; return (
  • - {rail ? ( - - ) : null} -
    - {favicon} +
    + {statusDot ? ( + + ) : ( + favicon + )} {thread.title} {diff ? ( - + +{diff.insertions}{" "} −{diff.deletions} ) : null} - - - {props.jumpLabel ?? threadTimeLabel(thread, status)} + + + {props.jumpLabel ?? workingTimer ?? threadTimeLabel(thread, status)} + +
    -
    +
    {statusWord ? ( - {statusWord.label} + + {statusWord.label} + ) : null} {status === "failed" && thread.session?.lastError ? ( {thread.session.lastError} - ) : null} - {thread.branch ? ( - {thread.branch} - ) : null} - {prBadge} - + ) : ( + <> + {thread.branch ? ( + + {thread.branch} + + ) : null} + {prBadge} + + )} + {driverKind ? ( - + + } + > + + + {thread.modelSelection.model} + + ) : null} + {isRemote ? ( + + + } + > + + + + Running on {props.environmentLabel ?? "a remote environment"} + + ) : null} - {thread.modelSelection.model} - {isRemote ? ( - - - } - > - - {props.environmentLabel ?? "Remote"} - - - Running on {props.environmentLabel ?? "a remote environment"} - - - ) : null}
  • @@ -406,7 +475,8 @@ export default function SidebarV2() { const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); const { settleThread, unsettleThread, archiveThread, deleteThread } = useThreadActions(); - const handleNewThread = useNewThreadHandler(); + const newThreadContext = useHandleNewThread(); + const openAddProjectCommandPalette = useOpenAddProjectCommandPalette(); const { environments } = useEnvironments(); const primaryEnvironmentId = usePrimaryEnvironmentId(); const clearSelection = useThreadSelectionStore((s) => s.clearSelection); @@ -480,9 +550,29 @@ export default function SidebarV2() { [], ); + // Project scope: chips above the list. Scoping filters the list AND + // becomes the new-thread target — one visible control doing both jobs the + // old per-project headers did. + const [projectScopeKey, setProjectScopeKey] = useState(null); + const scopedProject = useMemo( + () => + projectScopeKey === null + ? null + : (projects.find( + (project) => `${project.environmentId}:${project.id}` === projectScopeKey, + ) ?? null), + [projectScopeKey, projects], + ); + const { activeThreads, settledThreads } = useMemo(() => { const now = `${nowMinute}:00.000Z`; - const visible = threads.filter((thread) => thread.archivedAt === null); + const visible = threads.filter( + (thread) => + thread.archivedAt === null && + (scopedProject === null || + (thread.environmentId === scopedProject.environmentId && + thread.projectId === scopedProject.id)), + ); const active: EnvironmentThreadShell[] = []; const settled: EnvironmentThreadShell[] = []; for (const thread of visible) { @@ -502,7 +592,7 @@ export default function SidebarV2() { Date.parse(left.latestUserMessageAt ?? left.updatedAt), ), }; - }, [autoSettleAfterDays, changeRequestStateByKey, nowMinute, threads]); + }, [autoSettleAfterDays, changeRequestStateByKey, nowMinute, scopedProject, threads]); const orderedThreads = useMemo( () => [...activeThreads, ...settledThreads], @@ -884,12 +974,77 @@ export default function SidebarV2() { autoAnimate(node, { duration: 150, easing: "ease-out" }); }, []); + // New thread defaults to the project you're in (active thread's project, + // falling back to the top project) — same resolution the command palette + // uses. The chevron menu is the explicit project picker the flat list no + // longer gets from per-project headers. + const [newThreadPickerOpen, setNewThreadPickerOpen] = useState(false); + // chat.new (mod+shift+o / mod+n) is handled by the _chat route layout; in + // v2 with multiple projects it opens this picker via the event bus. + useEffect(() => onOpenNewThreadPicker(() => setNewThreadPickerOpen(true)), []); const handleNewThreadClick = useCallback(() => { - const firstProject = projects[0]; - if (!firstProject) return; - if (isMobile) setOpenMobile(false); - void handleNewThread(scopeProjectRef(firstProject.environmentId, firstProject.id)); - }, [handleNewThread, isMobile, projects, setOpenMobile]); + // One project: nothing to pick, create immediately. + if (projects.length <= 1) { + if (isMobile) setOpenMobile(false); + void startNewThreadFromContext({ + activeDraftThread: newThreadContext.activeDraftThread, + activeThread: newThreadContext.activeThread ?? undefined, + defaultProjectRef: newThreadContext.defaultProjectRef, + handleNewThread: newThreadContext.handleNewThread, + }); + return; + } + setNewThreadPickerOpen(true); + }, [isMobile, newThreadContext, projects.length, setOpenMobile]); + const createThreadInProject = useCallback( + (environmentId: (typeof projects)[number]["environmentId"], projectId: string) => { + setNewThreadPickerOpen(false); + if (isMobile) setOpenMobile(false); + const project = projects.find( + (candidate) => candidate.environmentId === environmentId && candidate.id === projectId, + ); + if (!project) return; + void startNewThreadInProjectFromContext( + { + activeDraftThread: newThreadContext.activeDraftThread, + activeThread: newThreadContext.activeThread ?? undefined, + defaultProjectRef: newThreadContext.defaultProjectRef, + handleNewThread: newThreadContext.handleNewThread, + }, + scopeProjectRef(project.environmentId, project.id), + ); + }, + [isMobile, newThreadContext, projects, setOpenMobile], + ); + const activeProjectRef = (() => { + const activeThread = routeThreadKey ? threadByKey.get(routeThreadKey) : null; + return activeThread + ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) + : null; + })(); + const newThreadTargetRef = scopedProject + ? scopeProjectRef(scopedProject.environmentId, scopedProject.id) + : (activeProjectRef ?? newThreadContext.defaultProjectRef); + const newThreadTargetProject = newThreadTargetRef + ? (projects.find( + (project) => + project.environmentId === newThreadTargetRef.environmentId && + project.id === newThreadTargetRef.projectId, + ) ?? null) + : null; + // Picker order: the contextual default first (preselected), everything else + // after — the common case is Enter/click on the top row. + const newThreadPickerProjects = useMemo(() => { + if (!newThreadTargetProject) return projects; + return [ + newThreadTargetProject, + ...projects.filter( + (project) => + project.environmentId !== newThreadTargetProject.environmentId || + project.id !== newThreadTargetProject.id, + ), + ]; + }, [newThreadTargetProject, projects]); const commandPaletteShortcutLabel = shortcutLabelForCommand(keybindings, "commandPalette.toggle"); const newThreadShortcutLabel = shortcutLabelForCommand(keybindings, "chat.new"); @@ -937,16 +1092,97 @@ export default function SidebarV2() { + {projects.length > 0 ? ( + +
    + {projects.length > 1 ? ( + + ) : null} + {projects.map((project) => { + const scopeKey = `${project.environmentId}:${project.id}`; + const isScoped = projectScopeKey === scopeKey; + return ( + + ); + })} + + + } + > + + + Add project + +
    +
    + ) : null} -
      - {orderedThreads.map((thread) => { +
        + {orderedThreads.map((thread, threadIndex) => { const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); const isSettledRow = settledThreadKeys.has(threadKey); + // Settled is the ONLY thing that collapses a row: every + // not-settled thread is a full card. Density comes from users + // (or the auto rules) actually settling work, not from the + // sidebar second-guessing what still matters. + const isCard = !isSettledRow; + const previousThread = threadIndex > 0 ? orderedThreads[threadIndex - 1] : null; + const previousWasCard = + previousThread != null && + !settledThreadKeys.has( + scopedThreadKey(scopeThreadRef(previousThread.environmentId, previousThread.id)), + ); + const showQuietDivider = !isCard && previousWasCard; return ( {orderedThreads.length === 0 ? ( -
        - No threads yet +
        + {projects.length === 0 ? ( + <> + No projects yet + + + ) : scopedProject ? ( + `No threads in ${scopedProject.title} yet` + ) : ( + "No threads yet" + )}
        ) : null} + + + + New thread in… + +
        { + if ( + event.key !== "ArrowDown" && + event.key !== "ArrowUp" && + event.key !== "Home" && + event.key !== "End" + ) { + return; + } + const container = event.currentTarget; + const options = [...container.querySelectorAll("button")]; + if (options.length === 0) return; + const currentIndex = options.findIndex((option) => option === document.activeElement); + const nextIndex = + event.key === "Home" + ? 0 + : event.key === "End" + ? options.length - 1 + : event.key === "ArrowDown" + ? (currentIndex + 1) % options.length + : (currentIndex - 1 + options.length) % options.length; + event.preventDefault(); + options[nextIndex]?.focus(); + }} + > + {newThreadPickerProjects.map((project, index) => { + const isDefault = index === 0 && newThreadTargetProject !== null; + return ( + + ); + })} + +
        +
        +
        ); } diff --git a/apps/web/src/newThreadPickerBus.ts b/apps/web/src/newThreadPickerBus.ts new file mode 100644 index 00000000000..a1cc7560e20 --- /dev/null +++ b/apps/web/src/newThreadPickerBus.ts @@ -0,0 +1,14 @@ +// Tiny event bus connecting the global chat.new shortcut (handled in the +// _chat route layout) to SidebarV2's project picker dialog. The route layer +// can't render the picker itself — it lives with the sidebar — and the +// sidebar can't own the window keydown handler without racing the layout's. +const NEW_THREAD_PICKER_EVENT = "t3code:open-new-thread-picker"; + +export function openNewThreadPicker(): void { + window.dispatchEvent(new CustomEvent(NEW_THREAD_PICKER_EVENT)); +} + +export function onOpenNewThreadPicker(listener: () => void): () => void { + window.addEventListener(NEW_THREAD_PICKER_EVENT, listener); + return () => window.removeEventListener(NEW_THREAD_PICKER_EVENT, listener); +} diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 9fb1eae721e..b590c59ed9d 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -3,6 +3,9 @@ import { useAtomValue } from "@effect/atom-react"; import { useEffect } from "react"; import { isCommandPaletteOpen } from "../commandPaletteContext"; +import { useClientSettings } from "../hooks/useSettings"; +import { openNewThreadPicker } from "../newThreadPickerBus"; +import { useProjects } from "../state/entities"; import { dispatchPreviewAction } from "../components/preview/previewActionBus"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { @@ -25,6 +28,8 @@ function ChatRouteGlobalShortcuts() { const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread, routeThreadRef } = useHandleNewThread(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const sidebarV2Enabled = useClientSettings((settings) => settings.sidebarV2Enabled); + const projectCount = useProjects().length; const terminalOpen = useTerminalUiStateStore((state) => routeThreadRef ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen @@ -75,6 +80,13 @@ function ChatRouteGlobalShortcuts() { if (command === "chat.new") { event.preventDefault(); event.stopPropagation(); + // Sidebar v2 routes creation through its project picker whenever + // there is a real choice to make; v1 (and single-project setups) + // keep the immediate contextual create. + if (sidebarV2Enabled && projectCount > 1) { + openNewThreadPicker(); + return; + } void startNewThreadFromContext({ activeDraftThread, activeThread: activeThread ?? undefined, @@ -140,8 +152,10 @@ function ChatRouteGlobalShortcuts() { keybindings, defaultProjectRef, previewOpen, + projectCount, routeThreadRef, selectedThreadKeysSize, + sidebarV2Enabled, terminalOpen, ]); From 069f271b1cb4bfabcac8a9572ffba4eb876a747d Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 14 Jul 2026 04:20:29 -0700 Subject: [PATCH 3/5] Address PR #3960 review findings and CI failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI: add settledOverride/settledAt + new client settings to desktop and mobile test fixtures and the mobile threadDetailToShell mapping. Review fixes (cursor bugbot + macroscope): - failed status now requires session.status === "error" instead of any lingering lastError on a stopped/ready session - effectiveSettled: pending approvals or user input always win — blocked work can never collapse into the settled tail, even manually settled - decider unsettle-on-activity guards check settledOverride === "settled" so activity no longer erases a user's explicit keep-active override - repeat thread.settle re-emits with updatedAt = original settledAt, so double-settles no longer bump recency ordering - sidebarAutoSettleAfterDays bounded 1..90 in schema and patch - approval wait sort/label use shell.updatedAt (bumped by the approval activity in the projection pipeline) instead of latestUserMessageAt / turn start, with the approximation documented - v2 rows: Enter/Space keyboard activation; double-click inline rename ported from v1 plus a context-menu Rename entry - new-thread picker: cross-project creation no longer inherits the active thread's branch/worktree; preselection consults the active draft via resolveThreadActionProjectRef; stale project scope resets to All - settle worktree prompt: vcs status checked before offering (and again at click time) — dirty or ahead worktrees are never offered Co-Authored-By: Claude Fable 5 --- .../settings/DesktopClientSettings.test.ts | 2 + .../archive/archivedThreadList.test.ts | 2 + .../src/features/home/homeListItems.test.ts | 2 + .../src/features/home/homeThreadList.test.ts | 2 + apps/mobile/src/lib/repositoryGroups.test.ts | 2 + apps/mobile/src/lib/threadActivity.test.ts | 2 + apps/mobile/src/state/use-thread-selection.ts | 2 + .../src/orchestration/decider.settled.test.ts | 52 ++++- apps/server/src/orchestration/decider.ts | 8 +- apps/web/src/components/Sidebar.logic.test.ts | 21 +- apps/web/src/components/Sidebar.logic.ts | 15 +- apps/web/src/components/SidebarV2.tsx | 193 +++++++++++++++--- apps/web/src/hooks/useThreadActions.ts | 37 +++- apps/web/src/lib/chatThreadActions.test.ts | 21 ++ apps/web/src/lib/chatThreadActions.ts | 8 + apps/web/src/worktreeCleanup.test.ts | 14 +- apps/web/src/worktreeCleanup.ts | 7 + .../src/state/threadSettled.test.ts | 39 ++-- .../client-runtime/src/state/threadSettled.ts | 2 + packages/contracts/src/settings.test.ts | 7 + packages/contracts/src/settings.ts | 16 +- 21 files changed, 386 insertions(+), 68 deletions(-) diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index ea7ec6e1512..47800f3192d 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -20,6 +20,7 @@ const clientSettings: ClientSettings = { diffIgnoreWhitespace: true, favorites: [], providerModelPreferences: {}, + sidebarAutoSettleAfterDays: 3, sidebarProjectGroupingMode: "repository_path", sidebarProjectGroupingOverrides: { "environment-1:/tmp/project-a": "separate", @@ -27,6 +28,7 @@ const clientSettings: ClientSettings = { sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", sidebarThreadPreviewCount: 6, + sidebarV2Enabled: false, timestampFormat: "24-hour", wordWrap: true, }; diff --git a/apps/mobile/src/features/archive/archivedThreadList.test.ts b/apps/mobile/src/features/archive/archivedThreadList.test.ts index 6cd530ab37d..697d13e7c47 100644 --- a/apps/mobile/src/features/archive/archivedThreadList.test.ts +++ b/apps/mobile/src/features/archive/archivedThreadList.test.ts @@ -41,6 +41,8 @@ function makeThread( hasPendingUserInput: false, hasActionableProposedPlan: false, ...input, + settledOverride: input.settledOverride ?? null, + settledAt: input.settledAt ?? null, }; } diff --git a/apps/mobile/src/features/home/homeListItems.test.ts b/apps/mobile/src/features/home/homeListItems.test.ts index 36e98ef129f..c5a9f2c6bbc 100644 --- a/apps/mobile/src/features/home/homeListItems.test.ts +++ b/apps/mobile/src/features/home/homeListItems.test.ts @@ -47,6 +47,8 @@ function makeThread(id: string, projectId: ProjectId): EnvironmentThreadShell { createdAt: "2026-06-01T00:00:00.000Z", updatedAt: "2026-06-01T00:00:00.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, session: null, latestUserMessageAt: null, hasPendingApprovals: false, diff --git a/apps/mobile/src/features/home/homeThreadList.test.ts b/apps/mobile/src/features/home/homeThreadList.test.ts index 8f13000b9e2..46d1173b0dd 100644 --- a/apps/mobile/src/features/home/homeThreadList.test.ts +++ b/apps/mobile/src/features/home/homeThreadList.test.ts @@ -41,6 +41,8 @@ function makeThread( hasPendingUserInput: false, hasActionableProposedPlan: false, ...input, + settledOverride: input.settledOverride ?? null, + settledAt: input.settledAt ?? null, }; } diff --git a/apps/mobile/src/lib/repositoryGroups.test.ts b/apps/mobile/src/lib/repositoryGroups.test.ts index 8cea5df2307..ab4311524ce 100644 --- a/apps/mobile/src/lib/repositoryGroups.test.ts +++ b/apps/mobile/src/lib/repositoryGroups.test.ts @@ -38,6 +38,8 @@ function makeThread( hasPendingUserInput: false, hasActionableProposedPlan: false, ...input, + settledOverride: input.settledOverride ?? null, + settledAt: input.settledAt ?? null, }; } diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 23b47fc625f..5f9f099a46c 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -50,6 +50,8 @@ function makeThread( checkpoints: [], session: null, ...input, + settledOverride: input.settledOverride ?? null, + settledAt: input.settledAt ?? null, }; } diff --git a/apps/mobile/src/state/use-thread-selection.ts b/apps/mobile/src/state/use-thread-selection.ts index d369fbc4377..80bc1916b11 100644 --- a/apps/mobile/src/state/use-thread-selection.ts +++ b/apps/mobile/src/state/use-thread-selection.ts @@ -58,6 +58,8 @@ function threadDetailToShell( createdAt: thread.createdAt, updatedAt: thread.updatedAt, archivedAt: thread.archivedAt, + settledOverride: thread.settledOverride, + settledAt: thread.settledAt, session: thread.session, latestUserMessageAt: latestUserMessageAt(thread), hasPendingApprovals: false, diff --git a/apps/server/src/orchestration/decider.settled.test.ts b/apps/server/src/orchestration/decider.settled.test.ts index dca1100e286..d4ecfba6a40 100644 --- a/apps/server/src/orchestration/decider.settled.test.ts +++ b/apps/server/src/orchestration/decider.settled.test.ts @@ -98,6 +98,7 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => { expect(reEmitEvents[0]?.type).toBe("thread.settled"); if (reEmitEvents[0]?.type === "thread.settled") { expect(reEmitEvents[0].payload.settledAt).toBe(SETTLED_AT); + expect(reEmitEvents[0].payload.updatedAt).toBe(SETTLED_AT); } }), ); @@ -199,10 +200,55 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => { readModel: makeReadModel("active"), }); const sessionEvents = Array.isArray(sessionResult) ? sessionResult : [sessionResult]; - expect(sessionEvents.map((event) => event.type)).toEqual([ - "thread.unsettled", - "thread.session-set", + expect(sessionEvents.map((event) => event.type)).toEqual(["thread.session-set"]); + }), + ); + + it.effect("preserves an explicit active override during activity", () => + Effect.gen(function* () { + const turnResult = yield* decideOrchestrationCommand({ + command: { + type: "thread.turn.start", + commandId: CommandId.make("cmd-active-turn-start"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: MessageId.make("message-active"), + role: "user", + text: "Continue", + attachments: [], + }, + runtimeMode: "full-access", + interactionMode: "default", + createdAt: NOW, + }, + readModel: makeReadModel("active"), + }); + const turnEvents = Array.isArray(turnResult) ? turnResult : [turnResult]; + expect(turnEvents.map((event) => event.type)).toEqual([ + "thread.message-sent", + "thread.turn-start-requested", ]); + + const activityResult = yield* decideOrchestrationCommand({ + command: { + type: "thread.activity.append", + commandId: CommandId.make("cmd-active-approval"), + threadId: ThreadId.make("thread-1"), + activity: { + id: EventId.make("activity-active"), + tone: "approval", + kind: "approval.requested", + summary: "Command approval requested", + payload: null, + turnId: null, + createdAt: NOW, + }, + createdAt: NOW, + }, + readModel: makeReadModel("active"), + }); + const activityEvents = Array.isArray(activityResult) ? activityResult : [activityResult]; + expect(activityEvents.map((event) => event.type)).toEqual(["thread.activity-appended"]); }), ); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index ad84090da79..1f9cc311ce5 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -337,7 +337,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" payload: { threadId: command.threadId, settledAt, - updatedAt: occurredAt, + updatedAt: settledAt, }, }; } @@ -519,7 +519,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" createdAt: command.createdAt, }, }; - if (targetThread.settledOverride === null) { + if (targetThread.settledOverride !== "settled") { return [userMessageEvent, turnStartRequestedEvent]; } const unsettledEvent: Omit = { @@ -681,7 +681,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" // must not fight a user's explicit settle. const isSessionActivity = command.session.status === "starting" || command.session.status === "running"; - if (thread.settledOverride === null || !isSessionActivity) { + if (thread.settledOverride !== "settled" || !isSessionActivity) { return sessionSetEvent; } const unsettledEvent: Omit = { @@ -857,7 +857,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" const wakesSettledThread = command.activity.kind === "approval.requested" || command.activity.kind === "user-input.requested"; - if (thread.settledOverride === null || !wakesSettledThread) { + if (thread.settledOverride !== "settled" || !wakesSettledThread) { return activityAppendedEvent; } const unsettledEvent: Omit = { diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 5348aa67cb3..6a1a2da2b06 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -592,17 +592,23 @@ describe("resolveSidebarV2Status", () => { ).toBe("working"); }); - it("reports failed only for stopped sessions with an error", () => { + it("reports failed only while the session status is error", () => { expect( resolveSidebarV2Status({ hasPendingApprovals: false, - session: { ...session, status: "stopped" as const, lastError: "boom" }, + session: { ...session, status: "error" as const, lastError: "boom" }, }), ).toBe("failed"); expect( resolveSidebarV2Status({ hasPendingApprovals: false, - session: { ...session, status: "stopped" as const }, + session: { ...session, status: "stopped" as const, lastError: "persisted" }, + }), + ).toBe("ready"); + expect( + resolveSidebarV2Status({ + hasPendingApprovals: false, + session: { ...session, status: "ready" as const, lastError: "persisted" }, }), ).toBe("ready"); }); @@ -617,12 +623,13 @@ describe("sortThreadsForSidebarV2", () => { id: string; hasPendingApprovals?: boolean; latestUserMessageAt: string; + updatedAt?: string; }) => ({ id: input.id, hasPendingApprovals: input.hasPendingApprovals ?? false, session: null, createdAt: "2026-03-09T10:00:00.000Z", - updatedAt: input.latestUserMessageAt, + updatedAt: input.updatedAt ?? input.latestUserMessageAt, latestUserMessageAt: input.latestUserMessageAt, }); @@ -632,12 +639,14 @@ describe("sortThreadsForSidebarV2", () => { sortable({ id: "approval-new", hasPendingApprovals: true, - latestUserMessageAt: "2026-03-09T11:00:00.000Z", + latestUserMessageAt: "2026-03-09T08:00:00.000Z", + updatedAt: "2026-03-09T11:00:00.000Z", }), sortable({ id: "approval-old", hasPendingApprovals: true, - latestUserMessageAt: "2026-03-09T09:00:00.000Z", + latestUserMessageAt: "2026-03-09T11:30:00.000Z", + updatedAt: "2026-03-09T09:00:00.000Z", }), sortable({ id: "stale", latestUserMessageAt: "2026-03-09T08:00:00.000Z" }), ]); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 40e9e89fbfb..bce7c48be02 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -373,7 +373,7 @@ export function resolveSidebarV2Status(thread: SidebarV2StatusInput): SidebarV2S if (thread.session?.status === "running" || thread.session?.status === "starting") { return "working"; } - if (thread.session != null && thread.session.lastError !== null) { + if (thread.session?.status === "error") { return "failed"; } return "ready"; @@ -390,13 +390,16 @@ export function sortThreadsForSidebarV2< if (leftApproval !== rightApproval) { return leftApproval ? -1 : 1; } - const byRecency = - getThreadSortTimestamp(right, "updated_at") - getThreadSortTimestamp(left, "updated_at"); - // Approval wait time is "how long you've been the bottleneck", which is - // oldest-activity-first — the inverse of recency. + // Approval activities update the shell's updatedAt in the projection + // pipeline, making it the best available request-time proxy. Oldest waits + // pin first. (The shell does not expose the approval request timestamp.) if (leftApproval && rightApproval) { - return -byRecency || left.id.localeCompare(right.id); + return ( + Date.parse(left.updatedAt) - Date.parse(right.updatedAt) || left.id.localeCompare(right.id) + ); } + const byRecency = + getThreadSortTimestamp(right, "updated_at") - getThreadSortTimestamp(left, "updated_at"); return byRecency || left.id.localeCompare(right.id); }); } diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index c46cb39fc9b..b00abee5b80 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -16,6 +16,7 @@ import { useMemo, useRef, useState, + type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, } from "react"; import { useParams, useRouter } from "@tanstack/react-router"; @@ -47,6 +48,7 @@ import { useOpenAddProjectCommandPalette } from "../commandPaletteContext"; import { onOpenNewThreadPicker } from "../newThreadPickerBus"; import { Dialog, DialogHeader, DialogPopup, DialogTitle } from "./ui/dialog"; import { + resolveThreadActionProjectRef, startNewThreadFromContext, startNewThreadInProjectFromContext, } from "../lib/chatThreadActions"; @@ -55,7 +57,9 @@ import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments" import { useProjects, useThreadShells } from "../state/entities"; import { primaryServerKeybindingsAtom } from "../state/server"; import { vcsEnvironment } from "../state/vcs"; +import { threadEnvironment } from "../state/threads"; import { useEnvironmentQuery } from "../state/query"; +import { useAtomCommand } from "../state/use-atom-command"; import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; import { formatElapsedDurationLabel, formatRelativeTimeLabel } from "../timestampFormat"; import type { SidebarThreadSummary } from "../types"; @@ -128,8 +132,10 @@ function threadTimeLabel(thread: SidebarThreadSummary, status: SidebarV2Status): return formatElapsedDurationLabel(thread.latestTurn.startedAt); } if (status === "approval") { - const waitingSince = thread.latestTurn?.startedAt ?? thread.updatedAt; - return `waiting ${formatElapsedDurationLabel(waitingSince)}`; + // Approval activities bump shell.updatedAt in the projection pipeline. + // The shell has no dedicated request timestamp, so this is the closest + // accurate wait-start signal shared by the label and approval ordering. + return `waiting ${formatElapsedDurationLabel(thread.updatedAt)}`; } const timestamp = thread.latestUserMessageAt ?? thread.updatedAt; return formatRelativeTimeLabel(timestamp); @@ -152,17 +158,31 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { projectCwd: string | null; providerEntryByInstanceId: ReadonlyMap; onThreadClick: (event: ReactMouseEvent, threadRef: ScopedThreadRef) => void; + onThreadActivate: (threadRef: ScopedThreadRef) => void; + onStartRename: (threadRef: ScopedThreadRef, title: string) => void; + onRenameTitleChange: (title: string) => void; + onCommitRename: (threadRef: ScopedThreadRef, title: string, originalTitle: string) => void; + onCancelRename: () => void; + isRenaming: boolean; + renamingTitle: string; onContextMenu: (threadRef: ScopedThreadRef, position: { x: number; y: number }) => void; onSettle: (threadRef: ScopedThreadRef) => void; onUnsettle: (threadRef: ScopedThreadRef) => void; onChangeRequestState: (threadKey: string, state: "open" | "closed" | "merged" | null) => void; }) { const { + isRenaming, onChangeRequestState, + onCancelRename, + onCommitRename, onContextMenu, + onRenameTitleChange, onSettle, + onStartRename, + onThreadActivate, onThreadClick, onUnsettle, + renamingTitle, thread, variant, variantAction, @@ -221,6 +241,50 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { }, [onContextMenu, threadRef], ); + const handleKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + if (event.target !== event.currentTarget) return; + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + onThreadActivate(threadRef); + }, + [onThreadActivate, threadRef], + ); + const handleDoubleClick = useCallback( + (event: ReactMouseEvent) => { + if (isRenaming || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { + return; + } + if ((event.target as HTMLElement).closest("button, a, input")) return; + event.preventDefault(); + onStartRename(threadRef, thread.title); + }, + [isRenaming, onStartRename, thread.title, threadRef], + ); + const renameCommittedRef = useRef(false); + useEffect(() => { + if (isRenaming) renameCommittedRef.current = false; + }, [isRenaming]); + const handleRenameKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + event.stopPropagation(); + if (event.key === "Enter") { + event.preventDefault(); + renameCommittedRef.current = true; + onCommitRename(threadRef, renamingTitle, thread.title); + } else if (event.key === "Escape") { + event.preventDefault(); + renameCommittedRef.current = true; + onCancelRename(); + } + }, + [onCancelRename, onCommitRename, renamingTitle, thread.title, threadRef], + ); + const handleRenameBlur = useCallback(() => { + if (!renameCommittedRef.current) { + onCommitRename(threadRef, renamingTitle, thread.title); + } + }, [onCommitRename, renamingTitle, thread.title, threadRef]); const handleSettleClick = useCallback( (event: ReactMouseEvent) => { event.preventDefault(); @@ -261,6 +325,36 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { /> ); + const title = isRenaming ? ( + onRenameTitleChange(event.target.value)} + onFocus={(event) => event.currentTarget.select()} + onKeyDown={handleRenameKeyDown} + onBlur={handleRenameBlur} + onClick={(event) => event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + className="min-w-0 flex-1 rounded border border-border bg-background px-1 text-[13px] text-foreground outline-none focus:border-ring" + /> + ) : ( + + {thread.title} + + ); + const prBadge = prStatus && pr ? ( @@ -408,7 +423,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { type="button" aria-label="Settle thread" onClick={handleSettleClick} - className="absolute inset-y-0 right-0 inline-flex items-center gap-1 rounded-md border border-border bg-card px-2 text-[11px] text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover/v2-row:opacity-100" + className="absolute inset-y-0 right-0 inline-flex items-center gap-1 border border-border bg-background px-2 text-[11px] text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover/v2-row:opacity-100" > @@ -419,7 +434,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { ); } - const statusDot = STATUS_DOT_BY_STATUS[status]; + const statusEdge = CARD_EDGE_BY_STATUS[status]; const statusWord = STATUS_WORD_BY_STATUS[status]; const diff = latestTurnDiff(thread); const workingTimer = @@ -430,46 +445,44 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { return (
      • -
        - {statusDot ? ( - - ) : ( - favicon - )} + {statusEdge ? ( + + ) : null} +
        + {favicon} {title} {diff ? ( - + +{diff.insertions}{" "} −{diff.deletions} ) : null} - + {props.jumpLabel ?? workingTimer ?? threadTimeLabel(thread, status)} @@ -478,16 +491,21 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { type="button" aria-label="Settle thread" onClick={handleSettleClick} - className="absolute inset-y-0 right-0 inline-flex items-center gap-1 rounded-md border border-border bg-card px-2 text-[11px] text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover/v2-row:opacity-100" + className="absolute inset-y-0 right-0 inline-flex items-center gap-1 border border-border bg-background px-2 text-[11px] text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover/v2-row:opacity-100" > Settle
        -
        +
        {statusWord ? ( - + {statusWord.label} ) : null} @@ -498,7 +516,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { ) : ( <> {thread.branch ? ( - + {thread.branch} ) : null} @@ -1242,10 +1260,10 @@ export default function SidebarV2() { aria-selected={projectScopeKey === null} onClick={() => setProjectScopeKey(null)} className={cn( - "shrink-0 rounded-full border px-2.5 py-1 text-[11px] font-medium transition-colors", + "shrink-0 border px-2.5 py-1 font-mono text-[11px] font-medium uppercase tracking-wide transition-colors", projectScopeKey === null - ? "border-foreground/25 bg-accent text-foreground" - : "border-border bg-transparent text-muted-foreground hover:bg-accent/60 hover:text-foreground", + ? "border-foreground bg-foreground text-background" + : "border-black/15 text-muted-foreground hover:border-black/40 hover:text-foreground dark:border-white/15 dark:hover:border-white/40", )} > All @@ -1262,10 +1280,10 @@ export default function SidebarV2() { aria-selected={isScoped} onClick={() => setProjectScopeKey(isScoped ? null : scopeKey)} className={cn( - "flex shrink-0 items-center gap-1.5 rounded-full border py-1 pl-1.5 pr-2.5 text-[11px] font-medium transition-colors", + "flex shrink-0 items-center gap-1.5 border py-1 pl-1.5 pr-2.5 font-mono text-[11px] font-medium transition-colors", isScoped - ? "border-foreground/25 bg-accent text-foreground" - : "border-border bg-transparent text-muted-foreground hover:bg-accent/60 hover:text-foreground", + ? "border-foreground bg-foreground/10 text-foreground dark:bg-white/[0.1]" + : "border-black/15 text-muted-foreground hover:border-black/40 hover:text-foreground dark:border-white/15 dark:hover:border-white/40", )} > } > @@ -1351,7 +1369,7 @@ export default function SidebarV2() {
        -
        - {statusWord ? ( - - {statusWord.label} - - ) : null} - {status === "failed" && thread.session?.lastError ? ( - - {thread.session.lastError} - - ) : ( - <> - {thread.branch ? ( - - {thread.branch} - - ) : null} - {prBadge} - - )} - - {driverKind ? ( - - } - > - - - {thread.modelSelection.model} - +
        +
        + {title} + {diff ? ( + + +{diff.insertions}{" "} + −{diff.deletions} + ) : null} - {isRemote ? ( - - - } - > - - - - Running on {props.environmentLabel ?? "a remote environment"} - - +
        +
        + {statusWord ? ( + + {statusWord.label} + ) : null} - + {status === "failed" && thread.session?.lastError ? ( + + {thread.session.lastError} + + ) : ( + <> + {thread.branch ? ( + + {thread.branch} + + ) : null} + {prBadge} + + )} + + {driverKind ? ( + + } + > + + + {thread.modelSelection.model} + + ) : null} + {isRemote ? ( + + + } + > + + + + Running on {props.environmentLabel ?? "a remote environment"} + + + ) : null} + +
      • @@ -622,6 +642,11 @@ export default function SidebarV2() { ), [projects], ); + const projectTitleByKey = useMemo( + () => + new Map(projects.map((project) => [`${project.environmentId}:${project.id}`, project.title])), + [projects], + ); // now is quantized to the minute so effectiveSettled memoization doesn't // churn on every render; auto-settle thresholds are day-granular anyway. @@ -1344,6 +1369,9 @@ export default function SidebarV2() { projectCwd={ projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null } + projectTitle={ + projectTitleByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null + } providerEntryByInstanceId={providerEntryByInstanceId} onThreadClick={handleThreadClick} onThreadActivate={navigateToThread} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 5f9aec837ca..b568dc6208f 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -2049,7 +2049,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) >
        {!isComposerCollapsedMobile && (activePendingApproval ? ( -
        +
        ) : pendingUserInputs.length > 0 ? ( -
        +
        ) : showPlanFollowUpPrompt && activeProposedPlan ? ( -
        +
        ) : isComposerCollapsedMobile && pendingUserInputs.length > 0 ? (
        | undefined; preferredScriptId: string | null; @@ -58,6 +60,7 @@ export const ChatHeader = memo(function ChatHeader({ draftId, activeThreadTitle, activeProjectName, + activeProjectCwd, openInCwd, activeProjectScripts, preferredScriptId, @@ -79,6 +82,27 @@ export const ChatHeader = memo(function ChatHeader({ return (
        + {/* The project always leads the header: knowing which project a + thread lives in is priority zero, and the thread title alone + doesn't answer it. Mirrors the sidebar cards' title bar — + favicon + mono uppercase name. */} + {activeProjectName ? ( + + + + + {activeProjectName} + + + + / + + + ) : null}