diff --git a/.plans/21-sidebar-v2-beta.html b/.plans/21-sidebar-v2-beta.html new file mode 100644 index 00000000000..146c8c0c521 --- /dev/null +++ b/.plans/21-sidebar-v2-beta.html @@ -0,0 +1,510 @@ + + + + + +T3 Code — Sidebar v2 Beta Plan + + + +
+ +

Sidebar v2 — beta plan

+

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

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

01What we're building

+

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

+
+
+

Changes

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

Deliberately not

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

02The mock

+

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

+ +
+ + +
+

Card anatomy (active threads, ~52px)

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

Four states, three colors

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

Harness · model · machine

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

Slim row (settled, ~28px)

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

Sort

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

03The settled model

+

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

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

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

+
effectiveSettled(thread) =
+  override === "settled"true   // manual settle
+  override === "active"false  // manual keep-active (beats auto rules)
+  pr.state ∈ {merged, closed}                      → true   // auto — strongest signal
+  lastActivityAt < now − inactivityThreshold       → true   // auto — backstop, default 3d, configurable
+  otherwise                                        → false
+ +
Inbox-zero hedge: manual settling must feel like optional satisfaction, not homework. The auto rules guarantee a user who never touches the affordance still gets a tidy list. Beta telemetry decides how prominent the affordance stays.
+ +

04Swap boundary

+

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

+ + + + + + + + + AppSidebarLayout.tsx:93 + {sidebarV2Enabled ? <ThreadSidebarV2/> : <ThreadSidebar/>} + + + + Sidebar.tsx (v1 — untouched, default) + project groups · dnd · show-more · collapse + ignores new settled fields entirely + + SidebarV2.tsx (new, beta) + flat virtualized list · card/slim rows · settle + no imports from Sidebar.tsx, ever + + + + Shared — both components call the same modules + Sidebar.logic.ts · ThreadStatusIndicators · useThreadActions · threadSelectionStore · uiStateStore · threadSort.ts · keybindings + anything both need moves here first — never cross-imported + + + + +

05Phases

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

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

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

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

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

06Open questions

