One flat, recency-sorted thread list where row size is earned by state — and “settled” becomes a real lifecycle stage users control. Familiar mechanics only: nothing here requires learning a new gesture.
+
+ beta · toggle in settings
+ plan: .plans/21-sidebar-v2-beta.md
+ original 5 concepts ↗
+
+
+
01What we're building
+
The hybrid the concepts page predicted, filtered through “meet users where they are”: concept 4's adaptive density as the skeleton, concept 1's card layout (trimmed to two structured lines — no generated text) for active rows, and concept 3 reduced to a sort rule (approval-blocked threads pin above the recency flow — no tiers, no inline approve).
+
+
+
Changes
+
+
Flat recency list — project group headers gone; project becomes a chip. This is how Claude Code, Codex, and Cursor already present sessions.
+
Settled lifecycle state — explicit, user- or rule-triggered. Settled rows collapse to slim one-liners (≈ today's density).
+
Four states, three colors — Needs approval (amber, pinned with wait time), Working (sky, pulsing), Ready (no color, no label — the normal resting state; bold title until visited), Failed (red).
+
Structured metadata only — the card's second line is status word · branch · harness + model · machine. No summaries or free text to generate; every field is data the shell already carries.
+
Project identity = favicon — the existing ProjectFavicon per-row, not a text chip. Harness (Claude Code / Codex / other) is a tinted glyph on the model; machine shows only when the thread lives on another computer.
+
Failed sessions visible — session.lastError finally gets a red state; today a dead session shows nothing.
+
Settle → worktree cleanup — settling an orphaned-worktree thread offers a one-click remove.
+
+
+
+
Deliberately not
+
+
Inline approve/reject in the sidebar — needs a safety story first.
Auto-archive of long-settled threads (future: settled ≥ 30d).
+
Removing v1 — it stays the default; v2 is opt-in.
+
+
+
+
+
02The mock
+
Hover any card and hit “Settle” — it collapses into the slim tail. That height change is the whole design: it happens only at lifecycle transitions, never from streaming updates, which is what keeps the list calm.
+
+
+
+
+
⌕ Search ⌘K
+
+
+
+
Settled · worktree fix-pr-titles is orphaned
+
+
+
+
+
+
T3Normalize open PR titleswaiting 2m
+
APPROVALfix-pr-titles✳opus-4.8
+
+
+
+
+
+
+
+
T3Sidebar v2 UX Redesign14m 22s
+
WORKINGsidebar-v2-beta✳fable-5
+
+
+
+
+
+
+
FHFix image upload stalls18m
+
main✳sonnet-5⏻ mac-studio
+
+
+
+
+
+
+
T3Review default experience plan9m
+
plan-ux-review✳opus-4.8
+
+
+
+
+
+
+
T3Disable pairing codes by default+214−3812m
+
disable-pairing-codes◈gpt-5.6-sol
+
+
+
+
+
+
+
+
T3iOS Simulator Remote Setup34m
+
FAILEDmain◈gpt-5.6-sol⏻ mbp-16
+
+
+
+
+
T3Default new worktrees from origin main41m
+
T3Fix performance bottlenecks#3825 merged1d
+
T3Stress test long thread rendering#3822 open2d
+
FHCLI File Hosting Options2d
+
+
+
+
+
+
Card anatomy (active threads, ~52px)
+ Line 1 — project favicon · title (bold = unread) · diff stats when present · time
+ Line 2 — structured metadata only: status word (only when colored) · branch · harness glyph + model · machine (only when not this one). No generated text — everything on the card is data we already have.
+
Four states, three colors
+ APPROVAL — blocked mid-run, pinned to top with “waiting Xm”
+ WORKING — pulsing rail, live elapsed timer
+ Ready — the default; needs no label. Stopped, waiting on you. Bold title until visited.
+ FAILED — session error, invisible today
+
Harness · model · machine
+ ✳ Claude Code · ◈ Codex · a neutral glyph for “other” — one tinted glyph before the model name, deliberately quiet.
+ ⏻ machine appears only on threads running somewhere other than the current computer; local threads show nothing.
+
Slim row (settled, ~28px)
+ favicon · title · PR when notable · time. Visually ≈ today's v1 row, so density for history is preserved.
+
Sort
+ Approval first (by wait time) → then pure recency. No sections, no headers, no “show more” — the tail just scrolls, virtualized.
+
+
+
+
03The settled model
+
Concept 4 derived “settled” passively. This version makes it an acknowledgment with an act attached — Gmail's archive, GitHub notifications' “Done” — which is what makes row heights stable and the list trustworthy.
+
+
+
+
Storage is one override, everything else is computed. No background jobs, no sweeper:
+
effectiveSettled(thread) =
+ override === "settled" → true// manual settle
+ override === "active" → false// manual keep-active (beats auto rules)
+ pr.state ∈ {merged, closed} → true// auto — strongest signal
+ lastActivityAt < now − inactivityThreshold → true// auto — backstop, default 3d, configurable
+ otherwise → false
+
+
Stored: settledOverride: "settled" | "active" | null + settledAt, server-side next to archivedAt — it must sync across desktop/remote, and worktree cleanup needs the server to know. Mirrors the thread.archived event pattern exactly.
+
Activity clears the override (new user message, session start, approval request) — a settled thread that wakes up expands and floats back up. Settled is never a hole a thread disappears into.
+
Viewing ≠ settling. Visiting clears unread (existing lastVisitedAt); settling asserts “this work is concluded.” Conflating them would make auto-unsettle fight the user.
+
+
Inbox-zero hedge: manual settling must feel like optional satisfaction, not homework. The auto rules guarantee a user who never touches the affordance still gets a tidy list. Beta telemetry decides how prominent the affordance stays.
+
+
04Swap boundary
+
Whole-component swap, one seam. Today's Sidebar.tsx (~3,750 lines) takes zero props and mounts at exactly one place. Everything that must behave identically in both sidebars already lives outside the component in shared hooks and stores — and what lives inside (project grouping, drag-and-drop, show-more) is precisely what v2 deletes.
+
+
+
+
+
The settled data model ships dark and is flag-independent — v1 simply ignores the new fields. The toggle is a pure view swap: flipping it never migrates data, so switching back and forth is always safe.
+
Toggle lives in a new “Beta features” settings panel: sidebarV2Enabled (default false) + sidebarAutoSettleAfterDays (default 3, nullable = never) in ClientSettingsSchema.
+
Inherited for free via shared hooks: click-to-open, multi-select, context menu, inline rename, thread-jump ⌘1–9, archive, PR link, port/terminal/remote indicators.
contracts/orchestration.ts — settledOverride + settledAt on the thread (decoding defaults → old data decodes unchanged); thread.settled / thread.unsettled events; thread.settle / thread.unsettle commands.
+
server/orchestration/decider.ts — handle both commands (settle is idempotent for bulk); activity paths (user message, session start, approval request) emit thread.unsettled when an override is set.
+
client-runtime/threadReducer.ts — reduce both events (archive cases at :87–101 are the template).
+
Pure effectiveSettled(shell, {now, autoSettleAfterDays}) in client-runtime; unit-test the full truth table (override × PR state × inactivity).
“Beta features” settings panel + route; swap seam in AppSidebarLayout.tsx.
+
SidebarV2.tsx: flat virtualized list, sort = needs-approval (by wait) → recency; single SidebarV2Row with card / slim variants from the same shell data. Rows lead with the existing ProjectFavicon component (environmentId + cwd are already on the shell).
+
Consolidate status derivation in Sidebar.logic.ts to four visual states: Needs approval, Working (incl. connecting), Ready (merges today's Awaiting Input / Plan Ready / Completed-unseen — same user action, so one unlabeled state), and new Failed (session.lastError, non-running) — kept shared so v1 can adopt Failed later.
+
Meta line: harness glyph + model from the shell's provider/model selection; machine label from the thread's environment vs. the current one (render only when they differ). Both are display-only lookups, no new data plumbing.
+
Unread = bold-until-visited from existing uiStateStore.threadLastVisitedAtById.
+
Height changes only on settle/unsettle transitions (auto-animate); streaming never resizes rows.
+
useThreadActions: settleThread / unsettleThread mirroring archiveThread, minus navigation — settling never navigates away. Affordances: row hover button, context menu, bulk multi-select. No keyboard shortcut yet.
+
+
Verify: toggle on → v2; toggle off → v1 pixel-identical to before · settle round-trips across a desktop+remote pair · killed session → Failed card.
+
+
+
+
+
P3Worktree cleanup hooksettle = reclaim disk
+
+
+
On manual settle where the worktree is orphaned (getOrphanedWorktreePathForThread): non-blocking “Worktree kept · Remove?” prompt → vcsEnvironment.removeWorktree. Never automatic, never blocks the settle.
+
Skip the prompt when the worktree has uncommitted changes or unpushed commits (from vcs status the sidebar already has).
+
Auto-settle never touches worktrees. Follow-up (tracked, out of scope): settings “Storage” view listing orphaned worktrees of settled threads with sizes + bulk remove.
+
+
+
+
+
+
P4Telemetry & beta exitfind the balance
+
+
+
Count settles by source: manual / auto-PR / auto-inactivity / bulk. Count un-settles: manual vs. activity-driven.
+
Activity-driven un-settles of manual settles = the model fighting users → retune threshold or triggers.
+
Manual settling ≈ 0% → shrink the affordance, lean on auto rules. High → a habit worth building on (keyboard shortcut, etc.).
+
Track toggle-off rate after trying v2.
+
+
+
+
+
06Open questions
+
+
Question
Current thinking
+
Server-side activity observation
If decider activity paths are too scattered for unsettle-on-activity, fall back to client-side: compute with activity timestamps, override only stores manual state (activity newer than settledAt wins). Decide in P1.
+
Shell projection
settledOverride/settledAtmust be in the thread shell stream — the sidebar never loads details. Verify first thing in P1.
+
Diff stats on cards
Requires checkpoint data in the shell; if absent, defer diff stats rather than loading details per row.
+
Project grouping in v2
Not planned; v1 stays available for users who want groups. Revisit only if beta feedback demands it.
+
+
+
T3 Code · Sidebar v2 beta · plan doc pair: .plans/21-sidebar-v2-beta.md (implementation detail) + this page (design + rationale). Mock data mirrors the original concepts page (from live state.sqlite, Jul 13).
+
+
+
+
+
diff --git a/.plans/21-sidebar-v2-beta.md b/.plans/21-sidebar-v2-beta.md
new file mode 100644
index 00000000000..8cda132e80a
--- /dev/null
+++ b/.plans/21-sidebar-v2-beta.md
@@ -0,0 +1,285 @@
+# Sidebar v2 (beta): flat adaptive-density list with settled threads
+
+Status: planned
+Mocks: https://hsyscdqldmk5.postplan.dev/ (concept 4 base + concept 1 card layout + concept 3 needs-you pinning)
+
+## Summary
+
+A new thread sidebar behind a client-settings beta toggle. Core changes vs. the
+current sidebar:
+
+- **Flat recency list.** Project group headers are gone; project identity moves
+ onto the row as the existing `ProjectFavicon` (environmentId + cwd). This
+ matches the session lists users know from Claude Code, Codex, and Cursor.
+- **Adaptive density via a "settled" lifecycle state.** Active threads render
+ as two-line cards: favicon + title + time, then a structured meta line
+ (status word · branch · harness glyph + model · machine). Settled threads
+ collapse to slim one-liners, roughly today's row height. No generated or
+ free-text summaries anywhere — every field on a card is data the shell
+ already carries.
+- **Settled is an explicit state**, not a derived one: users settle threads
+ manually, or threads auto-settle (PR merged/closed, inactivity). Any real
+ activity auto-unsettles. Settled is a lifecycle stage between active and
+ archived — it stays in the list, just quiet.
+- **Four visual states, three colors.** Needs approval (amber, pinned above
+ the recency flow with "waiting Xm"), Working (sky, pulsing, elapsed timer),
+ **Ready** (uncolored and unlabeled — the normal resting state: the agent
+ stopped and is waiting on you, whether it finished, asked a question, or
+ proposed a plan; bold title until visited is the only signal), Failed (red,
+ `session.lastError` — a dead session shows nothing today). Today's Awaiting
+ Input / Plan Ready / Completed-unseen pills all collapse into Ready: they
+ differ in detail, not in what the user should do (look at the thread).
+ Color is reserved for "act now" (amber), "in motion" (sky), "broken" (red).
+- **Harness / model / machine as quiet metadata.** A tinted glyph before the
+ model name distinguishes Claude Code / Codex / other; a machine label
+ renders only when the thread lives on a different computer than the one
+ you're looking at. Display-only lookups from shell data, no new plumbing.
+- **Settled feeds cleanup.** Settling a thread offers/permits worktree cleanup;
+ long-settled threads are candidates for future auto-archive.
+
+The v2 component is a sibling of the current sidebar, swapped at a single mount
+point. The current sidebar is untouched except for the swap seam.
+
+## Rationale
+
+Full design discussion lives in the session that produced the mocks; the short
+version:
+
+- We aren't in a position to teach users a new interaction model. Every v2
+ mechanic maps to an existing habit: flat recency list (Claude Code / Codex /
+ Cursor), settle (Gmail archive / GitHub notifications "Done"), unread-bold
+ (email). Zero new gestures.
+- "Settled" makes the adaptive-density row-height rule *stable*: height changes
+ only at real lifecycle transitions, never from ambient re-rendering. This
+ kills concept 4's "jumpy list" risk.
+- Viewing a thread does **not** settle it. Visiting clears unseen (existing
+ `lastVisitedAt` mechanics); settling asserts "this work is concluded." If
+ viewing settled, auto-unsettle would fight the user and the state would stop
+ meaning anything.
+- Settling is optional by design. The auto rules are the hedge against
+ inbox-zero fatigue: a user who never touches the affordance still gets a
+ naturally tidy list. Beta telemetry question: what fraction of settles are
+ manual vs. auto?
+
+## The settled model
+
+```
+Active ──(user settles | PR merged/closed | inactivity ≥ threshold)──▶ Settled
+Settled ──(new user message | session starts | approval requested |
+ PR reopened | user un-settles)──▶ Active
+Settled ──(existing archive flow, or future auto-archive)──▶ Archived
+```
+
+Settled is a **computed property with one stored override**:
+
+```
+effectiveSettled(thread) =
+ override === "settled" → true (manual settle)
+ override === "active" → false (manual keep-active)
+ pr.state ∈ {merged, closed} → true (auto)
+ lastActivityAt < now − inactivityThreshold → true (auto)
+ otherwise → false
+```
+
+- The stored field is a tri-state: `settledOverride: "settled" | "active" | null`
+ plus `settledAt: IsoDateTime | null` (set when override becomes "settled",
+ used for display and future auto-archive aging).
+- The auto cases need no background job and no events — they fall out of data
+ the sidebar already streams (`vcs.status` change request state,
+ `latestUserMessageAt` / turn timestamps). Auto-unsettle for the time-based
+ case is free: activity moves the timestamp, the predicate flips.
+- **Activity clears the override.** Any thread activity event (new user
+ message, session start, approval request) resets `settledOverride` to null
+ server-side, so a manually settled thread that wakes up becomes active again
+ and later auto-rules apply fresh. Likewise, manually un-settling a merged-PR
+ thread sets `settledOverride: "active"`, which beats the PR auto-rule.
+- Inactivity threshold: default 3 days, client setting, `null` = never
+ auto-settle by time.
+
+Server-side storage (next to `archivedAt` on the thread) rather than
+client-local: settled must sync across desktop/remote environments, and the
+worktree-cleanup hook needs the server to know. This mirrors the
+`archivedAt` / `thread.archived` / `thread.unarchived` pattern exactly.
+
+Consequences of being settled:
+
+- Row collapses to the slim one-liner.
+- Excluded from status rollups (any badge counts, aggregate status dots).
+- Eligible for worktree cleanup prompting (see below).
+- Future: settled ≥ 30 days → auto-archive candidate (not in this phase).
+
+## Swap strategy: component swap at the mount point
+
+Decision: **swap the whole sidebar component**, not internals.
+
+The current `Sidebar.tsx` (~3750 lines) takes no props — it reads everything
+from hooks/atoms/stores — and `AppSidebarLayout.tsx:93` is its single mount
+point. The truly shared behavior (thread-jump keybindings, selection store,
+uiStateStore, `useThreadActions`, context-menu via `readLocalApi()`) already
+lives *outside* the component in hooks and stores, so a sibling component
+inherits all of it by calling the same hooks. What lives inside Sidebar.tsx
+(project grouping, drag-and-drop, show-more, per-project collapse) is exactly
+the stuff v2 deletes — threading a variant flag through it would mean touching
+hundreds of conditional branches for no benefit.
+
+```tsx
+// AppSidebarLayout.tsx — the entire seam
+const sidebarV2 = useClientSettings((s) => s.sidebarV2Enabled);
+...
+{sidebarV2 ? : }
+```
+
+Rules for the seam:
+
+- `SidebarV2.tsx` imports freely from `Sidebar.logic.ts`,
+ `ThreadStatusIndicators.tsx`, `useThreadActions`, `threadSelectionStore`,
+ `uiStateStore` — shared logic stays shared.
+- `SidebarV2.tsx` must not import from `Sidebar.tsx`, and vice versa. Anything
+ both need moves into `Sidebar.logic.ts` (or a new shared module) first.
+- The settled *data model* (contracts, server, reducer) is flag-independent —
+ it ships dark and is simply unused by the v1 component. Only the *UI* is
+ gated. This keeps the toggle a pure view swap: flipping it never migrates
+ data, so switching back and forth is always safe.
+- v1 remains the default. Deleting v1 is a separate future decision once beta
+ feedback lands.
+
+## Implementation phases
+
+### Phase 1 — Settled data model (ships dark, no UI)
+
+Mirror the archived pattern end-to-end:
+
+1. `packages/contracts/src/settings.ts`: add to `ClientSettingsSchema`:
+ - `sidebarV2Enabled: boolean` (default false)
+ - `sidebarAutoSettleAfterDays: number | null` (default 3)
+2. `packages/contracts/src/orchestration.ts`:
+ - Thread shell: `settledOverride: "settled" | "active" | null`,
+ `settledAt: IsoDateTime | null` (both with decoding defaults of null —
+ old persisted threads decode unchanged).
+ - Events: `thread.settled` (threadId, settledAt, updatedAt),
+ `thread.unsettled` (threadId, updatedAt). Payload schemas alongside
+ `ThreadArchivedPayload` / `ThreadUnarchivedPayload`.
+ - Commands: `thread.settle`, `thread.unsettle`.
+3. `apps/server/src/orchestration/decider.ts`: handle both commands, mirroring
+ archive/unarchive. Invariants in `commandInvariants.ts`
+ (`requireThreadNotArchived` applies; settle of an already-settled thread is
+ a no-op rather than an error — idempotent for bulk operations).
+ In the decider paths that record thread activity (user message appended,
+ session started, approval requested): if `settledOverride !== null`, also
+ emit `thread.unsettled` to clear it.
+4. `packages/client-runtime/src/state/threadReducer.ts`: reduce both events
+ (`threadReducer.ts:87-101` is the archive template). Tests alongside the
+ archive reducer tests.
+5. `packages/client-runtime` (new `threadSettled.ts` or in `threadSort.ts`):
+ pure `effectiveSettled(shell, { now, autoSettleAfterDays })` implementing
+ the predicate above, plus `lastActivityAt(shell)` (max of
+ latestUserMessageAt, latest turn completion, session start). Unit-test the
+ truth table: each override state × PR state × inactivity.
+
+Verification: `bun run typecheck`, reducer + predicate unit tests. No visible
+change anywhere.
+
+### Phase 2 — SidebarV2 component + beta toggle
+
+1. Settings UI: "Beta features" section (new panel in
+ `apps/web/src/components/settings/SettingsPanels.tsx` + route, matching the
+ existing settings-page pattern) with the v2 toggle and, indented under it,
+ the auto-settle threshold control.
+2. Swap seam in `AppSidebarLayout.tsx` as above.
+3. `apps/web/src/components/SidebarV2.tsx`, reusing shared modules. Structure:
+ - **One flat virtualized list** of all non-archived threads across
+ projects. Sort key: (needs-approval first, by wait time) → then recency
+ (`latestUserMessageAt`, reusing `threadSort.ts`). No show-more, no
+ project collapse, no dnd.
+ - **Row variants** (single `SidebarV2Row` with a `variant` prop, both
+ variants rendered from the same shell data):
+ - `card` (~52px, threads where `effectiveSettled` is false): line 1
+ `ProjectFavicon` + title (+ diff stats when present) + time ("waiting
+ Xm" in amber when approval-blocked, live elapsed for working); line 2
+ structured meta — status word (only for the colored states) · branch ·
+ harness glyph + model · machine (only when the thread's environment is
+ not the current machine). Left rail color only for approval (amber),
+ working (sky, pulsing), failed (red) — Ready rows carry no color or
+ label, just a bold title while unread. No free-text/generated content.
+ - `slim` (~28px, settled): `ProjectFavicon` · title · PR badge when
+ notable · time. Visually close to today's v1 row.
+ - **Status derivation** consolidates `Sidebar.logic.ts`'s pill logic into
+ four visual states: Needs Approval, Working (incl. connecting), Ready
+ (today's Awaiting Input + Plan Ready + Completed-unseen — same user
+ action, different payload), and new Failed (`session.lastError` on a
+ non-running session). Keep it in `Sidebar.logic.ts` so v1 can adopt
+ Failed later too.
+ - **Unread**: bold title until visited, from existing
+ `uiStateStore.threadLastVisitedAtById` — same mechanics as today's
+ Completed-unseen pill.
+ - **Row height changes only on lifecycle transitions** (settle/unsettle),
+ animated with the existing auto-animate pattern. Streaming/status
+ updates within a variant never change height.
+ - Preserved v1 behaviors, via the shared hooks: click-to-open, multi-select
+ (threadSelectionStore), context menu (add Settle/Un-settle entries),
+ inline rename, thread-jump shortcut labels, archive affordance, PR icon
+ link, port/terminal/remote indicators (moved into line 3 / slim row
+ trailing icons).
+4. Settle affordances:
+ - Hover action on the row (next to today's archive hover button) +
+ context-menu entry + bulk via multi-select.
+ - `useThreadActions`: add `settleThread` / `unsettleThread` mirroring
+ `archiveThread` (`useThreadActions.ts:91-134`), minus the navigation
+ dance — settling never navigates away.
+ - No keyboard shortcut in the first cut; add via keybindings once the
+ affordance proves out.
+
+Verification: typecheck/lint; toggle on → v2 renders, toggle off → v1
+identical to before; settle/unsettle round-trips including across a
+desktop+remote pair; kill a session mid-run → Failed card appears.
+
+### Phase 3 — Worktree cleanup hook
+
+Settling is the natural moment to reclaim disk:
+
+1. On **manual settle** of a thread whose worktree is orphaned
+ (`getOrphanedWorktreePathForThread`, `worktreeCleanup.ts:11-33`): show a
+ non-blocking inline prompt/toast — "Worktree kept · Remove?" — that calls
+ `vcsEnvironment.removeWorktree`. Never remove without an explicit click;
+ never block the settle on the answer. Skip the prompt entirely when the
+ worktree has uncommitted changes or unpushed commits (check via the
+ existing vcs status the sidebar already has).
+2. **Auto-settle does not touch worktrees** in this phase. A settings-page
+ "Storage" affordance listing orphaned worktrees of settled threads (with
+ sizes, bulk-remove) is the follow-up; tracked but not in scope.
+
+### Phase 4 — Beta telemetry & exit criteria
+
+Instrument (whatever the existing analytics path is — if none, a lightweight
+local counter surfaced in diagnostics is enough for the beta):
+
+- settle events by source: manual / auto-PR / auto-inactivity / bulk
+- un-settle events: manual vs. activity-driven (activity-driven un-settles of
+ *manual* settles = the model fighting users)
+- toggle-off rate after trying v2
+
+Decision inputs for exiting beta: if manual settling is ~0%, shrink the
+affordance and lean on auto rules; if activity-unsettle-of-manual-settle is
+high, the inactivity threshold or unsettle triggers need retuning.
+
+## Explicitly out of scope
+
+- Inline approve/reject in the sidebar (concept 3) — needs a safety story.
+- Message snippets in rows (concept 2) — streaming churn unsolved.
+- Ops-grid density mode (concept 5).
+- Auto-archive of long-settled threads.
+- Removing v1 / making v2 the default.
+- Project grouping inside v2 (v1 remains available for users who want groups).
+
+## Open questions
+
+- Where thread "activity" is centrally observable server-side for the
+ unsettle-on-activity rule — if the decider paths are too scattered, an
+ acceptable fallback is client-side: compute `effectiveSettled` with the
+ activity timestamps and only use the override for manual state (activity
+ newer than `settledAt` wins). Decide during Phase 1 once in the decider.
+- Whether `settledAt` belongs in the thread *shell* stream (sidebar reads
+ shells only) — it must, or v2 can't sort/collapse without detail loads.
+ Verify shell projection includes it.
+- Diff stats on cards require checkpoint data in the shell; if absent, defer
+ diff stats rather than loading details for every row.
diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts
index ea7ec6e1512..47800f3192d 100644
--- a/apps/desktop/src/settings/DesktopClientSettings.test.ts
+++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts
@@ -20,6 +20,7 @@ const clientSettings: ClientSettings = {
diffIgnoreWhitespace: true,
favorites: [],
providerModelPreferences: {},
+ sidebarAutoSettleAfterDays: 3,
sidebarProjectGroupingMode: "repository_path",
sidebarProjectGroupingOverrides: {
"environment-1:/tmp/project-a": "separate",
@@ -27,6 +28,7 @@ const clientSettings: ClientSettings = {
sidebarProjectSortOrder: "manual",
sidebarThreadSortOrder: "created_at",
sidebarThreadPreviewCount: 6,
+ sidebarV2Enabled: false,
timestampFormat: "24-hour",
wordWrap: true,
};
diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx
index 49cf06d85ec..0e08529eebd 100644
--- a/apps/mobile/src/features/home/HomeRouteScreen.tsx
+++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx
@@ -28,7 +28,8 @@ export function HomeRouteScreen() {
const { savedConnectionsById } = useSavedRemoteConnections();
const navigation = useNavigation();
const [searchQuery, setSearchQuery] = useState("");
- const { archiveThread, confirmDeleteThread } = useThreadListActions();
+ const { archiveThread, confirmDeleteThread, settleThread, unsettleThread } =
+ useThreadListActions();
const pendingTasks = usePendingNewTasks();
const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions();
const environments = useMemo(
@@ -111,6 +112,8 @@ export function HomeRouteScreen() {
}
onArchiveThread={archiveThread}
onDeleteThread={confirmDeleteThread}
+ onSettleThread={settleThread}
+ onUnsettleThread={unsettleThread}
onEnvironmentChange={setSelectedEnvironmentId}
onOpenEnvironments={() =>
navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" })
diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx
index 0807304631d..f7cc02b96df 100644
--- a/apps/mobile/src/features/home/HomeScreen.tsx
+++ b/apps/mobile/src/features/home/HomeScreen.tsx
@@ -14,16 +14,18 @@ import type {
} from "@t3tools/contracts";
import { useAtomSet, useAtomValue } from "@effect/atom-react";
import { AsyncResult } from "effect/unstable/reactivity";
-import { useCallback, useMemo, useRef, useState } from "react";
-import { ActivityIndicator, Platform, View } from "react-native";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { ActivityIndicator, Platform, Pressable, View } from "react-native";
import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useThemeColor } from "../../lib/useThemeColor";
+import { AppText as Text } from "../../components/AppText";
import { EmptyState } from "../../components/EmptyState";
import type { WorkspaceState } from "../../state/workspaceModel";
import type { SavedRemoteConnection } from "../../lib/connection";
-import { scopedProjectKey } from "../../lib/scopedEntities";
+import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";
+import { useArchivedThreadSnapshots } from "../archive/useArchivedThreadSnapshots";
import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
import type { PendingNewTask } from "../../state/use-pending-new-tasks";
import {
@@ -32,6 +34,8 @@ import {
ThreadListRow,
ThreadListShowMoreRow,
} from "../threads/thread-list-items";
+import { ThreadListV2Row } from "../threads/thread-list-v2-items";
+import { buildThreadListV2Items, type ThreadListV2Item } from "../threads/threadListV2";
import type { HomeListFilterMenuEnvironment } from "./home-list-filter-menu";
import {
buildHomeListLayout,
@@ -73,6 +77,9 @@ interface HomeScreenProps {
readonly onSelectThread: (thread: EnvironmentThreadShell) => void;
readonly onArchiveThread: (thread: EnvironmentThreadShell) => void;
readonly onDeleteThread: (thread: EnvironmentThreadShell) => void;
+ /** Resolves true iff the settle was dispatched and succeeded. */
+ readonly onSettleThread: (thread: EnvironmentThreadShell) => Promise;
+ readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void;
readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void;
readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void;
readonly onNewThreadInProject: (project: EnvironmentProject) => void;
@@ -81,6 +88,10 @@ interface HomeScreenProps {
/* ─── Layout constants ───────────────────────────────────────────────── */
const ESTIMATED_THREAD_ROW_HEIGHT = 72;
+// v2 settled-tail paging: recent history is the common lookup; the deep
+// tail stays behind an explicit Show more.
+const THREAD_LIST_V2_SETTLED_INITIAL_COUNT = 10;
+const THREAD_LIST_V2_SETTLED_PAGE_COUNT = 25;
/**
* Top spacing between the list and the Android custom header. The Android
* header (AndroidHomeHeader) is rendered in-flow above this screen and
@@ -162,6 +173,9 @@ export function HomeScreen(props: HomeScreenProps) {
ReadonlyMap
>(() => new Map());
const preferencesResult = useAtomValue(mobilePreferencesAtom);
+ const threadListV2Enabled =
+ AsyncResult.isSuccess(preferencesResult) &&
+ preferencesResult.value.threadListV2Enabled === true;
const savePreferences = useAtomSet(updateMobilePreferencesAtom);
const openSwipeableRef = useRef(null);
const listRef = useRef(null);
@@ -267,6 +281,207 @@ export function HomeScreen(props: HomeScreenProps) {
return map;
}, [props.projects]);
+ const projectByKey = useMemo(() => {
+ const map = new Map();
+ for (const project of props.projects) {
+ map.set(scopedProjectKey(project.environmentId, project.id), project);
+ }
+ return map;
+ }, [props.projects]);
+
+ // Thread List v2 (beta): one flat list in creation order, no grouping.
+ // Settled threads collapse into a recency tail below the card block.
+ // Settle = archive in the client-only model, and the live shell stream
+ // drops archived threads — merge them back from the archived snapshot so
+ // they render as the settled tail. Live shells win on overlap.
+ const archivedEnvironmentIds = useMemo(
+ () =>
+ threadListV2Enabled ? props.environments.map((environment) => environment.environmentId) : [],
+ [props.environments, threadListV2Enabled],
+ );
+ const { snapshots: archivedSnapshots } = useArchivedThreadSnapshots(archivedEnvironmentIds);
+ // PR states stream in per-row (rows own the VCS subscriptions); a merged or
+ // closed PR auto-settles its thread on the next partition (mirrors web).
+ const [changeRequestStateByKey, setChangeRequestStateByKey] = useState<
+ ReadonlyMap
+ >(() => new Map());
+ const handleChangeRequestState = useCallback(
+ (threadKey: string, state: "open" | "closed" | "merged" | null) => {
+ setChangeRequestStateByKey((current) => {
+ if ((current.get(threadKey) ?? null) === state) return current;
+ const next = new Map(current);
+ if (state === null) {
+ next.delete(threadKey);
+ } else {
+ next.set(threadKey, state);
+ }
+ return next;
+ });
+ },
+ [],
+ );
+ // Bridge the gap between the live stream dropping a just-settled thread
+ // and the archived snapshot returning it: hold the shell we settled,
+ // marked archived, until the snapshot carries it. Held explicitly at
+ // settle time so deleted threads are never resurrected.
+ const [settledHolds, setSettledHolds] = useState>(
+ () => new Map(),
+ );
+ const handleSettleThread = useCallback(
+ (thread: EnvironmentThreadShell) => {
+ const threadKey = scopedThreadKey(thread.environmentId, thread.id);
+ setSettledHolds((current) =>
+ new Map(current).set(threadKey, {
+ ...thread,
+ archivedAt: thread.archivedAt ?? new Date().toISOString(),
+ }),
+ );
+ void (async () => {
+ // Roll the optimistic hold back if the settle was blocked or failed —
+ // otherwise a never-archived thread would render settled forever.
+ const succeeded = await props.onSettleThread(thread);
+ if (!succeeded) {
+ setSettledHolds((current) => {
+ const next = new Map(current);
+ next.delete(threadKey);
+ return next;
+ });
+ }
+ })();
+ },
+ [props.onSettleThread],
+ );
+ // Delete and un-settle both invalidate any hold for the thread.
+ const dropSettledHold = useCallback((thread: EnvironmentThreadShell) => {
+ setSettledHolds((current) => {
+ const threadKey = scopedThreadKey(thread.environmentId, thread.id);
+ if (!current.has(threadKey)) return current;
+ const next = new Map(current);
+ next.delete(threadKey);
+ return next;
+ });
+ }, []);
+ const handleDeleteThread = useCallback(
+ (thread: EnvironmentThreadShell) => {
+ dropSettledHold(thread);
+ props.onDeleteThread(thread);
+ },
+ [dropSettledHold, props.onDeleteThread],
+ );
+ const handleUnsettleThread = useCallback(
+ (thread: EnvironmentThreadShell) => {
+ dropSettledHold(thread);
+ props.onUnsettleThread(thread);
+ },
+ [dropSettledHold, props.onUnsettleThread],
+ );
+ useEffect(() => {
+ if (settledHolds.size === 0) return;
+ const covered = new Set();
+ for (const { environmentId, snapshot } of archivedSnapshots) {
+ for (const thread of snapshot.threads) {
+ covered.add(scopedThreadKey(environmentId, thread.id));
+ }
+ }
+ if ([...settledHolds.keys()].some((threadKey) => covered.has(threadKey))) {
+ setSettledHolds((current) => {
+ const next = new Map(current);
+ for (const threadKey of covered) next.delete(threadKey);
+ return next;
+ });
+ }
+ }, [archivedSnapshots, settledHolds]);
+ // The settled tail renders in pages; expansion resets when the filter
+ // context changes so environment/search flips never inherit a deep page.
+ const [settledVisibleCount, setSettledVisibleCount] = useState(
+ THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
+ );
+ const settledResetKey = `${props.selectedEnvironmentId ?? "all"}:${props.searchQuery.trim()}`;
+ const lastSettledResetKeyRef = useRef(settledResetKey);
+ if (lastSettledResetKeyRef.current !== settledResetKey) {
+ lastSettledResetKeyRef.current = settledResetKey;
+ setSettledVisibleCount(THREAD_LIST_V2_SETTLED_INITIAL_COUNT);
+ }
+ const showMoreSettled = useCallback(
+ () => setSettledVisibleCount((count) => count + THREAD_LIST_V2_SETTLED_PAGE_COUNT),
+ [],
+ );
+ const threadListV2Layout = useMemo(() => {
+ if (!threadListV2Enabled) return { items: [], hiddenSettledCount: 0 };
+ const merged = new Map();
+ for (const { environmentId, snapshot } of archivedSnapshots) {
+ for (const thread of snapshot.threads) {
+ merged.set(scopedThreadKey(environmentId, thread.id), { ...thread, environmentId });
+ }
+ }
+ for (const thread of props.threads) {
+ merged.set(scopedThreadKey(thread.environmentId, thread.id), thread);
+ }
+ for (const [threadKey, shell] of settledHolds) {
+ if (merged.has(threadKey)) continue;
+ merged.set(threadKey, shell);
+ }
+ return buildThreadListV2Items({
+ threads: [...merged.values()],
+ environmentId: props.selectedEnvironmentId,
+ searchQuery: props.searchQuery,
+ changeRequestStateByKey,
+ settledLimit: settledVisibleCount,
+ });
+ }, [
+ changeRequestStateByKey,
+ settledHolds,
+ settledVisibleCount,
+ archivedSnapshots,
+ props.searchQuery,
+ props.selectedEnvironmentId,
+ props.threads,
+ threadListV2Enabled,
+ ]);
+ const threadListV2Items = threadListV2Layout.items;
+
+ const renderV2Item = useCallback(
+ ({ item }: LegendListRenderItemProps) => (
+
+ ),
+ [
+ handleChangeRequestState,
+ handleDeleteThread,
+ handleSettleThread,
+ handleSwipeableClose,
+ handleSwipeableWillOpen,
+ handleUnsettleThread,
+ projectByKey,
+ projectCwdByKey,
+ props.onArchiveThread,
+ props.onSelectThread,
+ ],
+ );
+ const v2KeyExtractor = useCallback(
+ (item: ThreadListV2Item) => `${item.thread.environmentId}:${item.thread.id}`,
+ [],
+ );
+
const extraData = useMemo(
() => ({ savedConnectionsById: props.savedConnectionsById, projectCwdByKey }),
[props.savedConnectionsById, projectCwdByKey],
@@ -360,8 +575,12 @@ export function HomeScreen(props: HomeScreenProps) {
const keyExtractor = useCallback((item: HomeListItem) => item.key, []);
/* Empty states */
+ // v2 shows archived threads as its settled tail, so an archived-only
+ // workspace still has a list to render there.
const hasAnyThreads =
- props.threads.some((thread) => thread.archivedAt === null) || props.pendingTasks.length > 0;
+ props.threads.some((thread) => thread.archivedAt === null) ||
+ props.pendingTasks.length > 0 ||
+ (threadListV2Enabled && threadListV2Items.length > 0);
const hasResults = projectGroups.length > 0;
const selectedEnvironmentLabel =
props.selectedEnvironmentId === null
@@ -427,6 +646,28 @@ export function HomeScreen(props: HomeScreenProps) {
>
);
+ // v2 renders queued offline tasks above the thread cards — they are not
+ // thread shells, so the v2 item builder never sees them, but they must
+ // stay visible and deletable while their environment is offline.
+ const v2ListHeader = (
+ <>
+ {listHeader}
+ {props.pendingTasks.map((pendingTask, index) => (
+
+ ))}
+ >
+ );
+
const listEmpty = !hasResults ? (
hasSearchQuery ? (
@@ -440,6 +681,56 @@ export function HomeScreen(props: HomeScreenProps) {
)
) : null;
+ if (threadListV2Enabled) {
+ return (
+
+
+ 0 ? (
+ ({ opacity: pressed ? 0.6 : 1 })}
+ >
+
+ Show more ({threadListV2Layout.hiddenSettledCount} settled hidden)
+
+
+ ) : null
+ }
+ ListEmptyComponent={listEmpty}
+ style={{ flex: 1 }}
+ automaticallyAdjustsScrollIndicatorInsets={Platform.OS === "ios"}
+ contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"}
+ showsVerticalScrollIndicator={false}
+ keyboardDismissMode="on-drag"
+ keyboardShouldPersistTaps="handled"
+ {...scrollGateHandlers}
+ recycleItems
+ scrollEventThrottle={16}
+ contentContainerStyle={{
+ paddingBottom:
+ Platform.OS === "ios"
+ ? Math.max(insets.bottom, 24) + 24
+ : Math.max(insets.bottom, 16) + 88,
+ }}
+ />
+
+ {connectionStatus}
+
+ );
+ }
+
return (
{/* Sticky headers are deliberately not wired up: LegendList's JS sticky
diff --git a/apps/mobile/src/features/home/thread-swipe-actions.tsx b/apps/mobile/src/features/home/thread-swipe-actions.tsx
index 84a9f32ee5e..666169f3039 100644
--- a/apps/mobile/src/features/home/thread-swipe-actions.tsx
+++ b/apps/mobile/src/features/home/thread-swipe-actions.tsx
@@ -167,6 +167,13 @@ export function ThreadSwipeable(props: {
/** Disables NEW swipe activations (e.g. while the list scrolls). */
readonly enabled?: boolean;
readonly enableTrackpadSwipe?: boolean;
+ /**
+ * What a full swipe commits: "delete" (default, v1 behavior — the Delete
+ * button stretches) or "primary" — the advertised primary action fires and
+ * its button stretches instead. A full swipe must always match the action
+ * the stretching button advertises.
+ */
+ readonly fullSwipeAction?: "delete" | "primary";
readonly fullSwipeWidth: number;
readonly onDelete: () => void;
readonly onSwipeableClose?: (methods: SwipeableMethods) => void;
@@ -239,7 +246,11 @@ export function ThreadSwipeable(props: {
if (fullSwipeArmedRef.current) {
fullSwipeArmedRef.current = false;
methods.close();
- props.onDelete();
+ if (props.fullSwipeAction === "primary") {
+ props.primaryAction.onPress();
+ } else {
+ props.onDelete();
+ }
}
}}
overshootFriction={1}
@@ -247,6 +258,7 @@ export function ThreadSwipeable(props: {
renderRightActions={(_progress, translation, methods) => (
void;
readonly onFullSwipeArmedChange: (armed: boolean) => void;
@@ -430,6 +443,7 @@ export function ThreadSwipeActions(props: {
readonly threadTitle: string;
readonly translation: SharedValue;
}) {
+ const fullSwipeIsPrimary = props.fullSwipeAction === "primary";
useAnimatedReaction(
() => -props.translation.value >= props.fullSwipeThreshold,
(armed, previous) => {
@@ -457,7 +471,7 @@ export function ThreadSwipeActions(props: {
icon={props.primaryAction.icon}
label={props.primaryAction.label}
onPress={props.primaryAction.onPress}
- stretchesOnFullSwipe={false}
+ stretchesOnFullSwipe={fullSwipeIsPrimary}
translation={props.translation}
/>
diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts
index 8effdc942d9..1b347df2c55 100644
--- a/apps/mobile/src/features/home/useThreadListActions.ts
+++ b/apps/mobile/src/features/home/useThreadListActions.ts
@@ -6,19 +6,26 @@ import { Alert } from "react-native";
import { showConfirmDialog } from "../../components/ConfirmDialogHost";
import { scopedThreadKey } from "../../lib/scopedEntities";
+import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThreadSnapshots";
import { threadEnvironment } from "../../state/threads";
import { useAtomCommand } from "../../state/use-atom-command";
-type ThreadListAction = "archive" | "unarchive" | "delete";
+type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle";
+
+const ACTION_VERBS: Record = {
+ archive: "archived",
+ unarchive: "unarchived",
+ delete: "deleted",
+ settle: "settled",
+ unsettle: "un-settled",
+};
function actionFailureMessage(action: ThreadListAction, cause: Cause.Cause): string {
const error = Cause.squash(cause);
if (error instanceof Error && error.message.trim().length > 0) {
return error.message;
}
- const verb =
- action === "archive" ? "archived" : action === "unarchive" ? "unarchived" : "deleted";
- return `The thread could not be ${verb}.`;
+ return `The thread could not be ${ACTION_VERBS[action]}.`;
}
function selectionHaptic(): void {
@@ -28,54 +35,97 @@ function selectionHaptic(): void {
function actionFailureTitle(action: ThreadListAction): string {
if (action === "archive") return "Could not archive thread";
if (action === "unarchive") return "Could not unarchive thread";
+ if (action === "settle") return "Could not settle thread";
+ if (action === "unsettle") return "Could not un-settle thread";
return "Could not delete thread";
}
+/** Resolves to true iff the action was dispatched and succeeded. */
function useThreadActionExecutor(
onCompleted?: (action: ThreadListAction, thread: EnvironmentThreadShell) => void,
) {
const archiveMutation = useAtomCommand(threadEnvironment.archive, { reportFailure: false });
const unarchiveMutation = useAtomCommand(threadEnvironment.unarchive, { reportFailure: false });
const deleteMutation = useAtomCommand(threadEnvironment.delete, { reportFailure: false });
+ // Client-only settled model: settle/unsettle ride the archive lifecycle so
+ // no server upgrade is required. See client-runtime threadSettled.ts.
+ const settleMutation = archiveMutation;
+ const unsettleMutation = unarchiveMutation;
const inFlightThreadKeys = useRef(new Set());
const executeAction = useCallback(
async (action: ThreadListAction, thread: EnvironmentThreadShell) => {
const key = scopedThreadKey(thread.environmentId, thread.id);
if (inFlightThreadKeys.current.has(key)) {
- return;
+ return false;
}
inFlightThreadKeys.current.add(key);
selectionHaptic();
try {
+ // Settle rides archive, so it inherits archive's guard: never
+ // interrupt a thread mid-turn.
+ if (
+ (action === "settle" || action === "archive") &&
+ thread.session?.status === "running" &&
+ thread.session.activeTurnId != null
+ ) {
+ Alert.alert(
+ actionFailureTitle(action),
+ "This thread is working. Interrupt it first, then try again.",
+ );
+ return false;
+ }
+ // Auto-settled rows (inactivity / merged PR) are not archived;
+ // unarchiving them would be rejected. Nothing to undo — no-op.
+ if (action === "unsettle" && thread.archivedAt === null) {
+ return false;
+ }
const mutation =
- action === "archive"
- ? archiveMutation
- : action === "unarchive"
- ? unarchiveMutation
- : deleteMutation;
+ action === "settle"
+ ? settleMutation
+ : action === "unsettle"
+ ? unsettleMutation
+ : action === "archive"
+ ? archiveMutation
+ : action === "unarchive"
+ ? unarchiveMutation
+ : deleteMutation;
const result = await mutation({
environmentId: thread.environmentId,
input: { threadId: thread.id },
});
if (result._tag === "Failure") {
Alert.alert(actionFailureTitle(action), actionFailureMessage(action, result.cause));
- return;
+ return false;
}
+ // Archived threads leave the live shell stream, and the v2 list
+ // renders them from the archived snapshot — keep it fresh for every
+ // action that changes what that snapshot should contain (delete
+ // included, or a deleted settled row lingers until some later
+ // refresh).
+ refreshArchivedThreadsForEnvironment(thread.environmentId);
onCompleted?.(action, thread);
+ return true;
} finally {
inFlightThreadKeys.current.delete(key);
}
},
- [archiveMutation, deleteMutation, onCompleted, unarchiveMutation],
+ [
+ archiveMutation,
+ deleteMutation,
+ onCompleted,
+ settleMutation,
+ unarchiveMutation,
+ unsettleMutation,
+ ],
);
return executeAction;
}
function useConfirmDeleteThread(
- executeAction: (action: ThreadListAction, thread: EnvironmentThreadShell) => Promise,
+ executeAction: (action: ThreadListAction, thread: EnvironmentThreadShell) => Promise,
) {
return useCallback(
(thread: EnvironmentThreadShell) => {
@@ -111,6 +161,8 @@ function useConfirmDeleteThread(
export function useThreadListActions(): {
readonly archiveThread: (thread: EnvironmentThreadShell) => void;
readonly confirmDeleteThread: (thread: EnvironmentThreadShell) => void;
+ readonly settleThread: (thread: EnvironmentThreadShell) => Promise;
+ readonly unsettleThread: (thread: EnvironmentThreadShell) => void;
} {
const executeAction = useThreadActionExecutor();
@@ -120,10 +172,20 @@ export function useThreadListActions(): {
},
[executeAction],
);
+ const settleThread = useCallback(
+ async (thread: EnvironmentThreadShell) => (await executeAction("settle", thread)) === true,
+ [executeAction],
+ );
+ const unsettleThread = useCallback(
+ (thread: EnvironmentThreadShell) => {
+ void executeAction("unsettle", thread);
+ },
+ [executeAction],
+ );
const confirmDeleteThread = useConfirmDeleteThread(executeAction);
- return { archiveThread, confirmDeleteThread };
+ return { archiveThread, confirmDeleteThread, settleThread, unsettleThread };
}
export function useArchivedThreadListActions(
diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx
index 6c67a4d89e8..9aea392555a 100644
--- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx
+++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx
@@ -123,6 +123,8 @@ function LocalSettingsRouteScreen() {
+
+
@@ -506,6 +508,8 @@ function ConfiguredSettingsRouteScreen() {
+
+
@@ -514,6 +518,35 @@ function ConfiguredSettingsRouteScreen() {
);
}
+/**
+ * Device-local beta toggles. Mobile has no client-settings sync, so this is
+ * the counterpart of web's Settings → Beta backed by mobile preferences.
+ */
+function BetaSettingsSection() {
+ const preferencesResult = useAtomValue(mobilePreferencesAtom);
+ const savePreferences = useAtomSet(updateMobilePreferencesAtom);
+ const threadListV2Enabled = AsyncResult.isSuccess(preferencesResult)
+ ? preferencesResult.value.threadListV2Enabled === true
+ : false;
+
+ return (
+
+
+ savePreferences({ threadListV2Enabled: value })}
+ />
+
+
+ One flat thread list in creation order. Active work renders as cards; settled threads
+ collapse to compact rows. Switch back any time.
+
+
+ );
+}
+
function AppSettingsSection() {
const icon = useThemeColor("--color-icon");
diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx
new file mode 100644
index 00000000000..99667a1b5c6
--- /dev/null
+++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx
@@ -0,0 +1,382 @@
+import type {
+ EnvironmentProject,
+ EnvironmentThreadShell,
+} from "@t3tools/client-runtime/state/shell";
+import type { MenuAction } from "@react-native-menu/menu";
+import { memo, useCallback, useMemo, type ComponentProps, type ReactNode } from "react";
+import { Platform, Pressable, useWindowDimensions, View } from "react-native";
+import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
+import Animated, { useAnimatedStyle, useSharedValue, withSpring } from "react-native-reanimated";
+
+import { SymbolView } from "../../components/AppSymbol";
+import { AppText as Text } from "../../components/AppText";
+import { ControlPillMenu } from "../../components/ControlPill";
+import { ProjectFavicon } from "../../components/ProjectFavicon";
+import { cn } from "../../lib/cn";
+import { relativeTime } from "../../lib/time";
+import { useThemeColor } from "../../lib/useThemeColor";
+import { useThreadPr } from "../../state/use-thread-pr";
+import { ThreadSwipeable } from "../home/thread-swipe-actions";
+import { resolveThreadListV2Status, type ThreadListV2Status } from "./threadListV2";
+import { useEffect } from "react";
+
+/**
+ * Thread List v2 rows. The design language is the web sidebar v2 (status
+ * edge strip, mono project labels, settled tail), but the card anatomy is
+ * native iOS list-app, not the web's window chrome: a solid raised surface
+ * (no outline), a leading favicon tile as the touch anchor, a trailing
+ * chevron, and spring press feedback. Bordered translucent boxes with a
+ * header row read as notifications — information, not buttons.
+ */
+
+const MONO_FONT = Platform.select({
+ ios: "Menlo",
+ android: "monospace",
+ default: "monospace",
+});
+
+const EDGE_CLASS_BY_STATUS: Partial> = {
+ approval: "bg-amber-500 dark:bg-amber-400",
+ working: "bg-sky-500 dark:bg-sky-400",
+ failed: "bg-red-500",
+};
+
+const STATUS_WORD_BY_STATUS: Partial<
+ Record
+> = {
+ approval: { label: "NEEDS APPROVAL", className: "text-amber-600 dark:text-amber-400" },
+ working: { label: "WORKING", className: "text-sky-600 dark:text-sky-400" },
+ failed: { label: "FAILED", className: "text-red-600 dark:text-red-400" },
+};
+
+function threadTimeLabel(thread: EnvironmentThreadShell, status: ThreadListV2Status): string {
+ if (status === "approval") {
+ return `waiting ${relativeTime(thread.updatedAt)}`;
+ }
+ return relativeTime(thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt);
+}
+
+const CARD_MENU_ACTIONS: MenuAction[] = [
+ { id: "settle", title: "Settle", image: "checkmark" },
+ { id: "archive", title: "Archive", image: "archivebox" },
+ { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },
+];
+
+const SLIM_MENU_ACTIONS: MenuAction[] = [
+ { id: "unsettle", title: "Un-settle", image: "arrow.uturn.backward" },
+ { id: "archive", title: "Archive", image: "archivebox" },
+ { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },
+];
+
+const PRESS_SPRING = { damping: 30, stiffness: 400 } as const;
+
+/**
+ * Pressable that springs down to 97% while touched — the "this is a real
+ * object under your finger" signal every native list app ships.
+ *
+ * Accepts an injected `onLongPress` and forwards it to the inner Pressable:
+ * on Android, ControlPillMenu opens its menu by cloning its immediate child
+ * with an onLongPress prop, so this component must be that child and must
+ * route the handler to something that actually handles presses.
+ */
+function PressableScaleCard(props: {
+ readonly accessibilityHint: string;
+ readonly accessibilityLabel: string;
+ readonly onPress: () => void;
+ readonly onLongPress?: () => void;
+ readonly children: ReactNode;
+}) {
+ const scale = useSharedValue(1);
+ const animatedStyle = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] }));
+
+ return (
+
+ {
+ scale.value = withSpring(0.97, PRESS_SPRING);
+ }}
+ onPressOut={() => {
+ scale.value = withSpring(1, PRESS_SPRING);
+ }}
+ >
+ {props.children}
+
+
+ );
+}
+
+export const ThreadListV2SettledDivider = memo(function ThreadListV2SettledDivider() {
+ const separatorColor = useThemeColor("--color-separator");
+ return (
+
+
+ Settled
+
+
+
+ );
+});
+
+export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
+ readonly thread: EnvironmentThreadShell;
+ readonly variant: "card" | "slim";
+ readonly showSettledDivider: boolean;
+ readonly project: EnvironmentProject | null;
+ readonly onSelectThread: (thread: EnvironmentThreadShell) => void;
+ readonly onArchiveThread: (thread: EnvironmentThreadShell) => void;
+ readonly onDeleteThread: (thread: EnvironmentThreadShell) => void;
+ readonly onSettleThread: (thread: EnvironmentThreadShell) => void;
+ readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void;
+ readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void;
+ readonly onSwipeableClose: (methods: SwipeableMethods) => void;
+ /** Reports this row's live PR state up so the partition can auto-settle
+ merged/closed work (mirrors web's onChangeRequestState). */
+ readonly onChangeRequestState?: (
+ threadKey: string,
+ state: "open" | "closed" | "merged" | null,
+ ) => void;
+ readonly projectCwd?: string | null;
+ readonly simultaneousSwipeGesture?: ComponentProps<
+ typeof ThreadSwipeable
+ >["simultaneousWithExternalGesture"];
+}) {
+ const { width: windowWidth } = useWindowDimensions();
+ const {
+ thread,
+ variant,
+ onSelectThread,
+ onArchiveThread,
+ onDeleteThread,
+ onSettleThread,
+ onUnsettleThread,
+ onChangeRequestState,
+ } = props;
+
+ const pr = useThreadPr(thread, props.projectCwd ?? props.project?.workspaceRoot ?? null);
+ const prState = pr?.state ?? null;
+ const threadKey = `${thread.environmentId}:${thread.id}`;
+ useEffect(() => {
+ onChangeRequestState?.(threadKey, prState);
+ }, [onChangeRequestState, prState, threadKey]);
+
+ const iconSubtleColor = useThemeColor("--color-icon-subtle");
+ const screenColor = useThemeColor("--color-screen");
+
+ const status = resolveThreadListV2Status(thread);
+ const statusEdge = EDGE_CLASS_BY_STATUS[status];
+ const statusWord = STATUS_WORD_BY_STATUS[status];
+ const timeLabel = threadTimeLabel(thread, status);
+
+ const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]);
+ const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]);
+ const handleSettle = useCallback(() => onSettleThread(thread), [onSettleThread, thread]);
+ const handleUnsettle = useCallback(() => onUnsettleThread(thread), [onUnsettleThread, thread]);
+ const handleMenuAction = useCallback(
+ ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => {
+ if (nativeEvent.event === "settle") handleSettle();
+ if (nativeEvent.event === "unsettle") handleUnsettle();
+ if (nativeEvent.event === "archive") handleArchive();
+ if (nativeEvent.event === "delete") handleDelete();
+ },
+ [handleArchive, handleDelete, handleSettle, handleUnsettle],
+ );
+
+ // Swipe: the v2 primary action is the lifecycle transition. Un-settle only
+ // exists when there is an archive to undo; an auto-settled slim row
+ // (inactivity / merged PR, archivedAt null) offers Settle, which archives
+ // it — the explicit "keep it settled" the row can actually deliver.
+ const canUnsettle = variant === "slim" && thread.archivedAt !== null;
+ const primaryAction = useMemo(
+ () =>
+ canUnsettle
+ ? {
+ accessibilityLabel: `Un-settle ${thread.title}`,
+ icon: "arrow.uturn.backward" as const,
+ label: "Un-settle",
+ onPress: handleUnsettle,
+ }
+ : {
+ accessibilityLabel: `Settle ${thread.title}`,
+ icon: "checkmark" as const,
+ label: "Settle",
+ onPress: handleSettle,
+ },
+ [canUnsettle, handleSettle, handleUnsettle, thread.title],
+ );
+
+ const rowContent = (close: () => void) =>
+ variant === "card" ? (
+ // PressableScaleCard must be the ROOT here: ControlPillMenu injects
+ // its Android long-press by cloning this element.
+ {
+ close();
+ onSelectThread(thread);
+ }}
+ >
+
+ {/* Solid raised card: bg-card with no border reads as an object,
+ not a notification banner. */}
+
+ {statusEdge ? : null}
+ {/* Favicon tile: the leading touch anchor, app-icon style. */}
+
+ {props.project ? (
+
+ ) : null}
+
+
+
+
+ {props.project?.title ?? ""}
+
+
+ {timeLabel}
+
+
+
+ {thread.title}
+
+ {statusWord || thread.branch || (status === "failed" && thread.session?.lastError) ? (
+
+ {statusWord ? (
+
+ {statusWord.label}
+
+ ) : null}
+ {status === "failed" && thread.session?.lastError ? (
+
+ {thread.session.lastError}
+
+ ) : thread.branch ? (
+
+ {thread.branch}
+
+ ) : null}
+
+ ) : null}
+
+ {/* Trailing chevron: the universal "this navigates" affordance. */}
+
+
+
+
+
+
+ ) : (
+ {
+ close();
+ onSelectThread(thread);
+ }}
+ style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
+ >
+ {/* Settled history recedes: dimmed favicon + muted title. */}
+
+ {props.project ? (
+
+
+
+ ) : null}
+
+ {thread.title}
+
+
+ {relativeTime(thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt)}
+
+
+
+ );
+
+ return (
+ <>
+ {props.showSettledDivider ? : null}
+
+ {(close) => (
+
+ {rowContent(close)}
+
+ )}
+
+ >
+ );
+});
diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts
new file mode 100644
index 00000000000..3a656d0d201
--- /dev/null
+++ b/apps/mobile/src/features/threads/threadListV2.test.ts
@@ -0,0 +1,184 @@
+import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
+import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
+import { describe, expect, it } from "vite-plus/test";
+
+import {
+ buildThreadListV2Items,
+ resolveThreadListV2Status,
+ sortThreadsForListV2,
+} from "./threadListV2";
+
+const environmentId = EnvironmentId.make("environment-1");
+
+function makeThread(
+ input: Partial & Pick,
+): EnvironmentThreadShell {
+ return {
+ environmentId,
+ projectId: ProjectId.make("project-1"),
+ modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
+ runtimeMode: "full-access",
+ interactionMode: "default",
+ branch: null,
+ worktreePath: null,
+ latestTurn: null,
+ createdAt: "2026-06-01T00:00:00.000Z",
+ updatedAt: "2026-06-01T00:00:00.000Z",
+ archivedAt: null,
+ session: null,
+ latestUserMessageAt: null,
+ hasPendingApprovals: false,
+ hasPendingUserInput: false,
+ hasActionableProposedPlan: false,
+ ...input,
+ };
+}
+
+const NOW = "2026-06-02T00:00:00.000Z";
+
+describe("resolveThreadListV2Status", () => {
+ it("prioritizes approval over a running session", () => {
+ const thread = makeThread({
+ id: ThreadId.make("t"),
+ title: "t",
+ hasPendingApprovals: true,
+ session: {
+ threadId: ThreadId.make("t"),
+ status: "running",
+ providerName: "Codex",
+ providerInstanceId: ProviderInstanceId.make("codex"),
+ runtimeMode: "full-access",
+ activeTurnId: null,
+ lastError: null,
+ updatedAt: NOW,
+ },
+ });
+ expect(resolveThreadListV2Status(thread)).toBe("approval");
+ });
+
+ it("resolves ready for quiescent threads", () => {
+ expect(resolveThreadListV2Status(makeThread({ id: ThreadId.make("t"), title: "t" }))).toBe(
+ "ready",
+ );
+ });
+});
+
+describe("sortThreadsForListV2", () => {
+ it("orders by creation time, newest first, ignoring activity", () => {
+ const sorted = sortThreadsForListV2([
+ { id: "oldest", createdAt: "2026-06-01T08:00:00.000Z" },
+ { id: "newest", createdAt: "2026-06-01T12:00:00.000Z" },
+ { id: "middle", createdAt: "2026-06-01T10:00:00.000Z" },
+ ]);
+ expect(sorted.map((thread) => thread.id)).toEqual(["newest", "middle", "oldest"]);
+ });
+});
+
+describe("buildThreadListV2Items", () => {
+ it("partitions archived (settled) threads into a slim tail with one divider", () => {
+ const { items } = buildThreadListV2Items({
+ threads: [
+ makeThread({ id: ThreadId.make("active"), title: "Active" }),
+ makeThread({
+ id: ThreadId.make("settled"),
+ title: "Settled",
+ archivedAt: NOW,
+ }),
+ makeThread({
+ id: ThreadId.make("settled-2"),
+ title: "Settled 2",
+ archivedAt: NOW,
+ }),
+ ],
+ environmentId: null,
+ searchQuery: "",
+ now: NOW,
+ });
+
+ expect(items.map((item) => [item.thread.id, item.variant])).toEqual([
+ ["active", "card"],
+ ["settled", "slim"],
+ ["settled-2", "slim"],
+ ]);
+ expect(items.map((item) => item.showSettledDivider)).toEqual([false, true, false]);
+ expect(items.map((item) => item.isLast)).toEqual([false, false, true]);
+ });
+
+ it("keeps cards in creation order while settled sorts by recency", () => {
+ const { items } = buildThreadListV2Items({
+ threads: [
+ makeThread({
+ id: ThreadId.make("older-created"),
+ title: "Older",
+ createdAt: "2026-06-01T08:00:00.000Z",
+ updatedAt: NOW, // recent activity must NOT promote it
+ }),
+ makeThread({
+ id: ThreadId.make("newer-created"),
+ title: "Newer",
+ createdAt: "2026-06-01T12:00:00.000Z",
+ }),
+ ],
+ environmentId: null,
+ searchQuery: "",
+ now: NOW,
+ });
+
+ expect(items.map((item) => item.thread.id)).toEqual(["newer-created", "older-created"]);
+ });
+
+ it("keeps archived threads in the tail and filters by search query", () => {
+ const { items } = buildThreadListV2Items({
+ threads: [
+ makeThread({ id: ThreadId.make("match"), title: "Fix login bug" }),
+ makeThread({ id: ThreadId.make("miss"), title: "Greeting" }),
+ makeThread({
+ id: ThreadId.make("archived"),
+ title: "Fix login again",
+ archivedAt: NOW,
+ }),
+ ],
+ environmentId: null,
+ searchQuery: "login",
+ now: NOW,
+ });
+
+ expect(items.map((item) => [item.thread.id, item.variant])).toEqual([
+ ["match", "card"],
+ ["archived", "slim"],
+ ]);
+ });
+});
+
+describe("buildThreadListV2Items settled paging", () => {
+ it("caps the settled tail at settledLimit and reports the hidden count", () => {
+ const threads = [
+ makeThread({ id: ThreadId.make("active"), title: "Active" }),
+ ...Array.from({ length: 4 }, (_, index) =>
+ makeThread({
+ id: ThreadId.make(`settled-${index}`),
+ title: `Settled ${index}`,
+ archivedAt: NOW,
+ latestUserMessageAt: `2026-06-01T0${index}:00:00.000Z`,
+ }),
+ ),
+ ];
+
+ const layout = buildThreadListV2Items({
+ threads,
+ environmentId: null,
+ searchQuery: "",
+ settledLimit: 2,
+ now: NOW,
+ });
+
+ expect(layout.hiddenSettledCount).toBe(2);
+ expect(layout.items.filter((item) => item.variant === "slim")).toHaveLength(2);
+ // Most recent settled first — the hidden ones are the oldest.
+ expect(layout.items.map((item) => item.thread.id)).toEqual([
+ "active",
+ "settled-3",
+ "settled-2",
+ ]);
+ });
+});
diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts
new file mode 100644
index 00000000000..8a62da608fe
--- /dev/null
+++ b/apps/mobile/src/features/threads/threadListV2.ts
@@ -0,0 +1,126 @@
+import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled";
+import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
+import type { EnvironmentId } from "@t3tools/contracts";
+
+/**
+ * Thread List v2 model, ported from the web sidebar v2
+ * (apps/web/src/components/Sidebar.logic.ts + SidebarV2.tsx).
+ *
+ * Four visual states, three colors: color is reserved for "act now"
+ * (approval), "in motion" (working), and "broken" (failed). Ready is the
+ * unlabeled resting state.
+ */
+export type ThreadListV2Status = "approval" | "working" | "failed" | "ready";
+
+export function resolveThreadListV2Status(
+ thread: Pick,
+): ThreadListV2Status {
+ if (thread.hasPendingApprovals) {
+ return "approval";
+ }
+ if (thread.session?.status === "running" || thread.session?.status === "starting") {
+ return "working";
+ }
+ if (thread.session?.status === "error") {
+ return "failed";
+ }
+ return "ready";
+}
+
+/**
+ * v2 sort: static creation order, newest thread on top. Activity NEVER
+ * reorders the list — a row holds its position from open until settled, so
+ * the screen only moves at lifecycle transitions. Mirrors web's
+ * sortThreadsForSidebarV2.
+ */
+export function sortThreadsForListV2(
+ threads: readonly T[],
+): T[] {
+ // .sort() on a copy, not .toSorted(): Hermes doesn't ship the ES2023
+ // change-by-copy array methods.
+ return [...threads].sort(
+ (left, right) =>
+ Date.parse(right.createdAt) - Date.parse(left.createdAt) || left.id.localeCompare(right.id),
+ );
+}
+
+export interface ThreadListV2Item {
+ readonly thread: EnvironmentThreadShell;
+ readonly variant: "card" | "slim";
+ /** First settled row after the card block draws the SETTLED divider. */
+ readonly showSettledDivider: boolean;
+ readonly isLast: boolean;
+}
+
+export interface ThreadListV2Layout {
+ readonly items: ThreadListV2Item[];
+ /** Settled threads beyond the render limit (behind "Show more"). */
+ readonly hiddenSettledCount: number;
+}
+
+/**
+ * Partitions visible threads into the active card block (creation order) and
+ * the settled recency tail, matching the web v2 list. `autoSettleAfterDays`
+ * mirrors the web default of 3 — mobile has no client-settings sync yet, so
+ * the default is fixed here rather than user-configurable.
+ */
+export function buildThreadListV2Items(input: {
+ readonly threads: ReadonlyArray;
+ readonly environmentId: EnvironmentId | null;
+ readonly searchQuery: string;
+ /** Per-row PR state reported up by visible rows ("env:threadId" keys). */
+ readonly changeRequestStateByKey?: ReadonlyMap;
+ readonly autoSettleAfterDays?: number;
+ /** Max settled rows to render; the rest are counted, not built. */
+ readonly settledLimit?: number;
+ /** Injectable for tests; defaults to now. */
+ readonly now?: string;
+}): ThreadListV2Layout {
+ const now = input.now ?? new Date().toISOString();
+ const autoSettleAfterDays = input.autoSettleAfterDays ?? 3;
+ const query = input.searchQuery.trim().toLocaleLowerCase();
+
+ const active: EnvironmentThreadShell[] = [];
+ const settled: EnvironmentThreadShell[] = [];
+ for (const thread of input.threads) {
+ // Archived threads stay in the list: in the client-only settled model,
+ // archive IS settle, so they render as the settled tail.
+ if (input.environmentId !== null && thread.environmentId !== input.environmentId) continue;
+ if (query.length > 0 && !thread.title.toLocaleLowerCase().includes(query)) continue;
+ const changeRequestState =
+ input.changeRequestStateByKey?.get(`${thread.environmentId}:${thread.id}`) ?? null;
+ if (effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState })) {
+ settled.push(thread);
+ } else {
+ active.push(thread);
+ }
+ }
+
+ const orderedActive = sortThreadsForListV2(active);
+ const orderedSettled = [...settled].sort(
+ (left, right) =>
+ Date.parse(right.latestUserMessageAt ?? right.updatedAt) -
+ Date.parse(left.latestUserMessageAt ?? left.updatedAt),
+ );
+ const settledLimit = input.settledLimit ?? Number.POSITIVE_INFINITY;
+ const visibleSettled =
+ orderedSettled.length > settledLimit ? orderedSettled.slice(0, settledLimit) : orderedSettled;
+
+ const items: ThreadListV2Item[] = [];
+ for (const thread of orderedActive) {
+ items.push({ thread, variant: "card", showSettledDivider: false, isLast: false });
+ }
+ for (const [index, thread] of visibleSettled.entries()) {
+ items.push({
+ thread,
+ variant: "slim",
+ showSettledDivider: index === 0,
+ isLast: false,
+ });
+ }
+ const last = items.at(-1);
+ if (last) {
+ items[items.length - 1] = { ...last, isLast: true };
+ }
+ return { items, hiddenSettledCount: orderedSettled.length - visibleSettled.length };
+}
diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts
index 6971570b0e6..1138ad2b655 100644
--- a/apps/mobile/src/persistence/mobile-preferences.ts
+++ b/apps/mobile/src/persistence/mobile-preferences.ts
@@ -22,6 +22,12 @@ export interface Preferences {
readonly codeWordBreak?: boolean;
readonly connectOnboardingOptOutAccounts?: ReadonlyArray;
readonly collapsedProjectGroups?: readonly string[];
+ /**
+ * Device-local mirror of the web beta's `sidebarV2Enabled`. Mobile has no
+ * client-settings sync, so the flat v2 thread list is opted into per
+ * device.
+ */
+ readonly threadListV2Enabled?: boolean;
}
export class MobilePreferencesLoadError extends Schema.TaggedErrorClass()(
@@ -71,6 +77,7 @@ function sanitizePreferences(parsed: Preferences): Preferences {
codeWordBreak?: boolean;
connectOnboardingOptOutAccounts?: ReadonlyArray;
collapsedProjectGroups?: readonly string[];
+ threadListV2Enabled?: boolean;
} = {};
if (typeof parsed.liveActivitiesEnabled === "boolean") {
@@ -97,6 +104,9 @@ function sanitizePreferences(parsed: Preferences): Preferences {
(key): key is string => typeof key === "string",
);
}
+ if (typeof parsed.threadListV2Enabled === "boolean") {
+ preferences.threadListV2Enabled = parsed.threadListV2Enabled;
+ }
return preferences;
}
diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx
index 0f1a8f9d429..9ff0c90d293 100644
--- a/apps/web/src/components/AppSidebarLayout.tsx
+++ b/apps/web/src/components/AppSidebarLayout.tsx
@@ -1,12 +1,14 @@
import { useAtomValue } from "@effect/atom-react";
import { useEffect, type CSSProperties, type ReactNode } from "react";
-import { useNavigate } from "@tanstack/react-router";
+import { useLocation, useNavigate } from "@tanstack/react-router";
import { isElectron } from "../env";
import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
import { isMacPlatform } from "../lib/utils";
import { primaryServerKeybindingsAtom } from "../state/server";
+import { useClientSettings } from "../hooks/useSettings";
import ThreadSidebar from "./Sidebar";
+import ThreadSidebarV2 from "./SidebarV2";
import { Sidebar, SidebarProvider, SidebarRail, SidebarTrigger, useSidebar } from "./ui/sidebar";
import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
@@ -55,6 +57,12 @@ function SidebarControl() {
export function AppSidebarLayout({ children }: { children: ReactNode }) {
const navigate = useNavigate();
+ const sidebarV2Enabled = useClientSettings((settings) => settings.sidebarV2Enabled);
+ // Settings routes render the settings nav, which lives in the v1 component
+ // and is identical for both sidebars — so v1 stays mounted there.
+ const pathname = useLocation({ select: (location) => location.pathname });
+ const isOnSettings = pathname === "/settings" || pathname.startsWith("/settings/");
+ const useSidebarV2 = sidebarV2Enabled && !isOnSettings;
const macosWindowControlsStyle =
isElectron && isMacPlatform(navigator.platform)
? ({ "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET } as CSSProperties)
@@ -82,7 +90,14 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
@@ -90,7 +105,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
storageKey: THREAD_SIDEBAR_WIDTH_STORAGE_KEY,
}}
>
-
+ {useSidebarV2 ? : }
{children}
diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx
index 958cb3743c7..2ccaf4144e9 100644
--- a/apps/web/src/components/BranchToolbar.tsx
+++ b/apps/web/src/components/BranchToolbar.tsx
@@ -24,6 +24,7 @@ import {
import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector";
import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector";
import { BranchToolbarEnvModeSelector } from "./BranchToolbarEnvModeSelector";
+import { ProjectFavicon } from "./ProjectFavicon";
import { Button } from "./ui/button";
import {
Menu,
@@ -240,6 +241,21 @@ export const BranchToolbar = memo(function BranchToolbar({
return (
+ {/* The submit target leads the run-context strip: this is the one
+ surface where "which project am I about to send this to" must be
+ answered before the first keystroke, especially on drafts.
+ Mirrors the sidebar cards' title bar — favicon + mono name. */}
+
+
+
+ {activeProject.title}
+
+
+
{isMobile ? (
{
});
});
+describe("resolveSidebarV2Status", () => {
+ const session = {
+ threadId: ThreadId.make("thread-1"),
+ status: "running" as const,
+ providerName: "Codex",
+ providerInstanceId: ProviderInstanceId.make("codex"),
+ runtimeMode: DEFAULT_RUNTIME_MODE,
+ activeTurnId: "turn-1" as never,
+ lastError: null,
+ updatedAt: "2026-03-09T10:00:00.000Z",
+ };
+
+ it("prioritizes approval over a running session", () => {
+ expect(resolveSidebarV2Status({ hasPendingApprovals: true, session })).toBe("approval");
+ });
+
+ it("reports working for running and starting sessions", () => {
+ expect(resolveSidebarV2Status({ hasPendingApprovals: false, session })).toBe("working");
+ expect(
+ resolveSidebarV2Status({
+ hasPendingApprovals: false,
+ session: { ...session, status: "starting" as const },
+ }),
+ ).toBe("working");
+ });
+
+ it("reports failed only while the session status is error", () => {
+ expect(
+ resolveSidebarV2Status({
+ hasPendingApprovals: false,
+ session: { ...session, status: "error" as const, lastError: "boom" },
+ }),
+ ).toBe("failed");
+ expect(
+ resolveSidebarV2Status({
+ hasPendingApprovals: false,
+ session: { ...session, status: "stopped" as const, lastError: "persisted" },
+ }),
+ ).toBe("ready");
+ expect(
+ resolveSidebarV2Status({
+ hasPendingApprovals: false,
+ session: { ...session, status: "ready" as const, lastError: "persisted" },
+ }),
+ ).toBe("ready");
+ });
+
+ it("defaults to ready with no session", () => {
+ expect(resolveSidebarV2Status({ hasPendingApprovals: false, session: null })).toBe("ready");
+ });
+});
+
+describe("sortThreadsForSidebarV2", () => {
+ const sortable = (input: { id: string; createdAt: string }) => ({
+ id: input.id,
+ createdAt: input.createdAt,
+ });
+
+ it("orders by creation time, newest first, ignoring activity", () => {
+ const sorted = sortThreadsForSidebarV2([
+ sortable({ id: "oldest", createdAt: "2026-03-09T08:00:00.000Z" }),
+ sortable({ id: "newest", createdAt: "2026-03-09T12:00:00.000Z" }),
+ sortable({ id: "middle", createdAt: "2026-03-09T10:00:00.000Z" }),
+ ]);
+
+ expect(sorted.map((thread) => thread.id)).toEqual(["newest", "middle", "oldest"]);
+ });
+
+ it("breaks creation-time ties by id so the order is stable", () => {
+ const sorted = sortThreadsForSidebarV2([
+ sortable({ id: "b", createdAt: "2026-03-09T10:00:00.000Z" }),
+ sortable({ id: "a", createdAt: "2026-03-09T10:00:00.000Z" }),
+ ]);
+
+ expect(sorted.map((thread) => thread.id)).toEqual(["a", "b"]);
+ });
+});
+
describe("resolveThreadStatusPill", () => {
const baseThread = {
hasActionableProposedPlan: false,
diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts
index 4e7614ed551..d240b57d7d5 100644
--- a/apps/web/src/components/Sidebar.logic.ts
+++ b/apps/web/src/components/Sidebar.logic.ts
@@ -357,6 +357,41 @@ export function resolveThreadRowClassName(input: {
return cn(baseClassName, "text-muted-foreground hover:bg-accent hover:text-foreground");
}
+// ── Sidebar v2 status model ─────────────────────────────────────────
+// Four visual states, three colors: color is reserved for "act now"
+// (approval), "in motion" (working), and "broken" (failed). Ready is the
+// unlabeled resting state — the agent stopped and is waiting on the user,
+// whether it finished, asked a question, or proposed a plan.
+export type SidebarV2Status = "approval" | "working" | "failed" | "ready";
+
+type SidebarV2StatusInput = Pick;
+
+export function resolveSidebarV2Status(thread: SidebarV2StatusInput): SidebarV2Status {
+ if (thread.hasPendingApprovals) {
+ return "approval";
+ }
+ if (thread.session?.status === "running" || thread.session?.status === "starting") {
+ return "working";
+ }
+ if (thread.session?.status === "error") {
+ return "failed";
+ }
+ return "ready";
+}
+
+// v2 sort: static creation order, newest thread on top. Activity NEVER
+// reorders the list — a row holds its position from open until settled, so
+// the screen only moves at lifecycle transitions. Status (including pending
+// approval) is carried by each card's edge strip, not by position.
+export function sortThreadsForSidebarV2<
+ T extends { readonly id: string; readonly createdAt: string },
+>(threads: readonly T[]): T[] {
+ return [...threads].toSorted(
+ (left, right) =>
+ Date.parse(right.createdAt) - Date.parse(left.createdAt) || left.id.localeCompare(right.id),
+ );
+}
+
export function resolveThreadStatusPill(input: {
thread: ThreadStatusInput;
}): ThreadStatusPill | null {
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx
index bb6b2752a17..b02cc23ad1d 100644
--- a/apps/web/src/components/Sidebar.tsx
+++ b/apps/web/src/components/Sidebar.tsx
@@ -8,7 +8,6 @@ import {
Globe2Icon,
LoaderIcon,
SearchIcon,
- SettingsIcon,
SquarePenIcon,
TerminalIcon,
TriangleAlertIcon,
@@ -63,7 +62,7 @@ import {
settlePromise,
squashAtomCommandFailure,
} from "@t3tools/client-runtime/state/runtime";
-import { Link, useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router";
+import { useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router";
import {
MAX_SIDEBAR_THREAD_PREVIEW_COUNT,
MIN_SIDEBAR_THREAD_PREVIEW_COUNT,
@@ -74,7 +73,6 @@ import {
import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal";
import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps";
import { isElectron } from "../env";
-import { APP_STAGE_LABEL } from "../branding";
import { useOpenPrLink } from "../lib/openPullRequestLink";
import { isTerminalFocused } from "../lib/terminalFocus";
import { isMacPlatform } from "../lib/utils";
@@ -168,9 +166,7 @@ import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./u
import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
import {
SidebarContent,
- SidebarFooter,
SidebarGroup,
- SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
@@ -178,7 +174,6 @@ import {
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarSeparator,
- SidebarTrigger,
useSidebar,
} from "./ui/sidebar";
import { useThreadSelectionStore } from "../threadSelectionStore";
@@ -191,7 +186,6 @@ import {
resolveProjectStatusIndicator,
resolveSidebarNewThreadSeedContext,
resolveSidebarNewThreadEnvMode,
- resolveSidebarStageBadgeLabel,
resolveThreadRowClassName,
resolveThreadStatusPill,
orderItemsByPreferredIds,
@@ -201,12 +195,12 @@ import {
ThreadStatusPill,
} from "./Sidebar.logic";
import { sortThreads } from "../lib/threadSort";
-import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill";
+import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome";
import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
import { useIsMobile } from "~/hooks/useMediaQuery";
import { CommandDialogTrigger } from "./ui/command";
import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings";
-import { primaryServerConfigAtom, primaryServerKeybindingsAtom } from "../state/server";
+import { primaryServerKeybindingsAtom } from "../state/server";
import {
derivePhysicalProjectKey,
deriveProjectGroupingOverrideKey,
@@ -220,7 +214,6 @@ import {
type SidebarProjectGroupMember,
type SidebarProjectSnapshot,
} from "../sidebarProjectGrouping";
-import { SidebarProviderUpdatePill } from "./sidebar/SidebarProviderUpdatePill";
const SIDEBAR_SORT_LABELS: Record = {
updated_at: "Last user message",
created_at: "Created at",
@@ -2739,100 +2732,6 @@ function SortableProjectItem({
);
}
-const SidebarChromeHeader = memo(function SidebarChromeHeader({
- isElectron,
-}: {
- isElectron: boolean;
-}) {
- return isElectron ? (
-
-
-
-
- ) : (
-
-
-
-
- );
-});
-
-function SidebarBrand() {
- const stageLabel = useSidebarStageLabel();
-
- return (
-
-
-
- Code
-
-
- {stageLabel}
-
-
- );
-}
-
-function useSidebarStageLabel() {
- const primaryServerVersion =
- useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null;
-
- return resolveSidebarStageBadgeLabel({
- primaryServerVersion,
- fallbackStageLabel: APP_STAGE_LABEL,
- });
-}
-
-function T3Wordmark() {
- return (
-
- );
-}
-
-const SidebarChromeFooter = memo(function SidebarChromeFooter() {
- const navigate = useNavigate();
- const { isMobile, setOpenMobile } = useSidebar();
- const handleSettingsClick = useCallback(() => {
- if (isMobile) {
- setOpenMobile(false);
- }
- void navigate({ to: "/settings" });
- }, [isMobile, navigate, setOpenMobile]);
-
- return (
-
-
-
-
-
-
-
- Settings
-
-
-
-
- );
-});
-
interface SidebarProjectsContentProps {
showArm64IntelBuildWarning: boolean;
arm64IntelBuildWarningDescription: string | null;
diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx
new file mode 100644
index 00000000000..436b5ab2d38
--- /dev/null
+++ b/apps/web/src/components/SidebarV2.tsx
@@ -0,0 +1,1677 @@
+import { autoAnimate } from "@formkit/auto-animate";
+import { useAtomValue } from "@effect/atom-react";
+import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled";
+import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models";
+import {
+ scopeProjectRef,
+ scopeThreadRef,
+ scopedThreadKey,
+} from "@t3tools/client-runtime/environment";
+import type { ScopedThreadRef } from "@t3tools/contracts";
+import { CheckIcon, CloudIcon, PlusIcon, SearchIcon, Undo2Icon } from "lucide-react";
+import {
+ memo,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type KeyboardEvent as ReactKeyboardEvent,
+ type MouseEvent as ReactMouseEvent,
+} from "react";
+import { useParams, useRouter } from "@tanstack/react-router";
+
+import {
+ isAtomCommandInterrupted,
+ settlePromise,
+ squashAtomCommandFailure,
+} from "@t3tools/client-runtime/state/runtime";
+import { isElectron } from "../env";
+import {
+ resolveShortcutCommand,
+ shortcutLabelForCommand,
+ threadJumpCommandForIndex,
+ threadJumpIndexFromCommand,
+ threadTraversalDirectionFromCommand,
+} from "../keybindings";
+import { isTerminalFocused } from "../lib/terminalFocus";
+import { isModelPickerOpen } from "../modelPickerVisibility";
+import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
+import { isMacPlatform } from "~/lib/utils";
+import { useOpenPrLink } from "../lib/openPullRequestLink";
+import { readLocalApi } from "../localApi";
+import { useUiStateStore } from "../uiStateStore";
+import { useThreadSelectionStore } from "../threadSelectionStore";
+import { useThreadActions } from "../hooks/useThreadActions";
+import {
+ refreshArchivedThreadsForEnvironment,
+ useArchivedThreadSnapshots,
+} from "../lib/archivedThreadsState";
+import { useHandleNewThread } from "../hooks/useHandleNewThread";
+import { useOpenAddProjectCommandPalette } from "../commandPaletteContext";
+import { onOpenNewThreadPicker } from "../newThreadPickerBus";
+import { Dialog, DialogHeader, DialogPopup, DialogTitle } from "./ui/dialog";
+import {
+ resolveThreadActionProjectRef,
+ startNewThreadFromContext,
+ startNewThreadInProjectFromContext,
+} from "../lib/chatThreadActions";
+import { useClientSettings } from "../hooks/useSettings";
+import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments";
+import { useProjects, useThreadShells } from "../state/entities";
+import { primaryServerKeybindingsAtom } from "../state/server";
+import { vcsEnvironment } from "../state/vcs";
+import { threadEnvironment } from "../state/threads";
+import { useEnvironmentQuery } from "../state/query";
+import { useAtomCommand } from "../state/use-atom-command";
+import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes";
+import { formatElapsedDurationLabel, formatRelativeTimeLabel } from "../timestampFormat";
+import type { SidebarThreadSummary } from "../types";
+import { cn } from "~/lib/utils";
+import {
+ isTrailingDoubleClick,
+ resolveAdjacentThreadId,
+ resolveSidebarV2Status,
+ sortThreadsForSidebarV2,
+ type SidebarV2Status,
+} from "./Sidebar.logic";
+import { prStatusIndicator, resolveThreadPr } from "./ThreadStatusIndicators";
+import { ProjectFavicon } from "./ProjectFavicon";
+import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon";
+import { deriveProviderInstanceEntries, type ProviderInstanceEntry } from "../providerInstances";
+import { primaryServerProvidersAtom } from "../state/server";
+import { stackedThreadToast, toastManager } from "./ui/toast";
+import { CommandDialogTrigger } from "./ui/command";
+import { Kbd } from "./ui/kbd";
+import {
+ SidebarContent,
+ SidebarGroup,
+ SidebarMenu,
+ SidebarMenuButton,
+ SidebarMenuItem,
+ SidebarSeparator,
+ useSidebar,
+} from "./ui/sidebar";
+import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome";
+import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
+
+// Row heights are fixed per variant so the list only changes shape at
+// lifecycle transitions (settle/unsettle), never from streaming updates.
+// Cards are square, hard-edged blocks: a solid full-saturation edge strip
+// carries the state color; the border stays neutral and high-contrast.
+const CARD_EDGE_BY_STATUS: Partial> = {
+ approval: "bg-amber-500 dark:bg-amber-400",
+ working: "bg-sky-500 animate-status-pulse dark:bg-sky-400",
+ failed: "bg-red-500",
+};
+
+// Settled-tail paging: recent history is the common lookup; the deep tail
+// stays behind an explicit Show more.
+const SETTLED_TAIL_INITIAL_COUNT = 10;
+const SETTLED_TAIL_PAGE_COUNT = 25;
+
+const STATUS_WORD_BY_STATUS: Partial<
+ Record
+> = {
+ approval: { label: "Needs approval", className: "text-amber-600 dark:text-amber-400" },
+ working: { label: "Working", className: "text-sky-600 dark:text-sky-400" },
+ failed: { label: "Failed", className: "text-red-600 dark:text-red-400" },
+};
+
+// The working timer re-renders once per second only for rows that show it.
+function useTickWhile(active: boolean): number {
+ const [, setTick] = useState(0);
+ useEffect(() => {
+ if (!active) return;
+ const id = window.setInterval(() => setTick((value) => value + 1), 1_000);
+ return () => window.clearInterval(id);
+ }, [active]);
+ return active ? Date.now() : 0;
+}
+
+function threadTimeLabel(thread: SidebarThreadSummary, status: SidebarV2Status): string {
+ if (status === "working" && thread.latestTurn?.startedAt) {
+ return formatElapsedDurationLabel(thread.latestTurn.startedAt);
+ }
+ if (status === "approval") {
+ // Approval activities bump shell.updatedAt in the projection pipeline.
+ // The shell has no dedicated request timestamp, so this is the closest
+ // accurate wait-start signal shared by the label and approval ordering.
+ return `waiting ${formatElapsedDurationLabel(thread.updatedAt)}`;
+ }
+ const timestamp = thread.latestUserMessageAt ?? thread.updatedAt;
+ return formatRelativeTimeLabel(timestamp);
+}
+
+const SidebarV2Row = memo(function SidebarV2Row(props: {
+ thread: SidebarThreadSummary;
+ variant: "card" | "slim";
+ // Slim rows are either settled (action: un-settle) or merely quiet
+ // (seen Ready threads — action: settle).
+ variantAction: "settle" | "unsettle";
+ // Draws a hairline above the first settled row after a group's card
+ // block, so active work and the history tail read as separate zones
+ // inside each project section.
+ showQuietDivider?: boolean;
+ isActive: boolean;
+ jumpLabel: string | null;
+ currentEnvironmentId: string | null;
+ environmentLabel: string | null;
+ projectCwd: string | null;
+ projectTitle: string | null;
+ providerEntryByInstanceId: ReadonlyMap;
+ onThreadClick: (event: ReactMouseEvent, threadRef: ScopedThreadRef) => void;
+ onThreadActivate: (threadRef: ScopedThreadRef) => void;
+ onStartRename: (threadRef: ScopedThreadRef, title: string) => void;
+ onRenameTitleChange: (title: string) => void;
+ onCommitRename: (threadRef: ScopedThreadRef, title: string, originalTitle: string) => void;
+ onCancelRename: () => void;
+ isRenaming: boolean;
+ renamingTitle: string;
+ onContextMenu: (threadRef: ScopedThreadRef, position: { x: number; y: number }) => void;
+ onSettle: (threadRef: ScopedThreadRef) => void;
+ onUnsettle: (threadRef: ScopedThreadRef) => void;
+ onChangeRequestState: (threadKey: string, state: "open" | "closed" | "merged" | null) => void;
+}) {
+ const {
+ isRenaming,
+ onChangeRequestState,
+ onCancelRename,
+ onCommitRename,
+ onContextMenu,
+ onRenameTitleChange,
+ onSettle,
+ onStartRename,
+ onThreadActivate,
+ onThreadClick,
+ onUnsettle,
+ renamingTitle,
+ thread,
+ variant,
+ variantAction,
+ } = props;
+ const threadRef = useMemo(
+ () => scopeThreadRef(thread.environmentId, thread.id),
+ [thread.environmentId, thread.id],
+ );
+ const threadKey = scopedThreadKey(threadRef);
+ const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]);
+ const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey));
+ const openPrLink = useOpenPrLink();
+
+ const status = resolveSidebarV2Status(thread);
+ useTickWhile(variant === "card" && (status === "working" || status === "approval"));
+
+ const gitCwd = thread.worktreePath ?? props.projectCwd;
+ const gitStatus = useEnvironmentQuery(
+ thread.branch != null && gitCwd !== null
+ ? vcsEnvironment.status({
+ environmentId: thread.environmentId,
+ input: { cwd: gitCwd },
+ })
+ : null,
+ );
+ const pr = resolveThreadPr(thread.branch, gitStatus.data);
+ const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider);
+ // Report the PR state up: the parent partitions rows with effectiveSettled,
+ // and a merged/closed PR auto-settles a thread — data only rows have.
+ const prState = pr?.state ?? null;
+ useEffect(() => {
+ onChangeRequestState(threadKey, prState);
+ }, [onChangeRequestState, prState, threadKey]);
+
+ const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId;
+ const driverKind = props.providerEntryByInstanceId.get(modelInstanceId)?.driverKind ?? null;
+
+ const isUnread =
+ thread.latestTurn?.completedAt != null &&
+ (lastVisitedAt == null ||
+ Date.parse(thread.latestTurn.completedAt) > Date.parse(lastVisitedAt));
+
+ const isRemote =
+ props.currentEnvironmentId !== null && thread.environmentId !== props.currentEnvironmentId;
+
+ const handleClick = useCallback(
+ (event: ReactMouseEvent) => {
+ onThreadClick(event, threadRef);
+ },
+ [onThreadClick, threadRef],
+ );
+ const handleContextMenu = useCallback(
+ (event: ReactMouseEvent) => {
+ event.preventDefault();
+ onContextMenu(threadRef, { x: event.clientX, y: event.clientY });
+ },
+ [onContextMenu, threadRef],
+ );
+ const handleKeyDown = useCallback(
+ (event: ReactKeyboardEvent) => {
+ if (event.target !== event.currentTarget) return;
+ if (event.key !== "Enter" && event.key !== " ") return;
+ event.preventDefault();
+ onThreadActivate(threadRef);
+ },
+ [onThreadActivate, threadRef],
+ );
+ const handleDoubleClick = useCallback(
+ (event: ReactMouseEvent) => {
+ if (isRenaming || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
+ return;
+ }
+ if ((event.target as HTMLElement).closest("button, a, input")) return;
+ event.preventDefault();
+ onStartRename(threadRef, thread.title);
+ },
+ [isRenaming, onStartRename, thread.title, threadRef],
+ );
+ const renameCommittedRef = useRef(false);
+ useEffect(() => {
+ if (isRenaming) renameCommittedRef.current = false;
+ }, [isRenaming]);
+ const handleRenameKeyDown = useCallback(
+ (event: ReactKeyboardEvent) => {
+ event.stopPropagation();
+ if (event.key === "Enter") {
+ event.preventDefault();
+ renameCommittedRef.current = true;
+ onCommitRename(threadRef, renamingTitle, thread.title);
+ } else if (event.key === "Escape") {
+ event.preventDefault();
+ renameCommittedRef.current = true;
+ onCancelRename();
+ }
+ },
+ [onCancelRename, onCommitRename, renamingTitle, thread.title, threadRef],
+ );
+ const handleRenameBlur = useCallback(() => {
+ if (!renameCommittedRef.current) {
+ onCommitRename(threadRef, renamingTitle, thread.title);
+ }
+ }, [onCommitRename, renamingTitle, thread.title, threadRef]);
+ const handleSettleClick = useCallback(
+ (event: ReactMouseEvent) => {
+ event.preventDefault();
+ event.stopPropagation();
+ onSettle(threadRef);
+ },
+ [onSettle, threadRef],
+ );
+ const handleUnsettleClick = useCallback(
+ (event: ReactMouseEvent) => {
+ event.preventDefault();
+ event.stopPropagation();
+ onUnsettle(threadRef);
+ },
+ [onUnsettle, threadRef],
+ );
+ const handlePrClick = useCallback(
+ (event: ReactMouseEvent) => {
+ if (pr?.url) openPrLink(event, pr.url);
+ },
+ [openPrLink, pr],
+ );
+
+ const rowClassName = cn(
+ "group/v2-row relative w-full cursor-pointer select-none text-left",
+ props.isActive
+ ? "bg-foreground/10 dark:bg-white/[0.12]"
+ : isSelected
+ ? "bg-primary/15 dark:bg-primary/20"
+ : "hover:bg-foreground/5 dark:hover:bg-white/[0.06]",
+ );
+
+ const title = isRenaming ? (
+ onRenameTitleChange(event.target.value)}
+ onFocus={(event) => event.currentTarget.select()}
+ onKeyDown={handleRenameKeyDown}
+ onBlur={handleRenameBlur}
+ onClick={(event) => event.stopPropagation()}
+ onDoubleClick={(event) => event.stopPropagation()}
+ className="min-w-0 flex-1 border border-border bg-background px-1 text-[13px] text-foreground outline-none focus:border-foreground"
+ />
+ ) : (
+
+ {thread.title}
+
+ );
+
+ const prBadge =
+ prStatus && pr ? (
+
+ ) : null;
+
+ if (variant === "slim") {
+ return (
+