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/index.html b/apps/web/index.html index dadef17d3bc..445cde6283b 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -7,14 +7,14 @@ content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" /> - - + +