+ + + + + + +
QuestionCurrent thinking
Server-side activity observationIf decider activity paths are too scattered for unsettle-on-activity, fall back to client-side: compute with activity timestamps, override only stores manual state (activity newer than settledAt wins). Decide in P1.
Shell projectionsettledOverride/settledAt must be in the thread shell stream — the sidebar never loads details. Verify first thing in P1.
Diff stats on cardsRequires checkpoint data in the shell; if absent, defer diff stats rather than loading details per row.
Project grouping in v2Not planned; v1 stays available for users who want groups. Revisit only if beta feedback demands it.
+ +
T3 Code · Sidebar v2 beta · plan doc pair: .plans/21-sidebar-v2-beta.md (implementation detail) + this page (design + rationale). Mock data mirrors the original concepts page (from live state.sqlite, Jul 13).
+
+ + + + diff --git a/.plans/21-sidebar-v2-beta.md b/.plans/21-sidebar-v2-beta.md new file mode 100644 index 00000000000..8cda132e80a --- /dev/null +++ b/.plans/21-sidebar-v2-beta.md @@ -0,0 +1,285 @@ +# Sidebar v2 (beta): flat adaptive-density list with settled threads + +Status: planned +Mocks: https://hsyscdqldmk5.postplan.dev/ (concept 4 base + concept 1 card layout + concept 3 needs-you pinning) + +## Summary + +A new thread sidebar behind a client-settings beta toggle. Core changes vs. the +current sidebar: + +- **Flat recency list.** Project group headers are gone; project identity moves + onto the row as the existing `ProjectFavicon` (environmentId + cwd). This + matches the session lists users know from Claude Code, Codex, and Cursor. +- **Adaptive density via a "settled" lifecycle state.** Active threads render + as two-line cards: favicon + title + time, then a structured meta line + (status word · branch · harness glyph + model · machine). Settled threads + collapse to slim one-liners, roughly today's row height. No generated or + free-text summaries anywhere — every field on a card is data the shell + already carries. +- **Settled is an explicit state**, not a derived one: users settle threads + manually, or threads auto-settle (PR merged/closed, inactivity). Any real + activity auto-unsettles. Settled is a lifecycle stage between active and + archived — it stays in the list, just quiet. +- **Four visual states, three colors.** Needs approval (amber, pinned above + the recency flow with "waiting Xm"), Working (sky, pulsing, elapsed timer), + **Ready** (uncolored and unlabeled — the normal resting state: the agent + stopped and is waiting on you, whether it finished, asked a question, or + proposed a plan; bold title until visited is the only signal), Failed (red, + `session.lastError` — a dead session shows nothing today). Today's Awaiting + Input / Plan Ready / Completed-unseen pills all collapse into Ready: they + differ in detail, not in what the user should do (look at the thread). + Color is reserved for "act now" (amber), "in motion" (sky), "broken" (red). +- **Harness / model / machine as quiet metadata.** A tinted glyph before the + model name distinguishes Claude Code / Codex / other; a machine label + renders only when the thread lives on a different computer than the one + you're looking at. Display-only lookups from shell data, no new plumbing. +- **Settled feeds cleanup.** Settling a thread offers/permits worktree cleanup; + long-settled threads are candidates for future auto-archive. + +The v2 component is a sibling of the current sidebar, swapped at a single mount +point. The current sidebar is untouched except for the swap seam. + +## Rationale + +Full design discussion lives in the session that produced the mocks; the short +version: + +- We aren't in a position to teach users a new interaction model. Every v2 + mechanic maps to an existing habit: flat recency list (Claude Code / Codex / + Cursor), settle (Gmail archive / GitHub notifications "Done"), unread-bold + (email). Zero new gestures. +- "Settled" makes the adaptive-density row-height rule *stable*: height changes + only at real lifecycle transitions, never from ambient re-rendering. This + kills concept 4's "jumpy list" risk. +- Viewing a thread does **not** settle it. Visiting clears unseen (existing + `lastVisitedAt` mechanics); settling asserts "this work is concluded." If + viewing settled, auto-unsettle would fight the user and the state would stop + meaning anything. +- Settling is optional by design. The auto rules are the hedge against + inbox-zero fatigue: a user who never touches the affordance still gets a + naturally tidy list. Beta telemetry question: what fraction of settles are + manual vs. auto? + +## The settled model + +``` +Active ──(user settles | PR merged/closed | inactivity ≥ threshold)──▶ Settled +Settled ──(new user message | session starts | approval requested | + PR reopened | user un-settles)──▶ Active +Settled ──(existing archive flow, or future auto-archive)──▶ Archived +``` + +Settled is a **computed property with one stored override**: + +``` +effectiveSettled(thread) = + override === "settled" → true (manual settle) + override === "active" → false (manual keep-active) + pr.state ∈ {merged, closed} → true (auto) + lastActivityAt < now − inactivityThreshold → true (auto) + otherwise → false +``` + +- The stored field is a tri-state: `settledOverride: "settled" | "active" | null` + plus `settledAt: IsoDateTime | null` (set when override becomes "settled", + used for display and future auto-archive aging). +- The auto cases need no background job and no events — they fall out of data + the sidebar already streams (`vcs.status` change request state, + `latestUserMessageAt` / turn timestamps). Auto-unsettle for the time-based + case is free: activity moves the timestamp, the predicate flips. +- **Activity clears the override.** Any thread activity event (new user + message, session start, approval request) resets `settledOverride` to null + server-side, so a manually settled thread that wakes up becomes active again + and later auto-rules apply fresh. Likewise, manually un-settling a merged-PR + thread sets `settledOverride: "active"`, which beats the PR auto-rule. +- Inactivity threshold: default 3 days, client setting, `null` = never + auto-settle by time. + +Server-side storage (next to `archivedAt` on the thread) rather than +client-local: settled must sync across desktop/remote environments, and the +worktree-cleanup hook needs the server to know. This mirrors the +`archivedAt` / `thread.archived` / `thread.unarchived` pattern exactly. + +Consequences of being settled: + +- Row collapses to the slim one-liner. +- Excluded from status rollups (any badge counts, aggregate status dots). +- Eligible for worktree cleanup prompting (see below). +- Future: settled ≥ 30 days → auto-archive candidate (not in this phase). + +## Swap strategy: component swap at the mount point + +Decision: **swap the whole sidebar component**, not internals. + +The current `Sidebar.tsx` (~3750 lines) takes no props — it reads everything +from hooks/atoms/stores — and `AppSidebarLayout.tsx:93` is its single mount +point. The truly shared behavior (thread-jump keybindings, selection store, +uiStateStore, `useThreadActions`, context-menu via `readLocalApi()`) already +lives *outside* the component in hooks and stores, so a sibling component +inherits all of it by calling the same hooks. What lives inside Sidebar.tsx +(project grouping, drag-and-drop, show-more, per-project collapse) is exactly +the stuff v2 deletes — threading a variant flag through it would mean touching +hundreds of conditional branches for no benefit. + +```tsx +// AppSidebarLayout.tsx — the entire seam +const sidebarV2 = useClientSettings((s) => s.sidebarV2Enabled); +... +{sidebarV2 ? : } +``` + +Rules for the seam: + +- `SidebarV2.tsx` imports freely from `Sidebar.logic.ts`, + `ThreadStatusIndicators.tsx`, `useThreadActions`, `threadSelectionStore`, + `uiStateStore` — shared logic stays shared. +- `SidebarV2.tsx` must not import from `Sidebar.tsx`, and vice versa. Anything + both need moves into `Sidebar.logic.ts` (or a new shared module) first. +- The settled *data model* (contracts, server, reducer) is flag-independent — + it ships dark and is simply unused by the v1 component. Only the *UI* is + gated. This keeps the toggle a pure view swap: flipping it never migrates + data, so switching back and forth is always safe. +- v1 remains the default. Deleting v1 is a separate future decision once beta + feedback lands. + +## Implementation phases + +### Phase 1 — Settled data model (ships dark, no UI) + +Mirror the archived pattern end-to-end: + +1. `packages/contracts/src/settings.ts`: add to `ClientSettingsSchema`: + - `sidebarV2Enabled: boolean` (default false) + - `sidebarAutoSettleAfterDays: number | null` (default 3) +2. `packages/contracts/src/orchestration.ts`: + - Thread shell: `settledOverride: "settled" | "active" | null`, + `settledAt: IsoDateTime | null` (both with decoding defaults of null — + old persisted threads decode unchanged). + - Events: `thread.settled` (threadId, settledAt, updatedAt), + `thread.unsettled` (threadId, updatedAt). Payload schemas alongside + `ThreadArchivedPayload` / `ThreadUnarchivedPayload`. + - Commands: `thread.settle`, `thread.unsettle`. +3. `apps/server/src/orchestration/decider.ts`: handle both commands, mirroring + archive/unarchive. Invariants in `commandInvariants.ts` + (`requireThreadNotArchived` applies; settle of an already-settled thread is + a no-op rather than an error — idempotent for bulk operations). + In the decider paths that record thread activity (user message appended, + session started, approval requested): if `settledOverride !== null`, also + emit `thread.unsettled` to clear it. +4. `packages/client-runtime/src/state/threadReducer.ts`: reduce both events + (`threadReducer.ts:87-101` is the archive template). Tests alongside the + archive reducer tests. +5. `packages/client-runtime` (new `threadSettled.ts` or in `threadSort.ts`): + pure `effectiveSettled(shell, { now, autoSettleAfterDays })` implementing + the predicate above, plus `lastActivityAt(shell)` (max of + latestUserMessageAt, latest turn completion, session start). Unit-test the + truth table: each override state × PR state × inactivity. + +Verification: `bun run typecheck`, reducer + predicate unit tests. No visible +change anywhere. + +### Phase 2 — SidebarV2 component + beta toggle + +1. Settings UI: "Beta features" section (new panel in + `apps/web/src/components/settings/SettingsPanels.tsx` + route, matching the + existing settings-page pattern) with the v2 toggle and, indented under it, + the auto-settle threshold control. +2. Swap seam in `AppSidebarLayout.tsx` as above. +3. `apps/web/src/components/SidebarV2.tsx`, reusing shared modules. Structure: + - **One flat virtualized list** of all non-archived threads across + projects. Sort key: (needs-approval first, by wait time) → then recency + (`latestUserMessageAt`, reusing `threadSort.ts`). No show-more, no + project collapse, no dnd. + - **Row variants** (single `SidebarV2Row` with a `variant` prop, both + variants rendered from the same shell data): + - `card` (~52px, threads where `effectiveSettled` is false): line 1 + `ProjectFavicon` + title (+ diff stats when present) + time ("waiting + Xm" in amber when approval-blocked, live elapsed for working); line 2 + structured meta — status word (only for the colored states) · branch · + harness glyph + model · machine (only when the thread's environment is + not the current machine). Left rail color only for approval (amber), + working (sky, pulsing), failed (red) — Ready rows carry no color or + label, just a bold title while unread. No free-text/generated content. + - `slim` (~28px, settled): `ProjectFavicon` · title · PR badge when + notable · time. Visually close to today's v1 row. + - **Status derivation** consolidates `Sidebar.logic.ts`'s pill logic into + four visual states: Needs Approval, Working (incl. connecting), Ready + (today's Awaiting Input + Plan Ready + Completed-unseen — same user + action, different payload), and new Failed (`session.lastError` on a + non-running session). Keep it in `Sidebar.logic.ts` so v1 can adopt + Failed later too. + - **Unread**: bold title until visited, from existing + `uiStateStore.threadLastVisitedAtById` — same mechanics as today's + Completed-unseen pill. + - **Row height changes only on lifecycle transitions** (settle/unsettle), + animated with the existing auto-animate pattern. Streaming/status + updates within a variant never change height. + - Preserved v1 behaviors, via the shared hooks: click-to-open, multi-select + (threadSelectionStore), context menu (add Settle/Un-settle entries), + inline rename, thread-jump shortcut labels, archive affordance, PR icon + link, port/terminal/remote indicators (moved into line 3 / slim row + trailing icons). +4. Settle affordances: + - Hover action on the row (next to today's archive hover button) + + context-menu entry + bulk via multi-select. + - `useThreadActions`: add `settleThread` / `unsettleThread` mirroring + `archiveThread` (`useThreadActions.ts:91-134`), minus the navigation + dance — settling never navigates away. + - No keyboard shortcut in the first cut; add via keybindings once the + affordance proves out. + +Verification: typecheck/lint; toggle on → v2 renders, toggle off → v1 +identical to before; settle/unsettle round-trips including across a +desktop+remote pair; kill a session mid-run → Failed card appears. + +### Phase 3 — Worktree cleanup hook + +Settling is the natural moment to reclaim disk: + +1. On **manual settle** of a thread whose worktree is orphaned + (`getOrphanedWorktreePathForThread`, `worktreeCleanup.ts:11-33`): show a + non-blocking inline prompt/toast — "Worktree kept · Remove?" — that calls + `vcsEnvironment.removeWorktree`. Never remove without an explicit click; + never block the settle on the answer. Skip the prompt entirely when the + worktree has uncommitted changes or unpushed commits (check via the + existing vcs status the sidebar already has). +2. **Auto-settle does not touch worktrees** in this phase. A settings-page + "Storage" affordance listing orphaned worktrees of settled threads (with + sizes, bulk-remove) is the follow-up; tracked but not in scope. + +### Phase 4 — Beta telemetry & exit criteria + +Instrument (whatever the existing analytics path is — if none, a lightweight +local counter surfaced in diagnostics is enough for the beta): + +- settle events by source: manual / auto-PR / auto-inactivity / bulk +- un-settle events: manual vs. activity-driven (activity-driven un-settles of + *manual* settles = the model fighting users) +- toggle-off rate after trying v2 + +Decision inputs for exiting beta: if manual settling is ~0%, shrink the +affordance and lean on auto rules; if activity-unsettle-of-manual-settle is +high, the inactivity threshold or unsettle triggers need retuning. + +## Explicitly out of scope + +- Inline approve/reject in the sidebar (concept 3) — needs a safety story. +- Message snippets in rows (concept 2) — streaming churn unsolved. +- Ops-grid density mode (concept 5). +- Auto-archive of long-settled threads. +- Removing v1 / making v2 the default. +- Project grouping inside v2 (v1 remains available for users who want groups). + +## Open questions + +- Where thread "activity" is centrally observable server-side for the + unsettle-on-activity rule — if the decider paths are too scattered, an + acceptable fallback is client-side: compute `effectiveSettled` with the + activity timestamps and only use the override for manual state (activity + newer than `settledAt` wins). Decide during Phase 1 once in the decider. +- Whether `settledAt` belongs in the thread *shell* stream (sidebar reads + shells only) — it must, or v2 can't sort/collapse without detail loads. + Verify shell projection includes it. +- Diff stats on cards require checkpoint data in the shell; if absent, defer + diff stats rather than loading details for every row. diff --git a/apps/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 ( +
  • + {props.showQuietDivider ? ( +
    + + Settled + + +
    + ) : null} +
    + {/* Settled history recedes: dimmed favicon at rest, restored on + hover so the tail stays scannable when you're hunting. */} + + + + {title} + {prBadge} + + + {props.jumpLabel ?? + formatRelativeTimeLabel(thread.latestUserMessageAt ?? thread.updatedAt)} + + {variantAction === "unsettle" ? ( + + ) : ( + + )} + +
    +
  • + ); + } + + const statusEdge = CARD_EDGE_BY_STATUS[status]; + const statusWord = STATUS_WORD_BY_STATUS[status]; + const diff = latestTurnDiff(thread); + const workingTimer = + status === "working" && thread.latestTurn?.startedAt + ? formatElapsedDurationLabel(thread.latestTurn.startedAt) + : null; + + return ( +
  • +
    + {statusEdge ? ( + + ) : null} + {/* Title bar: the card is a tiny window whose chrome carries project + identity — favicon + name up top, separated by a hairline, so + "which project" reads before the thread content does. */} +
    + + {props.projectTitle ? ( + + {props.projectTitle} + + ) : null} + + + {props.jumpLabel ?? workingTimer ?? threadTimeLabel(thread, status)} + + + +
    +
    +
    + {title} + {diff ? ( + + +{diff.insertions}{" "} + −{diff.deletions} + + ) : null} +
    +
    + {statusWord ? ( + + {statusWord.label} + + ) : null} + {status === "failed" && thread.session?.lastError ? ( + + {thread.session.lastError} + + ) : ( + <> + {thread.branch ? ( + + {thread.branch} + + ) : null} + {prBadge} + + )} + + {driverKind ? ( + + } + > + + + {thread.modelSelection.model} + + ) : null} + {isRemote ? ( + + + } + > + + + + Running on {props.environmentLabel ?? "a remote environment"} + + + ) : null} + +
    +
    +
    +
  • + ); +}); + +function latestTurnDiff( + thread: SidebarThreadSummary, +): { insertions: number; deletions: number } | null { + // Shells don't carry checkpoint summaries; diff stats render only when the + // shell projection grows them. Kept as a seam so the row layout is ready. + void thread; + return null; +} + +export default function SidebarV2() { + const projects = useProjects(); + const threads = useThreadShells(); + const router = useRouter(); + const { isMobile, setOpenMobile } = useSidebar(); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); + const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); + const { settleThread, unsettleThread, archiveThread, deleteThread } = useThreadActions(); + const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { + reportFailure: false, + }); + const newThreadContext = useHandleNewThread(); + const openAddProjectCommandPalette = useOpenAddProjectCommandPalette(); + const { environments } = useEnvironments(); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const clearSelection = useThreadSelectionStore((s) => s.clearSelection); + const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); + const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread); + const rangeSelectTo = useThreadSelectionStore((s) => s.rangeSelectTo); + const markThreadUnread = useUiStateStore((s) => s.markThreadUnread); + const routeThreadRef = useParams({ + strict: false, + select: (params) => resolveThreadRouteRef(params), + }); + const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; + + const environmentLabelById = useMemo( + () => + new Map( + environments.map((environment) => [environment.environmentId, environment.label] as const), + ), + [environments], + ); + const serverProviders = useAtomValue(primaryServerProvidersAtom); + const providerEntryByInstanceId = useMemo( + () => + new Map( + deriveProviderInstanceEntries(serverProviders).map( + (entry) => [entry.instanceId as string, entry] as const, + ), + ), + [serverProviders], + ); + const projectCwdByKey = useMemo( + () => + new Map( + projects.map((project) => [ + `${project.environmentId}:${project.id}`, + project.workspaceRoot, + ]), + ), + [projects], + ); + const projectTitleByKey = useMemo( + () => + new Map(projects.map((project) => [`${project.environmentId}:${project.id}`, project.title])), + [projects], + ); + + // now is quantized to the minute so effectiveSettled memoization doesn't + // churn on every render; auto-settle thresholds are day-granular anyway. + const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16)); + useEffect(() => { + const id = window.setInterval( + () => setNowMinute(new Date().toISOString().slice(0, 16)), + 60_000, + ); + return () => window.clearInterval(id); + }, []); + + // PR states stream in per-row (rows own the VCS subscriptions); a merged or + // closed PR auto-settles its thread on the next partition. + const [changeRequestStateByKey, setChangeRequestStateByKey] = useState< + ReadonlyMap + >(() => new Map()); + const handleChangeRequestState = useCallback( + (threadKey: string, state: "open" | "closed" | "merged" | null) => { + setChangeRequestStateByKey((current) => { + if ((current.get(threadKey) ?? null) === state) return current; + const next = new Map(current); + if (state === null) { + next.delete(threadKey); + } else { + next.set(threadKey, state); + } + return next; + }); + }, + [], + ); + + // Project scope: chips above the list. Scoping filters the list AND + // becomes the new-thread target — one visible control doing both jobs the + // old per-project headers did. + const [projectScopeKey, setProjectScopeKey] = useState(null); + const scopedProject = useMemo( + () => + projectScopeKey === null + ? null + : (projects.find( + (project) => `${project.environmentId}:${project.id}` === projectScopeKey, + ) ?? null), + [projectScopeKey, projects], + ); + useEffect(() => { + if ( + projectScopeKey !== null && + !projects.some((project) => `${project.environmentId}:${project.id}` === projectScopeKey) + ) { + setProjectScopeKey(null); + } + }, [projectScopeKey, projects]); + + // Archived threads ARE the settled tail in the client-only settled model, + // but the live shell stream drops a thread the moment it is archived + // (thread.archived → thread-removed). Merge them back in from the archived + // snapshot query; live shells win on the (brief) overlap. + const archivedEnvironmentIds = useMemo( + () => environments.map((environment) => environment.environmentId), + [environments], + ); + const { snapshots: archivedSnapshots } = useArchivedThreadSnapshots(archivedEnvironmentIds); + // Bridge the gap between the live stream dropping a just-settled thread + // (thread.archived → thread-removed) and the archived snapshot returning + // it: hold the shell we settled, marked archived, until either source + // carries the thread again. Held explicitly at settle time — not inferred + // from disappearance — so deleted threads are never resurrected. + const [settledHolds, setSettledHolds] = useState>( + () => new Map(), + ); + const allThreads = useMemo(() => { + const merged = new Map(); + for (const { environmentId, snapshot } of archivedSnapshots) { + for (const thread of snapshot.threads) { + merged.set(scopedThreadKey(scopeThreadRef(environmentId, thread.id)), { + ...thread, + environmentId, + }); + } + } + // Live shells win over snapshot copies. + for (const thread of threads) { + merged.set(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread); + } + for (const [threadKey, shell] of settledHolds) { + if (merged.has(threadKey)) continue; + merged.set(threadKey, shell); + } + return [...merged.values()]; + }, [archivedSnapshots, settledHolds, threads]); + // Drop a hold once the archived snapshot carries the thread — the steady + // state. (Not the live stream: the thread is still live when the hold is + // created, and holds are inert while a real source has the thread anyway.) + useEffect(() => { + if (settledHolds.size === 0) return; + const covered = new Set(); + for (const { environmentId, snapshot } of archivedSnapshots) { + for (const thread of snapshot.threads) { + covered.add(scopedThreadKey(scopeThreadRef(environmentId, thread.id))); + } + } + if ([...settledHolds.keys()].some((threadKey) => covered.has(threadKey))) { + setSettledHolds((current) => { + const next = new Map(current); + for (const threadKey of covered) next.delete(threadKey); + return next; + }); + } + }, [archivedSnapshots, settledHolds]); + + const { activeThreads, settledThreads } = useMemo(() => { + const now = `${nowMinute}:00.000Z`; + const visible = allThreads.filter( + (thread) => + scopedProject === null || + (thread.environmentId === scopedProject.environmentId && + thread.projectId === scopedProject.id), + ); + const active: EnvironmentThreadShell[] = []; + const settled: EnvironmentThreadShell[] = []; + for (const thread of visible) { + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + const changeRequestState = changeRequestStateByKey.get(threadKey) ?? null; + if (effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState })) { + settled.push(thread); + } else { + active.push(thread); + } + } + return { + activeThreads: sortThreadsForSidebarV2(active), + settledThreads: settled.toSorted( + (left, right) => + Date.parse(right.latestUserMessageAt ?? right.updatedAt) - + Date.parse(left.latestUserMessageAt ?? left.updatedAt), + ), + }; + }, [allThreads, autoSettleAfterDays, changeRequestStateByKey, nowMinute, scopedProject]); + + // The settled tail renders in pages: history shouldn't dominate the + // sidebar, and the common lookups are recent. Expansion resets when the + // filter context changes so a scope/search flip never inherits a deep + // page state. + const [settledVisibleCount, setSettledVisibleCount] = useState(SETTLED_TAIL_INITIAL_COUNT); + const settledResetKey = `${projectScopeKey ?? "all"}`; + const lastSettledResetKeyRef = useRef(settledResetKey); + if (lastSettledResetKeyRef.current !== settledResetKey) { + lastSettledResetKeyRef.current = settledResetKey; + setSettledVisibleCount(SETTLED_TAIL_INITIAL_COUNT); + } + const hiddenSettledCount = Math.max(0, settledThreads.length - settledVisibleCount); + const visibleSettledThreads = useMemo( + () => (hiddenSettledCount > 0 ? settledThreads.slice(0, settledVisibleCount) : settledThreads), + [hiddenSettledCount, settledThreads, settledVisibleCount], + ); + const showMoreSettled = useCallback( + () => setSettledVisibleCount((count) => count + SETTLED_TAIL_PAGE_COUNT), + [], + ); + + const orderedThreads = useMemo( + () => [...activeThreads, ...visibleSettledThreads], + [activeThreads, visibleSettledThreads], + ); + const orderedThreadKeys = useMemo( + () => + orderedThreads.map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ), + [orderedThreads], + ); + // Rows call back into the click handler without carrying the ordered list as + // a prop — a fresh array identity per shell update would defeat every row's + // memoization. The ref keeps shift-range-select working against the list as + // rendered at click time. + const orderedThreadKeysRef = useRef(orderedThreadKeys); + orderedThreadKeysRef.current = orderedThreadKeys; + const threadByKey = useMemo( + () => + new Map( + orderedThreads.map( + (thread) => + [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const, + ), + ), + [orderedThreads], + ); + // Handlers read these through refs: depending on per-update Map/Set + // identities would give every row a fresh callback prop on each shell + // event and defeat row memoization during streaming. + const threadByKeyRef = useRef(threadByKey); + threadByKeyRef.current = threadByKey; + const settledThreadKeys = useMemo( + () => + new Set( + settledThreads.map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ), + ), + [settledThreads], + ); + const settledThreadKeysRef = useRef(settledThreadKeys); + settledThreadKeysRef.current = settledThreadKeys; + + const jumpLabelByKey = useMemo(() => { + const mapping = new Map(); + for (const [index, threadKey] of orderedThreadKeys.entries()) { + const jumpCommand = threadJumpCommandForIndex(index); + if (!jumpCommand) break; + const label = shortcutLabelForCommand(keybindings, jumpCommand); + if (label) mapping.set(threadKey, label); + } + return mapping; + }, [keybindings, orderedThreadKeys]); + const [showJumpHints, setShowJumpHints] = useState(false); + + const navigateToThread = useCallback( + (threadRef: ScopedThreadRef) => { + if (useThreadSelectionStore.getState().selectedThreadKeys.size > 0) { + clearSelection(); + } + setSelectionAnchor(scopedThreadKey(threadRef)); + if (isMobile) { + setOpenMobile(false); + } + void router.navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(threadRef), + }); + }, + [clearSelection, isMobile, router, setOpenMobile, setSelectionAnchor], + ); + + const [renamingThreadKey, setRenamingThreadKey] = useState(null); + const [renamingTitle, setRenamingTitle] = useState(""); + const startThreadRename = useCallback((threadRef: ScopedThreadRef, title: string) => { + setRenamingThreadKey(scopedThreadKey(threadRef)); + setRenamingTitle(title); + }, []); + const cancelThreadRename = useCallback(() => setRenamingThreadKey(null), []); + const commitThreadRename = useCallback( + (threadRef: ScopedThreadRef, title: string, originalTitle: string) => { + void (async () => { + const trimmed = title.trim(); + setRenamingThreadKey(null); + if (trimmed.length === 0) { + toastManager.add({ type: "warning", title: "Thread title cannot be empty" }); + return; + } + if (trimmed === originalTitle) return; + const result = await updateThreadMetadata({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId, title: trimmed }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to rename thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + })(); + }, + [updateThreadMetadata], + ); + + const handleThreadClick = useCallback( + (event: ReactMouseEvent, threadRef: ScopedThreadRef) => { + const isMac = isMacPlatform(navigator.platform); + const isModClick = isMac ? event.metaKey : event.ctrlKey; + const threadKey = scopedThreadKey(threadRef); + if (isModClick) { + event.preventDefault(); + toggleThreadSelection(threadKey); + return; + } + if (event.shiftKey) { + event.preventDefault(); + rangeSelectTo(threadKey, orderedThreadKeysRef.current); + return; + } + if (isTrailingDoubleClick(event.detail)) { + return; + } + navigateToThread(threadRef); + }, + [navigateToThread, rangeSelectTo, toggleThreadSelection], + ); + + const attemptSettle = useCallback( + (threadRef: ScopedThreadRef) => { + void (async () => { + // Hold the shell before dispatching: the live stream drops the + // thread on archive, and the settled tail must not flicker while + // the archived snapshot refresh is in flight. + const threadKey = scopedThreadKey(threadRef); + const shell = threadByKeyRef.current.get(threadKey); + if (shell) { + setSettledHolds((current) => + new Map(current).set(threadKey, { + ...shell, + archivedAt: shell.archivedAt ?? new Date().toISOString(), + }), + ); + } + // Settling the thread you're looking at moves you forward: the next + // remaining card (never a settled row), or the new-thread screen + // when this was the last active one. Snapshot the target before the + // settle mutates the partition. Background settles never navigate. + let navigateAfterSettle: (() => void) | null = null; + if (routeThreadKey === threadKey) { + const orderedKeys = orderedThreadKeysRef.current; + const settledKeys = settledThreadKeysRef.current; + const currentIndex = orderedKeys.indexOf(threadKey); + const nextCardKey = + currentIndex === -1 + ? null + : ([ + ...orderedKeys.slice(currentIndex + 1), + ...orderedKeys.slice(0, currentIndex), + ].find((key) => !settledKeys.has(key)) ?? null); + const nextThread = nextCardKey ? threadByKeyRef.current.get(nextCardKey) : null; + navigateAfterSettle = nextThread + ? () => navigateToThread(scopeThreadRef(nextThread.environmentId, nextThread.id)) + : () => void router.navigate({ to: "/" }); + } + const result = await settleThread(threadRef); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + setSettledHolds((current) => { + const next = new Map(current); + next.delete(threadKey); + return next; + }); + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to settle thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + return; + } + // Settle = archive: the shell stream drops the thread, so pull the + // archived snapshot immediately to keep it visible as a settled row. + refreshArchivedThreadsForEnvironment(threadRef.environmentId); + navigateAfterSettle?.(); + })(); + }, + [navigateToThread, routeThreadKey, router, settleThread], + ); + const attemptUnsettle = useCallback( + (threadRef: ScopedThreadRef) => { + void (async () => { + const result = await unsettleThread(threadRef); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to un-settle thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + return; + } + // Un-settling invalidates any optimistic settled hold for the thread. + setSettledHolds((current) => { + const threadKey = scopedThreadKey(threadRef); + if (!current.has(threadKey)) return current; + const next = new Map(current); + next.delete(threadKey); + return next; + }); + refreshArchivedThreadsForEnvironment(threadRef.environmentId); + })(); + }, + [unsettleThread], + ); + + const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection); + const handleMultiSelectContextMenu = useCallback( + async (position: { x: number; y: number }) => { + const api = readLocalApi(); + if (!api) return; + const threadKeys = [...useThreadSelectionStore.getState().selectedThreadKeys]; + if (threadKeys.length === 0) return; + const count = threadKeys.length; + const clicked = await settlePromise(() => + api.contextMenu.show( + [ + { id: "settle", label: `Settle (${count})` }, + { id: "mark-unread", label: `Mark unread (${count})` }, + { id: "delete", label: `Delete (${count})`, destructive: true }, + ], + position, + ), + ); + if (clicked._tag === "Failure") return; + if (clicked.value === "settle") { + for (const threadKey of threadKeys) { + const thread = threadByKeyRef.current.get(threadKey); + if (!thread) continue; + attemptSettle(scopeThreadRef(thread.environmentId, thread.id)); + } + clearSelection(); + return; + } + if (clicked.value === "mark-unread") { + for (const threadKey of threadKeys) { + const thread = threadByKeyRef.current.get(threadKey); + markThreadUnread(threadKey, thread?.latestTurn?.completedAt); + } + clearSelection(); + return; + } + if (clicked.value !== "delete") return; + if (confirmThreadDelete) { + const confirmed = await settlePromise(() => + api.dialogs.confirm( + [ + `Delete ${count} thread${count === 1 ? "" : "s"}?`, + "This permanently clears conversation history for these threads.", + ].join("\n"), + ), + ); + if (confirmed._tag === "Failure" || !confirmed.value) return; + } + const deletedThreadKeys = new Set(threadKeys); + for (const threadKey of threadKeys) { + const thread = threadByKeyRef.current.get(threadKey); + if (!thread) continue; + const result = await deleteThread(scopeThreadRef(thread.environmentId, thread.id), { + deletedThreadKeys, + }); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to delete threads", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + return; + } + // Deleted threads must not survive as settled holds or stale + // archived-snapshot rows. + setSettledHolds((current) => { + if (!current.has(threadKey)) return current; + const next = new Map(current); + next.delete(threadKey); + return next; + }); + refreshArchivedThreadsForEnvironment(thread.environmentId); + } + removeFromSelection(threadKeys); + }, + [ + attemptSettle, + clearSelection, + confirmThreadDelete, + deleteThread, + markThreadUnread, + removeFromSelection, + ], + ); + + const handleThreadContextMenu = useCallback( + (threadRef: ScopedThreadRef, position: { x: number; y: number }) => { + void (async () => { + const api = readLocalApi(); + if (!api) return; + const threadKey = scopedThreadKey(threadRef); + const selectionState = useThreadSelectionStore.getState(); + if (selectionState.hasSelection() && selectionState.selectedThreadKeys.has(threadKey)) { + await handleMultiSelectContextMenu(position); + return; + } + const thread = threadByKeyRef.current.get(threadKey); + if (!thread) return; + // Un-settle appears only when there is an archive to undo; an + // auto-settled (unarchived) slim row gets Settle, which archives it. + const isSettled = settledThreadKeysRef.current.has(threadKey) && thread.archivedAt !== null; + const clicked = await settlePromise(() => + api.contextMenu.show( + [ + isSettled + ? { id: "unsettle", label: "Un-settle thread" } + : { id: "settle", label: "Settle thread" }, + { id: "rename", label: "Rename thread" }, + { id: "mark-unread", label: "Mark unread" }, + { id: "archive", label: "Archive" }, + { id: "delete", label: "Delete", destructive: true, icon: "trash" }, + ], + position, + ), + ); + if (clicked._tag === "Failure") return; + switch (clicked.value) { + case "settle": + attemptSettle(threadRef); + return; + case "unsettle": + attemptUnsettle(threadRef); + return; + case "rename": + startThreadRename(threadRef, thread.title); + return; + case "mark-unread": + markThreadUnread(threadKey, thread.latestTurn?.completedAt); + return; + case "archive": { + const result = await archiveThread(threadRef); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to archive thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + return; + } + case "delete": { + if (confirmThreadDelete) { + const confirmed = await settlePromise(() => + api.dialogs.confirm( + [ + `Delete thread "${thread.title}"?`, + "This permanently clears conversation history for this thread.", + ].join("\n"), + ), + ); + if (confirmed._tag === "Failure" || !confirmed.value) return; + } + const result = await deleteThread(threadRef); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to delete thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + return; + } + // A deleted thread must not survive as a settled hold or a stale + // archived-snapshot row. + setSettledHolds((current) => { + if (!current.has(threadKey)) return current; + const next = new Map(current); + next.delete(threadKey); + return next; + }); + refreshArchivedThreadsForEnvironment(threadRef.environmentId); + return; + } + default: + return; + } + })(); + }, + [ + archiveThread, + attemptSettle, + attemptUnsettle, + confirmThreadDelete, + deleteThread, + handleMultiSelectContextMenu, + markThreadUnread, + startThreadRename, + ], + ); + + // Thread jump (cmd+1..9) and prev/next traversal reuse the same commands as + // v1 — the keybinding layer is shared, only the ordered list differs. + const routeTerminalOpen = useTerminalUiStateStore((state) => + routeThreadRef + ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen + : false, + ); + useEffect(() => { + const onWindowKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented || event.repeat) return; + const command = resolveShortcutCommand(event, keybindings, { + platform: navigator.platform, + context: { + terminalFocus: isTerminalFocused(), + terminalOpen: routeTerminalOpen, + modelPickerOpen: isModelPickerOpen(), + }, + }); + const navigateToThreadKey = (targetThreadKey: string | null) => { + if (!targetThreadKey) return false; + const targetThread = threadByKey.get(targetThreadKey); + if (!targetThread) return false; + event.preventDefault(); + event.stopPropagation(); + navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); + return true; + }; + const traversalDirection = threadTraversalDirectionFromCommand(command); + if (traversalDirection !== null) { + navigateToThreadKey( + resolveAdjacentThreadId({ + threadIds: orderedThreadKeys, + currentThreadId: routeThreadKey, + direction: traversalDirection, + }), + ); + return; + } + const jumpIndex = threadJumpIndexFromCommand(command ?? ""); + if (jumpIndex === null) return; + navigateToThreadKey(orderedThreadKeys[jumpIndex] ?? null); + }; + window.addEventListener("keydown", onWindowKeyDown); + return () => window.removeEventListener("keydown", onWindowKeyDown); + }, [ + keybindings, + navigateToThread, + orderedThreadKeys, + routeTerminalOpen, + routeThreadKey, + threadByKey, + ]); + + useEffect(() => { + const sync = (event: KeyboardEvent) => setShowJumpHints(event.metaKey || event.ctrlKey); + const clear = () => setShowJumpHints(false); + window.addEventListener("keydown", sync); + window.addEventListener("keyup", sync); + window.addEventListener("blur", clear); + return () => { + window.removeEventListener("keydown", sync); + window.removeEventListener("keyup", sync); + window.removeEventListener("blur", clear); + }; + }, []); + + const attachListAutoAnimateRef = useCallback((node: HTMLUListElement | null) => { + if (!node) return; + autoAnimate(node, { duration: 150, easing: "ease-out" }); + }, []); + + // New thread defaults to the project you're in (active thread's project, + // falling back to the top project) — same resolution the command palette + // uses. The chevron menu is the explicit project picker the flat list no + // longer gets from per-project headers. + const [newThreadPickerOpen, setNewThreadPickerOpen] = useState(false); + // chat.new (mod+shift+o / mod+n) is handled by the _chat route layout; in + // v2 with multiple projects it opens this picker via the event bus. + useEffect(() => onOpenNewThreadPicker(() => setNewThreadPickerOpen(true)), []); + const handleNewThreadClick = useCallback(() => { + // One project: nothing to pick, create immediately. + if (projects.length <= 1) { + if (isMobile) setOpenMobile(false); + void startNewThreadFromContext({ + activeDraftThread: newThreadContext.activeDraftThread, + activeThread: newThreadContext.activeThread ?? undefined, + defaultProjectRef: newThreadContext.defaultProjectRef, + handleNewThread: newThreadContext.handleNewThread, + }); + return; + } + setNewThreadPickerOpen(true); + }, [isMobile, newThreadContext, projects.length, setOpenMobile]); + const createThreadInProject = useCallback( + (environmentId: (typeof projects)[number]["environmentId"], projectId: string) => { + setNewThreadPickerOpen(false); + if (isMobile) setOpenMobile(false); + const project = projects.find( + (candidate) => candidate.environmentId === environmentId && candidate.id === projectId, + ); + if (!project) return; + void startNewThreadInProjectFromContext( + { + activeDraftThread: newThreadContext.activeDraftThread, + activeThread: newThreadContext.activeThread ?? undefined, + defaultProjectRef: newThreadContext.defaultProjectRef, + handleNewThread: newThreadContext.handleNewThread, + }, + scopeProjectRef(project.environmentId, project.id), + ); + }, + [isMobile, newThreadContext, projects, setOpenMobile], + ); + const contextualProjectRef = resolveThreadActionProjectRef({ + activeDraftThread: newThreadContext.activeDraftThread, + activeThread: newThreadContext.activeThread ?? undefined, + defaultProjectRef: newThreadContext.defaultProjectRef, + handleNewThread: newThreadContext.handleNewThread, + }); + const newThreadTargetRef = scopedProject + ? scopeProjectRef(scopedProject.environmentId, scopedProject.id) + : contextualProjectRef; + const newThreadTargetProject = newThreadTargetRef + ? (projects.find( + (project) => + project.environmentId === newThreadTargetRef.environmentId && + project.id === newThreadTargetRef.projectId, + ) ?? null) + : null; + // Picker order: the contextual default first (preselected), everything else + // after — the common case is Enter/click on the top row. + const newThreadPickerProjects = useMemo(() => { + if (!newThreadTargetProject) return projects; + return [ + newThreadTargetProject, + ...projects.filter( + (project) => + project.environmentId !== newThreadTargetProject.environmentId || + project.id !== newThreadTargetProject.id, + ), + ]; + }, [newThreadTargetProject, projects]); + + const commandPaletteShortcutLabel = shortcutLabelForCommand(keybindings, "commandPalette.toggle"); + const newThreadShortcutLabel = shortcutLabelForCommand(keybindings, "chat.new"); + + return ( + <> + + + + + + + } + > + + Search + {commandPaletteShortcutLabel ? ( + + {commandPaletteShortcutLabel} + + ) : null} + + + + + + New thread + {newThreadShortcutLabel ? ( + + {newThreadShortcutLabel} + + ) : null} + + + + + {projects.length > 0 ? ( + +
    + {projects.length > 1 ? ( + + ) : null} + {projects.map((project) => { + const scopeKey = `${project.environmentId}:${project.id}`; + const isScoped = projectScopeKey === scopeKey; + return ( + + ); + })} + + + } + > + + + Add project + +
    +
    + ) : null} + +
      + {orderedThreads.map((thread, threadIndex) => { + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + const isSettledRow = settledThreadKeys.has(threadKey); + // Settled is the ONLY thing that collapses a row: every + // not-settled thread is a full card. Density comes from users + // (or the auto rules) actually settling work, not from the + // sidebar second-guessing what still matters. + const isCard = !isSettledRow; + const previousThread = threadIndex > 0 ? orderedThreads[threadIndex - 1] : null; + const previousWasCard = + previousThread != null && + !settledThreadKeys.has( + scopedThreadKey(scopeThreadRef(previousThread.environmentId, previousThread.id)), + ); + const showQuietDivider = !isCard && previousWasCard; + return ( + + ); + })} + {hiddenSettledCount > 0 ? ( +
    • + +
    • + ) : null} +
    + {orderedThreads.length === 0 ? ( +
    + {projects.length === 0 ? ( + <> + No projects yet + + + ) : scopedProject ? ( + `No threads in ${scopedProject.title} yet` + ) : ( + "No threads yet" + )} +
    + ) : null} +
    +
    + + + + + + New thread in… + +
    { + if ( + event.key !== "ArrowDown" && + event.key !== "ArrowUp" && + event.key !== "Home" && + event.key !== "End" + ) { + return; + } + const container = event.currentTarget; + const options = [...container.querySelectorAll("button")]; + if (options.length === 0) return; + const currentIndex = options.findIndex((option) => option === document.activeElement); + const nextIndex = + event.key === "Home" + ? 0 + : event.key === "End" + ? options.length - 1 + : event.key === "ArrowDown" + ? (currentIndex + 1) % options.length + : (currentIndex - 1 + options.length) % options.length; + event.preventDefault(); + options[nextIndex]?.focus(); + }} + > + {newThreadPickerProjects.map((project, index) => { + const isDefault = index === 0 && newThreadTargetProject !== null; + return ( + + ); + })} + +
    +
    +
    + + ); +} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 5f9aec837ca..b568dc6208f 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -2049,7 +2049,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) >
    {!isComposerCollapsedMobile && (activePendingApproval ? ( -
    +
    ) : pendingUserInputs.length > 0 ? ( -
    +
    ) : showPlanFollowUpPrompt && activeProposedPlan ? ( -
    +
    ) : isComposerCollapsedMobile && pendingUserInputs.length > 0 ? (
    | undefined; preferredScriptId: string | null; @@ -58,6 +60,7 @@ export const ChatHeader = memo(function ChatHeader({ draftId, activeThreadTitle, activeProjectName, + activeProjectCwd, openInCwd, activeProjectScripts, preferredScriptId, @@ -79,6 +82,27 @@ export const ChatHeader = memo(function ChatHeader({ return (
    + {/* The project always leads the header: knowing which project a + thread lives in is priority zero, and the thread title alone + doesn't answer it. Mirrors the sidebar cards' title bar — + favicon + mono uppercase name. */} + {activeProjectName ? ( + + + + + {activeProjectName} + + + + / + + + ) : null} void; +}) { + // Local draft so the field can be emptied mid-edit; the setting only moves + // on valid input and snaps back to the persisted value on blur. + const [draft, setDraft] = useState(String(value)); + useEffect(() => { + setDraft(String(value)); + }, [value]); + + return ( + { + setDraft(event.target.value); + const parsed = Number.parseInt(event.target.value, 10); + if ( + Number.isFinite(parsed) && + parsed >= AUTO_SETTLE_MIN_DAYS && + parsed <= AUTO_SETTLE_MAX_DAYS + ) { + onCommit(parsed); + } + }} + onBlur={() => setDraft(String(value))} + aria-label="Days of inactivity before auto-settle" + /> + ); +} + +export function BetaSettingsPanel() { + const sidebarV2Enabled = useClientSettings((settings) => settings.sidebarV2Enabled); + const sidebarAutoSettleAfterDays = useClientSettings( + (settings) => settings.sidebarAutoSettleAfterDays, + ); + const updateSettings = useUpdateClientSettings(); + + return ( + + + updateSettings({ sidebarV2Enabled: Boolean(checked) })} + aria-label="Enable the sidebar v2 beta" + /> + } + /> + {sidebarV2Enabled ? ( + <> + + updateSettings({ + sidebarAutoSettleAfterDays: checked ? AUTO_SETTLE_DEFAULT_DAYS : null, + }) + } + aria-label="Auto-settle inactive threads" + /> + } + /> + {sidebarAutoSettleAfterDays !== null ? ( + updateSettings({ sidebarAutoSettleAfterDays: days })} + /> + } + /> + ) : null} + + ) : null} + + + ); +} diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index 6774b6f333f..9d690f2a4e4 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -3,6 +3,7 @@ import { ArchiveIcon, ArrowLeftIcon, BotIcon, + FlaskConicalIcon, GitBranchIcon, KeyboardIcon, Link2Icon, @@ -28,6 +29,7 @@ export type SettingsSectionPath = | "/settings/providers" | "/settings/source-control" | "/settings/connections" + | "/settings/beta" | "/settings/archived"; export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ @@ -40,6 +42,7 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ { label: "Providers", to: "/settings/providers", icon: BotIcon }, { label: "Source Control", to: "/settings/source-control", icon: GitBranchIcon }, { label: "Connections", to: "/settings/connections", icon: Link2Icon }, + { label: "Beta", to: "/settings/beta", icon: FlaskConicalIcon }, { label: "Archive", to: "/settings/archived", icon: ArchiveIcon }, ]; diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx new file mode 100644 index 00000000000..87181ec42cc --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -0,0 +1,113 @@ +import { useAtomValue } from "@effect/atom-react"; +import { SettingsIcon } from "lucide-react"; +import { memo, useCallback } from "react"; +import { Link, useNavigate } from "@tanstack/react-router"; + +import { APP_STAGE_LABEL } from "../../branding"; +import { primaryServerConfigAtom } from "../../state/server"; +import { resolveSidebarStageBadgeLabel } from "../Sidebar.logic"; +import { + SidebarFooter, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarTrigger, + useSidebar, +} from "../ui/sidebar"; +import { SidebarProviderUpdatePill } from "./SidebarProviderUpdatePill"; +import { SidebarUpdatePill } from "./SidebarUpdatePill"; + +export const SidebarChromeHeader = memo(function SidebarChromeHeader({ + isElectron, +}: { + isElectron: boolean; +}) { + return isElectron ? ( + + + + + ) : ( + + + + + ); +}); + +function SidebarBrand() { + const stageLabel = useSidebarStageLabel(); + + return ( + + + + Code + + + {stageLabel} + + + ); +} + +function useSidebarStageLabel() { + const primaryServerVersion = + useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null; + + return resolveSidebarStageBadgeLabel({ + primaryServerVersion, + fallbackStageLabel: APP_STAGE_LABEL, + }); +} + +function T3Wordmark() { + return ( + + + + ); +} + +export const SidebarChromeFooter = memo(function SidebarChromeFooter() { + const navigate = useNavigate(); + const { isMobile, setOpenMobile } = useSidebar(); + const handleSettingsClick = useCallback(() => { + if (isMobile) { + setOpenMobile(false); + } + void navigate({ to: "/settings" }); + }, [isMobile, navigate, setOpenMobile]); + + return ( + + + + + + + + Settings + + + + + ); +}); diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 07655ad30d7..f4d4fa8d363 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -50,6 +50,14 @@ export function useThreadActions() { const deleteThreadMutation = useAtomCommand(threadEnvironment.delete, { reportFailure: false, }); + // Client-only settled model: settle/unsettle ride the pre-existing archive + // lifecycle so no server upgrade is required. See threadSettled.ts. + const settleThreadMutation = useAtomCommand(threadEnvironment.archive, { + reportFailure: false, + }); + const unsettleThreadMutation = useAtomCommand(threadEnvironment.unarchive, { + reportFailure: false, + }); const stopThreadSession = useAtomCommand(threadEnvironment.stopSession); const removeWorktree = useAtomCommand(vcsEnvironment.removeWorktree, { reportFailure: false, @@ -345,6 +353,54 @@ export function useThreadActions() { ], ); + const settleThread = useCallback( + async (target: ScopedThreadRef) => { + const resolved = resolveThreadTarget(target); + // Settle rides archive, so it inherits archive's guard: never + // interrupt a thread mid-turn. + if ( + resolved && + resolved.thread.session?.status === "running" && + resolved.thread.session.activeTurnId != null + ) { + return AsyncResult.failure( + Cause.fail( + new ThreadArchiveBlockedError({ + environmentId: resolved.threadRef.environmentId, + threadId: resolved.threadRef.threadId, + }), + ), + ); + } + // Settle is a high-frequency lifecycle action and stays silent — no + // toast. (The old "Thread settled" toast offering worktree removal was + // noise; disk cleanup belongs in an explicit surface, not a + // notification.) + return settleThreadMutation({ + environmentId: target.environmentId, + input: { threadId: target.threadId }, + }); + }, + [resolveThreadTarget, settleThreadMutation], + ); + + const unsettleThread = useCallback( + async (target: ScopedThreadRef) => { + // Auto-settled rows (inactivity / merged PR) are not archived; sending + // unarchive for them would be rejected by the server. There is nothing + // to undo client-side, so succeed as a no-op. + const resolved = resolveThreadTarget(target); + if (resolved && resolved.thread.archivedAt === null) { + return AsyncResult.success(undefined); + } + return unsettleThreadMutation({ + environmentId: target.environmentId, + input: { threadId: target.threadId }, + }); + }, + [resolveThreadTarget, unsettleThreadMutation], + ); + const confirmAndDeleteThread = useCallback( async (target: ScopedThreadRef) => { const localApi = readLocalApi(); @@ -379,7 +435,16 @@ export function useThreadActions() { unarchiveThread, deleteThread, confirmAndDeleteThread, + settleThread, + unsettleThread, }), - [archiveThread, confirmAndDeleteThread, deleteThread, unarchiveThread], + [ + archiveThread, + confirmAndDeleteThread, + deleteThread, + settleThread, + unarchiveThread, + unsettleThread, + ], ); } diff --git a/apps/web/src/lib/chatThreadActions.test.ts b/apps/web/src/lib/chatThreadActions.test.ts index 2b1d7b09b9f..2189f1e3ca2 100644 --- a/apps/web/src/lib/chatThreadActions.test.ts +++ b/apps/web/src/lib/chatThreadActions.test.ts @@ -6,6 +6,7 @@ import { resolveNewDraftStartFromOrigin, startNewLocalThreadFromContext, startNewThreadFromContext, + startNewThreadInProjectFromContext, type ChatThreadActionContext, } from "./chatThreadActions"; @@ -117,6 +118,26 @@ describe("chatThreadActions", () => { }); }); + it("does not carry branch or worktree context into a different project", async () => { + const handleNewThread = vi.fn(async () => {}); + const targetProjectRef = scopeProjectRef(ENVIRONMENT_ID, FALLBACK_PROJECT_ID); + + await startNewThreadInProjectFromContext( + createContext({ + activeThread: { + environmentId: ENVIRONMENT_ID, + projectId: PROJECT_ID, + branch: "feature/refactor", + worktreePath: "/tmp/worktree", + }, + handleNewThread, + }), + targetProjectRef, + ); + + expect(handleNewThread).toHaveBeenCalledWith(targetProjectRef); + }); + it("delegates the target environment defaults to the new-thread handler", async () => { const handleNewThread = vi.fn(async () => {}); diff --git a/apps/web/src/lib/chatThreadActions.ts b/apps/web/src/lib/chatThreadActions.ts index 4f30885610a..d8b831ac3ad 100644 --- a/apps/web/src/lib/chatThreadActions.ts +++ b/apps/web/src/lib/chatThreadActions.ts @@ -75,6 +75,14 @@ export async function startNewThreadInProjectFromContext( context: ChatThreadActionContext, projectRef: ScopedProjectRef, ): Promise { + const contextualProjectRef = resolveThreadActionProjectRef(context); + const matchesContext = + contextualProjectRef?.environmentId === projectRef.environmentId && + contextualProjectRef.projectId === projectRef.projectId; + if (!matchesContext) { + await context.handleNewThread(projectRef); + return; + } await context.handleNewThread(projectRef, buildContextualThreadOptions(context)); } diff --git a/apps/web/src/newThreadPickerBus.ts b/apps/web/src/newThreadPickerBus.ts new file mode 100644 index 00000000000..a1cc7560e20 --- /dev/null +++ b/apps/web/src/newThreadPickerBus.ts @@ -0,0 +1,14 @@ +// Tiny event bus connecting the global chat.new shortcut (handled in the +// _chat route layout) to SidebarV2's project picker dialog. The route layer +// can't render the picker itself — it lives with the sidebar — and the +// sidebar can't own the window keydown handler without racing the layout's. +const NEW_THREAD_PICKER_EVENT = "t3code:open-new-thread-picker"; + +export function openNewThreadPicker(): void { + window.dispatchEvent(new CustomEvent(NEW_THREAD_PICKER_EVENT)); +} + +export function onOpenNewThreadPicker(listener: () => void): () => void { + window.addEventListener(NEW_THREAD_PICKER_EVENT, listener); + return () => window.removeEventListener(NEW_THREAD_PICKER_EVENT, listener); +} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 3a9140e278c..c48b10d2c12 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -19,6 +19,7 @@ import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybi import { Route as SettingsGeneralRouteImport } from './routes/settings.general' import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagnostics' import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections' +import { Route as SettingsBetaRouteImport } from './routes/settings.beta' import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId' @@ -72,6 +73,11 @@ const SettingsConnectionsRoute = SettingsConnectionsRouteImport.update({ path: '/connections', getParentRoute: () => SettingsRoute, } as any) +const SettingsBetaRoute = SettingsBetaRouteImport.update({ + id: '/beta', + path: '/beta', + getParentRoute: () => SettingsRoute, +} as any) const SettingsArchivedRoute = SettingsArchivedRouteImport.update({ id: '/archived', path: '/archived', @@ -94,6 +100,7 @@ export interface FileRoutesByFullPath { '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren '/settings/archived': typeof SettingsArchivedRoute + '/settings/beta': typeof SettingsBetaRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute '/settings/general': typeof SettingsGeneralRoute @@ -107,6 +114,7 @@ export interface FileRoutesByTo { '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren '/settings/archived': typeof SettingsArchivedRoute + '/settings/beta': typeof SettingsBetaRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute '/settings/general': typeof SettingsGeneralRoute @@ -123,6 +131,7 @@ export interface FileRoutesById { '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren '/settings/archived': typeof SettingsArchivedRoute + '/settings/beta': typeof SettingsBetaRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute '/settings/general': typeof SettingsGeneralRoute @@ -140,6 +149,7 @@ export interface FileRouteTypes { | '/pair' | '/settings' | '/settings/archived' + | '/settings/beta' | '/settings/connections' | '/settings/diagnostics' | '/settings/general' @@ -153,6 +163,7 @@ export interface FileRouteTypes { | '/pair' | '/settings' | '/settings/archived' + | '/settings/beta' | '/settings/connections' | '/settings/diagnostics' | '/settings/general' @@ -168,6 +179,7 @@ export interface FileRouteTypes { | '/pair' | '/settings' | '/settings/archived' + | '/settings/beta' | '/settings/connections' | '/settings/diagnostics' | '/settings/general' @@ -257,6 +269,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsConnectionsRouteImport parentRoute: typeof SettingsRoute } + '/settings/beta': { + id: '/settings/beta' + path: '/beta' + fullPath: '/settings/beta' + preLoaderRoute: typeof SettingsBetaRouteImport + parentRoute: typeof SettingsRoute + } '/settings/archived': { id: '/settings/archived' path: '/archived' @@ -297,6 +316,7 @@ const ChatRouteWithChildren = ChatRoute._addFileChildren(ChatRouteChildren) interface SettingsRouteChildren { SettingsArchivedRoute: typeof SettingsArchivedRoute + SettingsBetaRoute: typeof SettingsBetaRoute SettingsConnectionsRoute: typeof SettingsConnectionsRoute SettingsDiagnosticsRoute: typeof SettingsDiagnosticsRoute SettingsGeneralRoute: typeof SettingsGeneralRoute @@ -307,6 +327,7 @@ interface SettingsRouteChildren { const SettingsRouteChildren: SettingsRouteChildren = { SettingsArchivedRoute: SettingsArchivedRoute, + SettingsBetaRoute: SettingsBetaRoute, SettingsConnectionsRoute: SettingsConnectionsRoute, SettingsDiagnosticsRoute: SettingsDiagnosticsRoute, SettingsGeneralRoute: SettingsGeneralRoute, diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 9fb1eae721e..b590c59ed9d 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -3,6 +3,9 @@ import { useAtomValue } from "@effect/atom-react"; import { useEffect } from "react"; import { isCommandPaletteOpen } from "../commandPaletteContext"; +import { useClientSettings } from "../hooks/useSettings"; +import { openNewThreadPicker } from "../newThreadPickerBus"; +import { useProjects } from "../state/entities"; import { dispatchPreviewAction } from "../components/preview/previewActionBus"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { @@ -25,6 +28,8 @@ function ChatRouteGlobalShortcuts() { const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread, routeThreadRef } = useHandleNewThread(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const sidebarV2Enabled = useClientSettings((settings) => settings.sidebarV2Enabled); + const projectCount = useProjects().length; const terminalOpen = useTerminalUiStateStore((state) => routeThreadRef ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen @@ -75,6 +80,13 @@ function ChatRouteGlobalShortcuts() { if (command === "chat.new") { event.preventDefault(); event.stopPropagation(); + // Sidebar v2 routes creation through its project picker whenever + // there is a real choice to make; v1 (and single-project setups) + // keep the immediate contextual create. + if (sidebarV2Enabled && projectCount > 1) { + openNewThreadPicker(); + return; + } void startNewThreadFromContext({ activeDraftThread, activeThread: activeThread ?? undefined, @@ -140,8 +152,10 @@ function ChatRouteGlobalShortcuts() { keybindings, defaultProjectRef, previewOpen, + projectCount, routeThreadRef, selectedThreadKeysSize, + sidebarV2Enabled, terminalOpen, ]); diff --git a/apps/web/src/routes/settings.beta.tsx b/apps/web/src/routes/settings.beta.tsx new file mode 100644 index 00000000000..a1e78f2dff7 --- /dev/null +++ b/apps/web/src/routes/settings.beta.tsx @@ -0,0 +1,11 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { BetaSettingsPanel } from "../components/settings/BetaSettingsPanel"; + +function SettingsBetaRoute() { + return ; +} + +export const Route = createFileRoute("/settings/beta")({ + component: SettingsBetaRoute, +}); diff --git a/apps/web/src/worktreeCleanup.test.ts b/apps/web/src/worktreeCleanup.test.ts index 13ff2f0f73e..85dcb2af632 100644 --- a/apps/web/src/worktreeCleanup.test.ts +++ b/apps/web/src/worktreeCleanup.test.ts @@ -2,7 +2,11 @@ import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools import { describe, expect, it } from "vite-plus/test"; import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types"; -import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "./worktreeCleanup"; +import { + canOfferWorktreeRemoval, + formatWorktreePathForDisplay, + getOrphanedWorktreePathForThread, +} from "./worktreeCleanup"; const localEnvironmentId = EnvironmentId.make("environment-local"); @@ -108,3 +112,11 @@ describe("formatWorktreePathForDisplay", () => { expect(result).toBe("my-worktree"); }); }); + +describe("canOfferWorktreeRemoval", () => { + it("requires a clean worktree with no unpushed commits", () => { + expect(canOfferWorktreeRemoval({ hasWorkingTreeChanges: false, aheadCount: 0 })).toBe(true); + expect(canOfferWorktreeRemoval({ hasWorkingTreeChanges: true, aheadCount: 0 })).toBe(false); + expect(canOfferWorktreeRemoval({ hasWorkingTreeChanges: false, aheadCount: 1 })).toBe(false); + }); +}); diff --git a/apps/web/src/worktreeCleanup.ts b/apps/web/src/worktreeCleanup.ts index 109f71ccd9a..25fbc278a69 100644 --- a/apps/web/src/worktreeCleanup.ts +++ b/apps/web/src/worktreeCleanup.ts @@ -1,3 +1,4 @@ +import type { VcsStatusResult } from "@t3tools/contracts"; import type { ThreadShell } from "./types"; function normalizeWorktreePath(path: string | null): string | null { @@ -43,3 +44,9 @@ export function formatWorktreePathForDisplay(worktreePath: string): string { const lastPart = parts[parts.length - 1]?.trim() ?? ""; return lastPart.length > 0 ? lastPart : trimmed; } + +export function canOfferWorktreeRemoval( + status: Pick, +): boolean { + return !status.hasWorkingTreeChanges && status.aheadCount === 0; +} diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index d9e19889721..4fa05f850e5 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -127,6 +127,10 @@ "types": "./src/state/threadSort.ts", "default": "./src/state/threadSort.ts" }, + "./state/thread-settled": { + "types": "./src/state/threadSettled.ts", + "default": "./src/state/threadSettled.ts" + }, "./state/vcs": { "types": "./src/state/vcs.ts", "default": "./src/state/vcs.ts" diff --git a/packages/client-runtime/src/state/threadSettled.test.ts b/packages/client-runtime/src/state/threadSettled.test.ts new file mode 100644 index 00000000000..f6609c3bd50 --- /dev/null +++ b/packages/client-runtime/src/state/threadSettled.test.ts @@ -0,0 +1,183 @@ +import { + ProjectId, + ProviderInstanceId, + ThreadId, + TurnId, + type OrchestrationThreadShell, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + effectiveSettled, + threadLastActivityAt, + type ChangeRequestStateLike, +} from "./threadSettled.ts"; + +const NOW = "2026-04-10T00:00:00.000Z"; +const FRESH = "2026-04-09T00:00:00.000Z"; +const STALE = "2026-04-06T23:59:59.999Z"; + +function makeShell(input: { + readonly archivedAt: string | null; + readonly activityAt: string | null; + readonly sessionStatus?: "starting" | "running"; + readonly pending?: "approval" | "user-input"; +}): OrchestrationThreadShell { + const threadId = ThreadId.make("thread-1"); + return { + id: threadId, + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: + input.activityAt === null + ? null + : { + turnId: TurnId.make("turn-1"), + state: "completed", + requestedAt: input.activityAt, + startedAt: null, + completedAt: null, + assistantMessageId: null, + }, + createdAt: "2026-04-01T00:00:00.000Z", + updatedAt: NOW, + archivedAt: input.archivedAt, + session: + input.sessionStatus === undefined + ? null + : { + threadId, + status: input.sessionStatus, + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: NOW, + }, + latestUserMessageAt: null, + hasPendingApprovals: input.pending === "approval", + hasPendingUserInput: input.pending === "user-input", + hasActionableProposedPlan: false, + }; +} + +describe("threadLastActivityAt", () => { + it("returns the latest real user or turn activity and ignores thread/session updates", () => { + const shell = makeShell({ archivedAt: null, activityAt: null, sessionStatus: "running" }); + const withActivity: OrchestrationThreadShell = { + ...shell, + latestUserMessageAt: "2026-04-04T00:00:00.000Z", + latestTurn: { + turnId: TurnId.make("turn-1"), + state: "completed", + requestedAt: "2026-04-03T00:00:00.000Z", + startedAt: "2026-04-05T00:00:00.000Z", + completedAt: "2026-04-06T00:00:00.000Z", + assistantMessageId: null, + }, + }; + + expect(threadLastActivityAt(withActivity)).toBe("2026-04-06T00:00:00.000Z"); + expect(threadLastActivityAt(shell)).toBeNull(); + }); +}); + +describe("effectiveSettled", () => { + const archivedCases = [null, NOW] as const; + const changeRequestStates = [undefined, "open", "merged"] as const; + const inactivityCases = [ + ["fresh", FRESH], + ["stale", STALE], + ["no-activity", null], + ] as const; + const runningCases = [false, true] as const; + const pendingCases = [undefined, "approval", "user-input"] as const; + const truthTable = archivedCases.flatMap((archivedAt) => + changeRequestStates.flatMap((changeRequestState) => + inactivityCases.flatMap(([inactivity, activityAt]) => + runningCases.flatMap((running) => + pendingCases.map((pending) => ({ + archivedAt, + changeRequestState, + inactivity, + activityAt, + running, + pending, + // Settled iff nothing blocks (pending work / live session) AND + // any positive signal holds: archived, merged PR, or staleness. + expected: + pending === undefined && + !running && + (archivedAt !== null || changeRequestState === "merged" || inactivity === "stale"), + })), + ), + ), + ), + ); + + it.each(truthTable)( + "archived=$archivedAt pr=$changeRequestState inactivity=$inactivity running=$running pending=$pending", + ({ archivedAt, changeRequestState, activityAt, running, pending, expected }) => { + const shell = makeShell({ + archivedAt, + activityAt, + ...(running ? { sessionStatus: "running" as const } : {}), + ...(pending === undefined ? {} : { pending }), + }); + const changeRequestOptions = + changeRequestState === undefined + ? {} + : { changeRequestState: changeRequestState as ChangeRequestStateLike }; + + expect( + effectiveSettled(shell, { + now: NOW, + autoSettleAfterDays: 3, + ...changeRequestOptions, + }), + ).toBe(expected); + }, + ); + + it("treats closed change requests like merged ones", () => { + const shell = makeShell({ archivedAt: null, activityAt: null }); + expect( + effectiveSettled(shell, { + now: NOW, + autoSettleAfterDays: null, + changeRequestState: "closed", + }), + ).toBe(true); + }); + + it("never settles a starting session, even when archived", () => { + const shell = makeShell({ + archivedAt: NOW, + activityAt: STALE, + sessionStatus: "starting", + }); + expect( + effectiveSettled(shell, { + now: NOW, + autoSettleAfterDays: 3, + changeRequestState: "merged", + }), + ).toBe(false); + }); + + it("uses a strict inactivity boundary and honors a null threshold", () => { + const boundary = makeShell({ + archivedAt: null, + activityAt: "2026-04-07T00:00:00.000Z", + }); + const stale = makeShell({ archivedAt: null, activityAt: STALE }); + + expect(effectiveSettled(boundary, { now: NOW, autoSettleAfterDays: 3 })).toBe(false); + expect(effectiveSettled(stale, { now: NOW, autoSettleAfterDays: null })).toBe(false); + }); +}); diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts new file mode 100644 index 00000000000..8ffaabe8d78 --- /dev/null +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -0,0 +1,63 @@ +import type { OrchestrationThreadShell } from "@t3tools/contracts"; + +export type ChangeRequestStateLike = "open" | "closed" | "merged"; + +const DAY_MS = 24 * 60 * 60 * 1_000; + +export function threadLastActivityAt(shell: OrchestrationThreadShell): string | null { + const candidates = [ + shell.latestUserMessageAt, + shell.latestTurn?.requestedAt, + shell.latestTurn?.startedAt, + shell.latestTurn?.completedAt, + ]; + let latest: string | null = null; + let latestTimestamp = Number.NEGATIVE_INFINITY; + + for (const candidate of candidates) { + if (candidate === null || candidate === undefined) continue; + const timestamp = Date.parse(candidate); + if (timestamp > latestTimestamp) { + latest = candidate; + latestTimestamp = timestamp; + } + } + + return latest; +} + +/** + * Client-only settled resolution, backed by the pre-existing archive + * lifecycle instead of dedicated settle commands — no server, protocol, or + * database changes required. "Settled" here means: the user archived the + * thread, its PR merged/closed, or it has been inactive past the auto-settle + * window. + * + * Trade-offs vs the event-sourced settled model (kept on the main feature + * branch): activity does not auto-un-settle an archived thread, and there is + * no distinct "keep active" override — un-settling is just unarchiving. + */ +export function effectiveSettled( + shell: OrchestrationThreadShell, + options: { + readonly now: string; + readonly autoSettleAfterDays: number | null; + readonly changeRequestState?: ChangeRequestStateLike | null; + }, +): boolean { + // Blocked work must remain visible even when a user explicitly settled it. + if (shell.hasPendingApprovals || shell.hasPendingUserInput) return false; + if (shell.session?.status === "starting" || shell.session?.status === "running") return false; + if (shell.archivedAt !== null) return true; + if (options.changeRequestState === "merged" || options.changeRequestState === "closed") { + return true; + } + if (options.autoSettleAfterDays === null) return false; + + const lastActivityAt = threadLastActivityAt(shell); + if (lastActivityAt === null) return false; + + return ( + Date.parse(lastActivityAt) < Date.parse(options.now) - options.autoSettleAfterDays * DAY_MS + ); +} diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index ac2d47ca336..408d8f0fed4 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -4,12 +4,14 @@ import * as Schema from "effect/Schema"; import { ProviderInstanceId } from "./providerInstance.ts"; import { ClientSettingsSchema, + ClientSettingsPatch, DEFAULT_SERVER_SETTINGS, ServerSettings, ServerSettingsPatch, } from "./settings.ts"; const decodeClientSettings = Schema.decodeUnknownSync(ClientSettingsSchema); +const decodeClientSettingsPatch = Schema.decodeUnknownSync(ClientSettingsPatch); const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings); const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch); const encodeServerSettings = Schema.encodeSync(ServerSettings); @@ -31,6 +33,25 @@ describe("ClientSettings word wrap", () => { }); }); +describe("ClientSettings sidebar v2", () => { + it("defaults the beta off with a three-day auto-settle threshold", () => { + const settings = decodeClientSettings({}); + expect(settings.sidebarV2Enabled).toBe(false); + expect(settings.sidebarAutoSettleAfterDays).toBe(3); + }); + + it("allows auto-settle by inactivity to be disabled", () => { + expect( + decodeClientSettings({ sidebarAutoSettleAfterDays: null }).sidebarAutoSettleAfterDays, + ).toBeNull(); + }); + + it.each([-1, 0, 91])("rejects an auto-settle threshold outside 1..90: %s", (value) => { + expect(() => decodeClientSettings({ sidebarAutoSettleAfterDays: value })).toThrow(); + expect(() => decodeClientSettingsPatch({ sidebarAutoSettleAfterDays: value })).toThrow(); + }); +}); + describe("ServerSettings.providerInstances (slice-2 invariant)", () => { it("defaults to an empty record so legacy configs without the key still decode", () => { expect(DEFAULT_SERVER_SETTINGS.providerInstances).toEqual({}); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 6ccd65533dd..94f4cb405de 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -38,6 +38,16 @@ export const SidebarThreadPreviewCount = Schema.Int.check( ); export type SidebarThreadPreviewCount = typeof SidebarThreadPreviewCount.Type; export const DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT: SidebarThreadPreviewCount = 6; +export const MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS = 1; +export const MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS = 90; +export const SidebarAutoSettleAfterDays = Schema.Number.check( + Schema.isBetween({ + minimum: MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, + maximum: MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, + }), +); +export type SidebarAutoSettleAfterDays = typeof SidebarAutoSettleAfterDays.Type; +export const DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS: SidebarAutoSettleAfterDays = 3; export const ClientSettingsSchema = Schema.Struct({ autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), @@ -72,6 +82,9 @@ export const ClientSettingsSchema = Schema.Struct({ modelOrder: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))), }), ).pipe(Schema.withDecodingDefault(Effect.succeed({}))), + sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)), + ), sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)), ), @@ -88,6 +101,7 @@ export const ClientSettingsSchema = Schema.Struct({ sidebarThreadPreviewCount: SidebarThreadPreviewCount.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT)), ), + sidebarV2Enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), timestampFormat: TimestampFormat.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)), ), @@ -559,6 +573,7 @@ export const ClientSettingsPatch = Schema.Struct({ }), ), ), + sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode), sidebarProjectGroupingOverrides: Schema.optionalKey( Schema.Record(TrimmedNonEmptyString, SidebarProjectGroupingMode), @@ -566,6 +581,7 @@ export const ClientSettingsPatch = Schema.Struct({ sidebarProjectSortOrder: Schema.optionalKey(SidebarProjectSortOrder), sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder), sidebarThreadPreviewCount: Schema.optionalKey(SidebarThreadPreviewCount), + sidebarV2Enabled: Schema.optionalKey(Schema.Boolean), timestampFormat: Schema.optionalKey(TimestampFormat), wordWrap: Schema.optionalKey(Schema.Boolean), });