diff --git a/apps/daemon/src/host/toolchains/state.ts b/apps/daemon/src/host/toolchains/state.ts index 461617c3b..1e51fc165 100644 --- a/apps/daemon/src/host/toolchains/state.ts +++ b/apps/daemon/src/host/toolchains/state.ts @@ -48,11 +48,13 @@ export async function loadState(dataDir: string): Promise { } return { version: STATE_VERSION, sources: clean }; } catch { - // Quarantine under a timestamped name so repeated corruption cannot make - // every subsequent load throw (EEXIST on a fixed quarantine name). + // Quarantine under a unique name so repeated corruption cannot make + // every subsequent load throw (EEXIST on a fixed quarantine name) and + // cannot collide within one millisecond (Date.now() alone proved + // ambiguous on fast runners — the second rename replaced the first). try { await Bun.write( - join(toolchainsDir(dataDir), `state.corrupt-${Date.now()}.json`), + join(toolchainsDir(dataDir), `state.corrupt-${Date.now()}-${crypto.randomUUID()}.json`), await Bun.file(filePath).arrayBuffer(), ); // Persist a VALID empty state — an empty string would re-corrupt on diff --git a/apps/daemon/test/toolchainsService.test.ts b/apps/daemon/test/toolchainsService.test.ts index f43abba33..8aaad3113 100644 --- a/apps/daemon/test/toolchainsService.test.ts +++ b/apps/daemon/test/toolchainsService.test.ts @@ -224,7 +224,8 @@ describe("managed install pipeline", () => { const dataDir = tempRoot(); const { bytes } = fakeArchive("payload"); const versionDir = path.join(dataDir, "toolchains", "tools", "node", pinFor("node")); - fs.mkdirSync(versionDir, { recursive: true }); + // nodeBin() nests under bin/ on non-Windows; create the deepest dir. + fs.mkdirSync(path.join(versionDir, path.dirname(nodeBin())), { recursive: true }); fs.writeFileSync(path.join(versionDir, nodeBin()), "previous"); await expect( diff --git a/docs/features/thread-sidebar-polish/plan.md b/docs/features/thread-sidebar-polish/plan.md new file mode 100644 index 000000000..f25060b4d --- /dev/null +++ b/docs/features/thread-sidebar-polish/plan.md @@ -0,0 +1,44 @@ +# Plan — Thread Sidebar Polish + +## Approach + +Layered on top of the fixes goal: store fields first (snoozed shelf, tick removal), then the pure logic additions +(anchoring, collector), then the components (row overlay/badges, section shell, rail), then tests. + +## Affected files + +| File | Change | +|---|---| +| `threadSidebarLogic.ts` | `activeSessionId` in `PartitionHelpers` (snooze anchoring); `collectThreadSidebarShortcutSessions`. | +| `stores/ui/threadSidebar.ts` | `snoozedShelfExpanded` + setter + storage key; remove `tick`/`bumpThreadSidebarTick`; `storage` event listener re-reads lifecycle snapshot. | +| `threads/ThreadSection.tsx` | New: section shell (label / collapsible toggle / count / children / Show more). | +| `threads/ThreadSidebarRow.tsx` | `memo`; overlay hover action + right-slot crossfade; state-driven settled actions; shortcut badge; keyboard-reachable action. | +| `threads/ThreadSidebarList.tsx` | Per-field selectors, `useCallback` props, `renderRow`, `ThreadSection` usage, search clear/empty/bypass, coarse `now`, badge pass-through, rail-era cleanup. | +| `components/WindowSideBar.tsx` | `SidebarCollapsedRail` (new internal component, both modes); experiment-mode shortcut sessions via partition + collector; badge fns passed to the list; doc comments. | +| `settings/components/DisplaySettings.tsx` | Copy mentions Snoozed. | +| `threads/threadSidebarLogic.test.ts` | Extended: anchoring, collector, shelf persistence is store-level (manual), formatting edges. | + +## Render model + +- One interval lives in `ThreadSidebarList`, updating local `now` only while working/snoozed-future rows exist. +- Rows with live durations (working pill, wake countdown) get exact `now`; others get `Math.floor(now / 15000) * 15000`. +- All Row callbacks are stable (`useCallback`), so `memo` short-circuits quiet rows between buckets. + +## Shortcut collection + +`ThreadSidebarList` publishes its rendered row order via `onVisibleRowsChange`; +`WindowSideBar` derives experiment-mode shortcut targets and badge numbering +from that exact list (slice 10), so badges can never disagree with the visible +rows — including while searching or right after a snooze expires. The original +mode keeps `collectVisibleShortcutSessions`. + +## Compatibility + +- Store type changes are renderer-internal; no persisted schema change (new `snoozed-expanded` key defaults `true`). +- Removing `tick` touches only this package (`bumpThreadSidebarTick` had a single consumer). +- E2E smoke tests keep working: `window-sidebar` testid unchanged; rail reuses shared action testids. + +## Test strategy + +Vitest for pure logic (anchoring, collector). Renderer behavior (rail, badges, overlay) is exercised via existing +testids manually; no new e2e specs in this goal (follow-up if the experiment graduates). diff --git a/docs/features/thread-sidebar-polish/spec.md b/docs/features/thread-sidebar-polish/spec.md new file mode 100644 index 000000000..a0284ca34 --- /dev/null +++ b/docs/features/thread-sidebar-polish/spec.md @@ -0,0 +1,100 @@ +# Thread Sidebar Polish (Experiment) + +## User need + +The experimental thread sidebar works but has rough edges versus the original sidebar: broken keyboard-navigation +targets, layout shift on hover, missing shortcut badges, an empty collapsed strip, inconsistent shelf persistence, +and confusing pinned+settled rows. It also carries dead code and re-renders wholesale every second. + +## Goal + +Bring the experimental sidebar to feature/UX parity with the original sidebar shell and clean up its internals. +Builds on `docs/issues/thread-sidebar-fixes/` (lands after those fixes). + +## Enhancements and acceptance criteria + +### U1 — Search UX + +- **AC1**: The search field gains a visible clear ("×") button (parity with `SidebarSearchBox`). +- **AC2**: A query with no matches shows "No results for ''" (search icon), not "No threads yet". +- **AC3**: While searching, matched rows in collapsed Snoozed/Settled shelves are shown (search bypasses shelf + collapse); shelves render their own state again once the query is cleared. + +### U2 — Keyboard navigation targets visible rows only + +- **AC4**: Arrow navigation and Enter operate on the flat list of *rendered* rows (visible settled page, expanded + shelves); hidden rows can no longer be selected invisibly. + +### U3 — Hover action without layout shift + +- **AC5**: The hover Settle/Un-settle/Unsnooze button overlays the row's right slot (absolutely positioned; the time + label crossfades out) — no horizontal shift of the title/time on hover. +- **AC6**: The action button is reachable by keyboard (`group-focus-within`, which also matches the focused row + itself since rows are tabbable). + +### U4 — Active session is never hidden by snooze + +- **AC7**: Snoozing the currently open thread keeps its row in Active (with normal age right slot) instead of moving + it into the Snoozed shelf. `partitionThreads` gains an optional `activeSessionId` helper. Settled behavior is + unchanged (SettledBanner already covers it). + +### U5 — Snoozed shelf expansion persists + +- **AC8**: `snoozedShelfExpanded` moves into `threadSidebarStore` with localStorage persistence + (`argos:thread-sidebar:snoozed-expanded`), matching the settled shelf. + +### U6 — Pinned + settled is visible-settled + +- **AC9**: A pinned thread with a settled entry renders settled state while staying in Pinned: settled age in the + right slot, Un-settle hover action and context-menu item (state-driven via the settled entry, not the row variant). + +### U7 — Collapsed rail + +- **AC10**: When the sidebar is collapsed (Cmd/Ctrl+B or toggle), a 48px icon rail replaces the empty strip in **both + modes**: expand, new chat, attention indicator (pending-approval/working counts; click selects the first attention + session and expands), theme, settings, usage. Reuses the original testids for shared actions + (`app-new-chat-button`, `app-settings-button`, `app-usage-button`, `window-sidebar-theme-toggle`) plus new + `sidebar-rail-*` ids. + +### U8 — Alt/⌘ shortcut badges in experiment mode + +- **AC11**: Holding Alt/⌘ shows number badges on the rows the list actually + renders (search-aware; collapsed shelves excluded; the visible settled page + included), and the shortcuts select them. The list publishes its rendered + row order and the shell derives both badges and shortcut activation from + that exact list. Slot numbering matches the original sidebar: 1–9 plus `0` + as the tenth slot. + +### C1 — Code hygiene and render performance + +- **AC12**: Dead code removed (unused `useAgentStore` import, `matchesTitle`, the `tick` store field + + `bumpThreadSidebarTick` — the list's local `now` already drives live labels). +- **AC13**: Section markup deduplicated via a `ThreadSection` shell component + a per-list `renderRow` helper (row + props built once from stable callbacks). +- **AC14**: The list subscribes per-field (`useSelector`), wraps `ThreadSidebarRow` in `memo`, stabilizes callbacks + with `useCallback`, and passes a coarse timestamp (~15s buckets) to rows without live durations so quiet rows stop + re-rendering every second. `WindowSideBar`/`AgentSwitcher`/`DisplaySettings` no longer re-render on the (removed) + per-second tick. +- **AC15**: Stale doc comments updated (`WindowSideBar` mode description, list ASCII layout, DisplaySettings copy + mentions Snoozed). + +### S1 — Cross-window lifecycle sync + +- **AC16**: `storage` events re-read settled/snoozed maps and both shelf flags so a second window reflects settles/ + snoozes made in the first (the writer window does not receive its own event, so no loops). `workingSinceById` is + intentionally not synced (per-window timing would fight the transition diff). + +## Constraints + +- Same files as the fixes goal; no daemon/contract changes; no new dependencies. +- t3code parity deviations (U4, U8 settled exclusion) are documented here as intentional. + +## Non-goals + +- Virtualization (list sizes are small; memoization is sufficient). +- i18n of the sidebar strings (original sidebar is also English-only). +- Drag-to-reorder pinned threads, custom snooze intervals. + +## Open questions + +None. diff --git a/docs/features/thread-sidebar-polish/tasks.md b/docs/features/thread-sidebar-polish/tasks.md new file mode 100644 index 000000000..604ec2463 --- /dev/null +++ b/docs/features/thread-sidebar-polish/tasks.md @@ -0,0 +1,9 @@ +# Tasks — Thread Sidebar Polish + +- [x] T1. Store: `snoozedShelfExpanded` persistence, remove `tick`, `storage`-event lifecycle sync. +- [x] T2. Logic: `activeSessionId` snooze anchoring; settled detection by key presence (legacy v1 records render as settled). +- [x] T3. `ThreadSidebarRow`: memo, overlay hover action, state-driven settled actions, shortcut badge, draft sync polish. +- [x] T4. `ThreadSidebarList` refactor (selectors, callbacks, search UX, coarse now, badges, viewport-fill pagination, rendered-row publication). +- [x] T5. `WindowSideBar`: collapsed rail (both modes) + shortcut targets from the published rendered rows + doc comment refresh. +- [x] T6. DisplaySettings copy mentions Snoozed. +- [x] T7. Tests + `bun run format` / `bun run lint` / typecheck. diff --git a/docs/issues/thread-sidebar-fixes/plan.md b/docs/issues/thread-sidebar-fixes/plan.md new file mode 100644 index 000000000..d2bb0c5db --- /dev/null +++ b/docs/issues/thread-sidebar-fixes/plan.md @@ -0,0 +1,38 @@ +# Plan — Thread Sidebar Fixes + +## Approach + +Pure helpers move into `threadSidebarLogic.ts` (already the pure module); the store keeps only side effects +(persist + subscribe). The list switches to store actions and gains scroll pagination mirroring +`WindowSideBar`'s original-mode implementation. + +## Affected files + +| File | Change | +|---|---| +| `packages/ui/src/components/threads/threadSidebarLogic.ts` | Add `diffWorkingTransitions`, `pruneLifecycleEntries`, `parseSettledRecord`, lifecycle map types; remove `matchesTitle` (replaced by existing `filterByTitle`). | +| `packages/ui/src/stores/ui/threadSidebar.ts` | Remove import-time destructive seed; subscribe with first-sight-aware diff; add `notifySessionDeleted`, one-time `!hasMore` sweep. | +| `packages/ui/src/components/threads/ThreadSidebarList.tsx` | Store actions for rename/delete; scroll pagination + skeleton + loading row; delete dialog state; inline action error line. | +| `packages/ui/src/components/threads/ThreadSidebarRow.tsx` | Drop `window.confirm`; `onDelete` becomes `onRequestDelete`; rename draft sync. | +| `packages/ui/src/components/DeleteConversationDialog.tsx` | New: extracted from `WindowSideBar.tsx` (shared by both modes). | +| `packages/ui/src/components/SidebarFirstPageSkeleton.tsx` | New: extracted so the experiment list can reuse it without an import cycle. | +| `packages/ui/src/components/WindowSideBar.tsx` | Import extracted components; call `notifySessionDeleted` on its own delete path. | + +## Data flow + +- Rename/delete: Row → list handler → `sessionStore.renameSession/deleteSession` → store updates → rows re-render. + Failure → `reportActionError` → transient error line. +- Delete lifecycle prune: list/`WindowSideBar` confirm → `notifySessionDeleted(id)` → prune maps + persist. +- Startup sweep: existing `sessionStore.subscribe` in the store; first time `sessions.length > 0 && !hasMore` → + `pruneLifecycleEntries` against known ids (once per renderer lifetime). + +## Compatibility + +- localStorage keys and v2 format unchanged; sweep/prune only remove ids not present in the fully-loaded session list. +- `matchesTitle` removal: only consumer is `ThreadSidebarList`, which switches to `filterByTitle`. + +## Test strategy + +Vitest (colocated, mirroring existing `src/**/*.test.ts`): unit tests for `diffWorkingTransitions`, +`pruneLifecycleEntries`, `parseSettledRecord` in `threadSidebarLogic.test.ts` (same file extended by the feature +goal). Manual: restart with working session (AC3), delete from both modes (AC7), scroll long history (AC1). diff --git a/docs/issues/thread-sidebar-fixes/spec.md b/docs/issues/thread-sidebar-fixes/spec.md new file mode 100644 index 000000000..24170afce --- /dev/null +++ b/docs/issues/thread-sidebar-fixes/spec.md @@ -0,0 +1,84 @@ +# Thread Sidebar Fixes (Experiment) + +## User need + +The experimental thread sidebar (`thread_sidebar_enabled`, `packages/ui/src/components/threads/*`) loses data, wipes +state on startup, and uses dialogs/flows inconsistent with the rest of the sidebar. Users on the experiment see fewer +threads than the original sidebar and lose their "Working" elapsed times across restarts. + +## Goal + +Fix the six functional defects found in a review of the experimental sidebar without changing its visual design. + +## Defects and acceptance criteria + +### F1 — Older sessions are invisible (missing pagination) + +The session store pages results (`hasMore` / `loadNextPage`); the original sidebar loads more on scroll, but +`ThreadSidebarList` never does. With the experiment on, users only ever see the first loaded page. + +- **AC1**: Scrolling the experiment list near the bottom loads the next page (same ~96px threshold and rAF throttle as + the original sidebar). +- **AC2**: A muted "Loading..." row appears while `loadingMore`; the first page load shows the same skeleton rows as + the original sidebar (no "No threads yet" flash during bootstrap). + +### F2 — `workingSinceById` does not survive restart + +The store seeds working-since at module import, when `sessionStore.sessions` is still empty, so the seed loop deletes +every persisted entry and persists the empty map. When sessions then load, `recordWorkingTransition` sees +`previousSessions = []` and stamps every working session with `now`, resetting pills to "0s". + +- **AC3**: After an app restart with a session still working, the Working pill continues from the persisted elapsed + time (falls back to `updatedAt` when no persisted value exists). +- **AC4**: A session that genuinely transitions into `working` during a live session still gets a fresh `now` stamp. +- **AC5**: Entries for sessions no longer working are cleaned up when first observed as non-working. + +### F3 — `createSessionClient()` per render + +`ThreadSidebarList` constructs a client on every render (every second while anything is live). The session store +already exposes `renameSession` / `deleteSession`; the list should use the store like the original sidebar does. + +- **AC6**: `ThreadSidebarList` has no module-level or per-render client construction; renames/deletes go through + `useSessionStore()` actions so titles update reactively. + +### F4 — Lifecycle maps grow forever + +`settledAtById`, `snoozedUntilById`, and `workingSinceById` are never pruned when a session is deleted; stale ids +accumulate in localStorage indefinitely. + +- **AC7**: Deleting a thread (from either sidebar mode) removes its lifecycle entries from state and storage. +- **AC8**: Once the session list is fully loaded (`!hasMore`), a one-time sweep prunes entries for unknown ids. The + sweep never runs while pages remain unloaded (paging makes "absent" ambiguous). + +### F5 — `window.confirm` delete and silent failures + +The experiment's context menu uses `window.confirm`; the original sidebar uses the styled +`DeleteConversationDialog`. Rename/delete failures only `console.warn`. + +- **AC9**: Delete in the experiment opens the same `DeleteConversationDialog` (extracted to its own module so both + modes share it; no import cycle with `ThreadSidebarList`). +- **AC10**: Rename/delete failures surface a transient inline error line in the sidebar (auto-clears ~4s) instead of + a console-only warning. + +### F6 — Stale rename draft + +`draftTitle` is captured once at mount. If the auto-title lands while not editing, committing a rename can overwrite +the fresh title with stale text; a failed rename also leaves the stale attempt in the next edit session. + +- **AC11**: Reopening Rename always starts from the current `session.title`; an externally changed title resets the + draft while not editing (adjust-state-during-render pattern, not a state-in-effect). + +## Constraints + +- No visual redesign in this goal (UX polish lives in `docs/features/thread-sidebar-polish/`). +- Keep `threadSidebarLogic.ts` pure (no React, no store, no side effects) so it stays trivially testable. +- localStorage formats are already v2; do not bump versions. `parseSettledRecord` stays compatible with v1 booleans. + +## Non-goals + +- Collapsed rail, shortcut badges, hover overlay, search UX, snooze/pin semantics (feature folder). +- Daemon-side lifecycle storage (still renderer-local by design). + +## Open questions + +None — all decisions resolved during review. diff --git a/docs/issues/thread-sidebar-fixes/tasks.md b/docs/issues/thread-sidebar-fixes/tasks.md new file mode 100644 index 000000000..9b27b4170 --- /dev/null +++ b/docs/issues/thread-sidebar-fixes/tasks.md @@ -0,0 +1,9 @@ +# Tasks — Thread Sidebar Fixes + +- [x] T1. Extract `DeleteConversationDialog` and `SidebarFirstPageSkeleton` into their own modules; update `WindowSideBar` imports. +- [x] T2. Add pure helpers to `threadSidebarLogic.ts`: `diffWorkingTransitions`, `pruneLifecycleEntries`, `parseSettledRecord`, lifecycle map types; drop `matchesTitle`. +- [x] T3. Rework `threadSidebar.ts`: first-sight-aware working-since diff on subscribe (no import-time seed), `notifySessionDeleted`, one-time `!hasMore` sweep. +- [x] T4. `ThreadSidebarList`: store-backed rename/delete with error line, delete dialog, scroll pagination, skeleton + loading row. +- [x] T5. `ThreadSidebarRow`: `onRequestDelete` (no `window.confirm`), rename draft sync during render. +- [x] T6. Wire `notifySessionDeleted` into `WindowSideBar`'s original-mode delete confirm. +- [x] T7. Unit tests for the new pure helpers; run `bun run format` + `bun run lint` + typecheck. diff --git a/packages/ui/settings/components/DisplaySettings.tsx b/packages/ui/settings/components/DisplaySettings.tsx index 2482b45f0..818e25e68 100644 --- a/packages/ui/settings/components/DisplaySettings.tsx +++ b/packages/ui/settings/components/DisplaySettings.tsx @@ -320,7 +320,8 @@ export default function DisplaySettings() {
- Replace the left sidebar with a task-oriented thread list (Active, Pinned, Settled) — t3code-inspired + Replace the left sidebar with a task-oriented thread list (Active, Pinned, Snoozed, Settled) — + t3code-inspired
diff --git a/packages/ui/src/components/DeleteConversationDialog.tsx b/packages/ui/src/components/DeleteConversationDialog.tsx new file mode 100644 index 000000000..6dbb65b29 --- /dev/null +++ b/packages/ui/src/components/DeleteConversationDialog.tsx @@ -0,0 +1,48 @@ +import { Button } from "#shadcn/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "#shadcn/components/ui/dialog"; + +interface DeleteConversationDialogProps { + open: boolean; + onCancel: () => void; + onConfirm: () => void; +} + +/** + * Confirmation dialog for deleting a conversation. Shared by the original + * sidebar and the thread-sidebar experiment (extracted from + * WindowSideBar.tsx so both modes can import it without an import cycle). + */ +export default function DeleteConversationDialog({ open, onCancel, onConfirm }: DeleteConversationDialogProps) { + return ( + { + if (!value) onCancel(); + }} + > + + + Delete Conversation + + Are you sure you want to delete this conversation? This action cannot be undone. + + + + + + + + + ); +} diff --git a/packages/ui/src/components/SidebarFirstPageSkeleton.tsx b/packages/ui/src/components/SidebarFirstPageSkeleton.tsx new file mode 100644 index 000000000..6a6321b7d --- /dev/null +++ b/packages/ui/src/components/SidebarFirstPageSkeleton.tsx @@ -0,0 +1,16 @@ +/** + * Skeleton rows shown while the first session page loads. Shared by the + * original sidebar and the thread-sidebar experiment (extracted from + * WindowSideBar.tsx so both modes can import it without an import cycle). + */ +export default function SidebarFirstPageSkeleton() { + return ( +
+ {Array.from({ + length: 6, + }).map((_, i) => ( +
+ ))} +
+ ); +} diff --git a/packages/ui/src/components/WindowSideBar.tsx b/packages/ui/src/components/WindowSideBar.tsx index 2a3d577c2..4ffe97788 100644 --- a/packages/ui/src/components/WindowSideBar.tsx +++ b/packages/ui/src/components/WindowSideBar.tsx @@ -1,17 +1,8 @@ -import { useState, useEffect, useRef, type RefObject, type ReactNode } from "react"; +import { useCallback, useState, useEffect, useMemo, useRef, type RefObject, type ReactNode } from "react"; import { useNavigate } from "@tanstack/react-router"; import { Icon } from "@iconify/react"; import { Tooltip, TooltipContent, TooltipTrigger } from "#shadcn/components/ui/tooltip"; -import { Button } from "#shadcn/components/ui/button"; import { Input } from "#shadcn/components/ui/input"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "#shadcn/components/ui/dialog"; import { createDeviceClient } from "#api/DeviceClient"; import { useAgentStore } from "#/stores/ui/agent"; import { useSessionStore, getHasActiveSession, type SessionGroup, type UISession } from "#/stores/ui/session"; @@ -19,8 +10,11 @@ import { useSpotlightStore } from "#/stores/ui/spotlight"; import WindowSideBarSessionItem from "./WindowSideBarSessionItem"; import WorkspaceSelector from "./WorkspaceSelector"; import ThreadSidebarList from "./threads/ThreadSidebarList"; +import DeleteConversationDialog from "./DeleteConversationDialog"; +import SidebarFirstPageSkeleton from "./SidebarFirstPageSkeleton"; +import { isSidebarVisibleSession } from "./threads/threadSidebarLogic"; import { useSidebarStore } from "#/stores/ui/sidebar"; -import { useThreadSidebarStore } from "#/stores/ui/threadSidebar"; +import { notifySessionDeleted, useThreadSidebarStore } from "#/stores/ui/threadSidebar"; import { useThemeStore } from "#/stores/theme"; type PinFeedbackMode = "pinning" | "unpinning"; type ShortcutPlatform = "mac" | "other"; @@ -139,17 +133,16 @@ const deviceClient = createDeviceClient(); /** * The left sidebar. Two modes: * - **Thread sidebar (experiment on)**: a t3code-style column with a - * search input, agent switcher, an Active row, and a Settled list. The - * bottom utility bar (search / theme / collapse / settings / usage) - * sits in a horizontal row at the very bottom of the column. + * clearable search + New-thread header and Pinned / Active / Snoozed / + * Settled lifecycle sections (docs/features/thread-sidebar-polish). * - **Original (experiment off)**: the agent / project / date history - * grouping with a search and grouped rows, plus the same horizontal - * utility bar pinned to the bottom. + * grouping with a search and grouped rows. * * In both modes, the dedicated left icon rail is gone — agent chips, the - * project selector, etc. live inside the column itself. The icon rail's - * bottom utility icons are now a horizontal bar at the bottom of the - * sidebar. + * project selector, etc. live inside the column itself — and a horizontal + * utility bar sits at the bottom of the column. When the sidebar is + * collapsed, a minimal icon rail replaces the empty strip (expand, new chat, + * attention indicator, theme / settings / usage). */ export default function WindowSideBar() { const navigate = useNavigate(); @@ -172,6 +165,8 @@ export default function WindowSideBar() { navigator.platform.toLowerCase().includes("mac") ? "mac" : "other", ); const [showShortcutBadges, setShowShortcutBadges] = useState(false); + /** Row order published by the experiment list — the exact rendered rows. */ + const [threadVisibleRows, setThreadVisibleRows] = useState([]); const sessionListRef = useRef(null); const pinFeedbackTimerRef = useRef(null); const sessionListScrollFrameRef = useRef(null); @@ -255,6 +250,7 @@ export default function WindowSideBar() { if (!deleteTargetSession) return; try { await sessionStore.deleteSession(deleteTargetSession.id); + notifySessionDeleted(deleteTargetSession.id); } catch {} setDeleteTargetSession(null); }; @@ -268,14 +264,33 @@ export default function WindowSideBar() { if (distanceToBottom <= 96) void sessionStore.loadNextPage(); }); }; - const visibleShortcutSessions = collectVisibleShortcutSessions({ + const handleThreadVisibleRowsChange = useCallback((rows: UISession[]) => setThreadVisibleRows(rows), []); + const visibleShortcutSessions = useMemo(() => { + if (threadSidebar.enabled) { + // Experiment mode: shortcut targets come from the rows the list + // actually renders (published via onVisibleRowsChange) — search-aware, + // shelf-expanded-only, and always in sync with the live clock. + if (collapsed) return []; + return threadVisibleRows.slice(0, 10); + } + return collectVisibleShortcutSessions({ + collapsed, + pinnedSessions, + isPinnedSectionCollapsed, + filteredGroups, + isGroupCollapsed, + pinFlightSessionId, + }); + }, [ + threadSidebar.enabled, collapsed, + threadVisibleRows, pinnedSessions, isPinnedSectionCollapsed, filteredGroups, isGroupCollapsed, pinFlightSessionId, - }); + ]); useEffect(() => { const { handleKeydown, handleKeyup, handleBlur } = createShortcutKeyHandlers({ shortcutPlatform, @@ -312,15 +327,48 @@ export default function WindowSideBar() { if (pinFeedbackTimerRef.current) window.clearTimeout(pinFeedbackTimerRef.current); }; }, []); - const getShortcutBadge = (sessionId: string) => - showShortcutBadges ? getShortcutBadgeLabelForSession(shortcutPlatform, sessionId, visibleShortcutSessions) : null; - const hasShortcutBadge = (sessionId: string) => - showShortcutBadges && visibleShortcutSessions.some((s) => s.id === sessionId); + const getShortcutBadge = useCallback( + (sessionId: string) => + showShortcutBadges ? getShortcutBadgeLabelForSession(shortcutPlatform, sessionId, visibleShortcutSessions) : null, + [showShortcutBadges, shortcutPlatform, visibleShortcutSessions], + ); + const hasShortcutBadge = useCallback( + (sessionId: string) => showShortcutBadges && visibleShortcutSessions.some((s) => s.id === sessionId), + [showShortcutBadges, visibleShortcutSessions], + ); + // Collapsed-rail attention indicator: pending approval first, then working. + // Sidebar visibility rules apply — drafts and subagent sessions never light + // the rail (the sidebar does not render them). + const railAttention = useMemo(() => { + let blocked: UISession | null = null; + let working: UISession | null = null; + let blockedCount = 0; + let workingCount = 0; + for (const session of sessionStore.sessions) { + if (!isSidebarVisibleSession(session)) continue; + if (session.status === "blocked") { + blockedCount += 1; + if (blocked === null) blocked = session; + } else if (session.status === "working") { + workingCount += 1; + if (working === null) working = session; + } + } + if (blocked) return { kind: "blocked" as const, count: blockedCount, sessionId: blocked.id }; + if (working) return { kind: "working" as const, count: workingCount, sessionId: working.id }; + return null; + }, [sessionStore.sessions]); + // Plain handler (no memoization needed — the rail is not memoized). + const handleRailAttentionClick = () => { + if (!railAttention) return; + sidebarStore.setCollapsed(false); + void sessionStore.selectSession(railAttention.sessionId); + }; return ( <>
{threadSidebar.enabled ? ( - + ) : ( <> {!collapsed && ( @@ -419,6 +470,22 @@ export default function WindowSideBar() { )}
+ {collapsed && ( +
+ themeStore.cycleTheme()} + onExpand={() => sidebarStore.setCollapsed(false)} + onNewChat={handleNewChat} + onOpenSettings={openSettings} + onOpenUsage={openUsage} + attention={railAttention} + onAttentionClick={handleRailAttentionClick} + /> +
+ )} + {!collapsed && ( - {Array.from({ - length: 6, - }).map((_, i) => ( -
- ))} -
- ); -} +/** Empty state for the original session list (first-page skeleton is shared via SidebarFirstPageSkeleton). */ function SidebarEmptyState({ hasQuery }: { hasQuery: boolean }) { return (
@@ -767,37 +823,151 @@ function SidebarBottomUtilityBar(props: SidebarBottomUtilityBarProps) {
); } -interface DeleteConversationDialogProps { - open: boolean; - onCancel: () => void; - onConfirm: () => void; +interface SidebarCollapsedRailProps { + themeIcon: string; + themeModeLabel: string; + onCycleTheme: () => void; + onExpand: () => void; + onNewChat: () => void; + onOpenSettings: () => void; + onOpenUsage: () => void; + attention: { + kind: "blocked" | "working"; + count: number; + sessionId: string; + } | null; + onAttentionClick: () => void; } -/** Confirmation dialog for deleting a conversation. */ -function DeleteConversationDialog({ open, onCancel, onConfirm }: DeleteConversationDialogProps) { +/** Vertical icon rail shown while the sidebar is collapsed (both modes). */ +function SidebarCollapsedRail({ + themeIcon, + themeModeLabel, + onCycleTheme, + onExpand, + onNewChat, + onOpenSettings, + onOpenUsage, + attention, + onAttentionClick, +}: SidebarCollapsedRailProps) { + const railButtonClassName = + "flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors duration-150 hover:bg-accent/40 hover:text-foreground"; return ( - { - if (!value) onCancel(); - }} +
- - - Delete Conversation - - Are you sure you want to delete this conversation? This action cannot be undone. - - - - - - - -
+ + + } + > + + + Expand Sidebar + + + + } + > + + + New Chat + + {attention && ( + + + } + > + + {attention.count > 1 && ( + + {attention.count} + + )} + + + {attention.kind === "blocked" ? `${attention.count} pending approval` : `${attention.count} working`} + + + )} +
+ + + } + > + + + + + Theme · {themeModeLabel} + + + + } + > + + + Settings + + + + } + > + + + Usage + +
+
); } diff --git a/packages/ui/src/components/threads/ThreadSection.tsx b/packages/ui/src/components/threads/ThreadSection.tsx new file mode 100644 index 000000000..0def0d29b --- /dev/null +++ b/packages/ui/src/components/threads/ThreadSection.tsx @@ -0,0 +1,69 @@ +import type { ReactNode } from "react"; +import { Icon } from "@iconify/react"; + +interface ThreadSectionProps { + label: string; + /** Shown next to collapsible labels; defaults to 0 when omitted. */ + count?: number; + /** Render a collapsible shelf header (chevron + count) instead of a plain label. */ + collapsible?: boolean; + expanded?: boolean; + onToggleExpanded?: () => void; + toggleTestId?: string; + children: ReactNode; + /** Renders a "Show more (N)" row below the children when remaining > 0. */ + showMore?: { + remaining: number; + onClick: () => void; + testId?: string; + }; +} + +/** + * Section shell for the thread sidebar (label, optional collapsible toggle, + * optional show-more row). Children are responsible for the row list so the + * caller keeps control of row props; this only dedupes the section chrome + * that was previously duplicated across Pinned/Active/Snoozed/Settled. + */ +export default function ThreadSection({ + label, + count = 0, + collapsible = false, + expanded = true, + onToggleExpanded, + toggleTestId, + children, + showMore, +}: ThreadSectionProps) { + const header = collapsible ? ( + + ) : ( +

{label}

+ ); + return ( +
+ {header} + {(!collapsible || expanded) && children} + {showMore && showMore.remaining > 0 && ( + + )} +
+ ); +} diff --git a/packages/ui/src/components/threads/ThreadSidebarList.tsx b/packages/ui/src/components/threads/ThreadSidebarList.tsx index 39885f988..76ed91fd3 100644 --- a/packages/ui/src/components/threads/ThreadSidebarList.tsx +++ b/packages/ui/src/components/threads/ThreadSidebarList.tsx @@ -1,37 +1,41 @@ -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Icon } from "@iconify/react"; -import { useAgentStore } from "#/stores/ui/agent"; +import { useSelector } from "@tanstack/react-store"; import { useSessionStore, type UISession } from "#/stores/ui/session"; -import { createSessionClient } from "../../../api/SessionClient"; import { Input } from "#shadcn/components/ui/input"; import { - bumpThreadSidebarTick, - getSettledAt, isSessionWoke, markThreadOpened, + notifySessionDeleted, setSettledShelfExpanded, + setSnoozedShelfExpanded, settleSession, snoozeSession, + threadSidebarStore, unsettleSession, unsnoozeSession, - useThreadSidebarStore, } from "#/stores/ui/threadSidebar"; -import { matchesTitle, partitionThreads } from "./threadSidebarLogic"; +import { filterByTitle, partitionThreads } from "./threadSidebarLogic"; +import ThreadSection from "./ThreadSection"; import ThreadSidebarRow from "./ThreadSidebarRow"; +import DeleteConversationDialog from "../DeleteConversationDialog"; +import SidebarFirstPageSkeleton from "../SidebarFirstPageSkeleton"; /** * t3code-style thread sidebar (v2 parity rework — - * docs/features/thread-sidebar-t3-parity). + * docs/features/thread-sidebar-t3-parity; fixes in + * docs/issues/thread-sidebar-fixes, polish in + * docs/features/thread-sidebar-polish). * * ┌───────────────────────────────┐ - * │ [🔎 Search ] [✎] │ ← dedicated New-thread button + * │ [🔎 Search ×] [✎] │ ← clearable search + New-thread * │ PINNED │ * │ Title 3d │ * │ ACTIVE │ * │ Title [●Working 12s] now │ * │ Title [◉Pending approval] │ * │ Title 5m │ - * │ SNOOZED (2) ▸ │ ← collapsible shelf + * │ SNOOZED (2) ▸ │ ← collapsible shelf (persisted) * │ Title waking in 58m │ * │ SETTLED (14) ▾ │ ← collapsible shelf, paged * │ • Title 3d │ ← selected row highlighted @@ -43,127 +47,264 @@ import ThreadSidebarRow from "./ThreadSidebarRow"; * non-pinned, non-snoozed, non-settled thread — newest first — with per-row * status pills (pending approval > failed > working > unseen completion). * Settling is an explicit user action (hover check button / context menu); - * working threads can never render as Settled. + * working threads can never render as Settled. The currently open session is + * never hidden by snooze: it stays anchored in Active. */ const SETTLED_PAGE_SIZE = 10; -export default function ThreadSidebarList() { +/** Quiet rows share a coarse clock bucket so memoized rows skip per-second renders. */ +const NOW_BUCKET_MS = 15_000; + +interface ThreadSidebarListProps { + /** Alt/⌘+1..9 badge label for a session id, or null while badges are hidden. */ + getShortcutBadge?: (sessionId: string) => string | null; + /** Publishes the rendered row order so the shell can derive shortcut targets from exactly what is visible. */ + onVisibleRowsChange?: (rows: UISession[]) => void; +} + +export default function ThreadSidebarList({ getShortcutBadge, onVisibleRowsChange }: ThreadSidebarListProps) { const sessionStore = useSessionStore(); - const { workingSinceById, settledAtById, snoozedUntilById, settledShelfExpanded, tick } = useThreadSidebarStore(); - const sessionClient = createSessionClient(); + // Per-field subscriptions: unlike a whole-store selector, unrelated store + // updates do not re-render the list. + const workingSinceById = useSelector(threadSidebarStore, (s) => s.workingSinceById); + const settledAtById = useSelector(threadSidebarStore, (s) => s.settledAtById); + const snoozedUntilById = useSelector(threadSidebarStore, (s) => s.snoozedUntilById); + const settledShelfExpanded = useSelector(threadSidebarStore, (s) => s.settledShelfExpanded); + const snoozedShelfExpanded = useSelector(threadSidebarStore, (s) => s.snoozedShelfExpanded); + const [searchQuery, setSearchQuery] = useState(""); const [navIndex, setNavIndex] = useState(-1); const [settledPageCount, setSettledPageCount] = useState(1); - const [snoozedShelfExpanded, setSnoozedShelfExpanded] = useState(true); + const [deleteTarget, setDeleteTarget] = useState(null); + const [actionError, setActionError] = useState(null); const [now, setNow] = useState(() => Date.now()); const listRef = useRef(null); + const scrollFrameRef = useRef(null); + const actionErrorTimerRef = useRef(null); // Live tick for working durations / wake countdowns: only run while there is // something live to show (cheap no-op otherwise). - const hasLiveRows = (() => { - const sessions = sessionStore.sessions; - const anyWorking = sessions.some((s) => s.status === "working"); - const anySnoozed = Object.keys(snoozedUntilById).length > 0; + const hasLiveRows = useMemo(() => { + const anyWorking = sessionStore.sessions.some((session) => session.status === "working"); + const anySnoozed = Object.values(snoozedUntilById).some((until) => until > now); return anyWorking || anySnoozed; - })(); + }, [sessionStore.sessions, snoozedUntilById, now]); useEffect(() => { if (!hasLiveRows) return; - const interval = window.setInterval(() => { - setNow(Date.now()); - bumpThreadSidebarTick(); - }, 1000); + const interval = window.setInterval(() => setNow(Date.now()), 1000); return () => window.clearInterval(interval); }, [hasLiveRows]); - // Touch `tick` so the panel re-renders on store flips even if the section - // identities didn't change. - void tick; - const sections = partitionThreads(sessionStore.sessions, { - settledAtById, - snoozedUntilById, - now, - }); + useEffect(() => { + return () => { + if (scrollFrameRef.current !== null) window.cancelAnimationFrame(scrollFrameRef.current); + if (actionErrorTimerRef.current !== null) window.clearTimeout(actionErrorTimerRef.current); + }; + }, []); + + const reportActionError = useCallback((message: string) => { + setActionError(message); + if (actionErrorTimerRef.current !== null) window.clearTimeout(actionErrorTimerRef.current); + actionErrorTimerRef.current = window.setTimeout(() => { + setActionError(null); + actionErrorTimerRef.current = null; + }, 4000); + }, []); + + const sections = useMemo( + () => + partitionThreads(sessionStore.sessions, { + settledAtById, + snoozedUntilById, + now, + activeSessionId: sessionStore.activeSessionId, + }), + [sessionStore.sessions, sessionStore.activeSessionId, settledAtById, snoozedUntilById, now], + ); + const searching = searchQuery.trim().length > 0; - const pinned = searching ? sections.pinned.filter((s) => matchesTitle(s, searchQuery)) : sections.pinned; - const active = searching ? sections.active.filter((s) => matchesTitle(s, searchQuery)) : sections.active; - const snoozed = searching ? sections.snoozed.filter((s) => matchesTitle(s, searchQuery)) : sections.snoozed; - const settled = searching ? sections.settled.filter((s) => matchesTitle(s, searchQuery)) : sections.settled; + const filtered = useMemo(() => { + if (!searching) return sections; + const byTitle = (rows: UISession[]) => filterByTitle(rows, searchQuery); + return { + pinned: byTitle(sections.pinned), + active: byTitle(sections.active), + snoozed: byTitle(sections.snoozed), + settled: byTitle(sections.settled), + }; + }, [sections, searching, searchQuery]); - // Flat result order drives keyboard navigation while searching. - const flatResults = [...pinned, ...active, ...snoozed, ...settled]; - const navIndexById = (() => { + // While searching, matched rows surface even from collapsed shelves. + const snoozedExpanded = searching || snoozedShelfExpanded; + const settledExpanded = searching || settledShelfExpanded; + const visibleSettledCount = searching ? filtered.settled.length : settledPageCount * SETTLED_PAGE_SIZE; + const visibleSettled = useMemo( + () => filtered.settled.slice(0, visibleSettledCount), + [filtered.settled, visibleSettledCount], + ); + + // Flat result order drives keyboard navigation — rendered rows only, so + // hidden rows (collapsed shelves, unshown settled pages) can never be + // selected invisibly. + const flatResults = useMemo( + () => [ + ...filtered.pinned, + ...filtered.active, + ...(snoozedExpanded ? filtered.snoozed : []), + ...(settledExpanded ? visibleSettled : []), + ], + [filtered.pinned, filtered.active, filtered.snoozed, visibleSettled, snoozedExpanded, settledExpanded], + ); + const navIndexById = useMemo(() => { const map = new Map(); flatResults.forEach((session, index) => map.set(session.id, index)); return map; - })(); + }, [flatResults]); + + // Publish the rendered row order so the shell can derive Alt/⌘ shortcut + // targets from exactly what is visible (search-aware, live clock). + useEffect(() => { + onVisibleRowsChange?.(flatResults); + }, [flatResults, onVisibleRowsChange]); + useEffect(() => { if (navIndex < 0) return; listRef.current?.querySelector('[data-nav-selected="true"]')?.scrollIntoView({ block: "nearest", }); }, [navIndex]); - const visibleSettledCount = searching ? settled.length : settledPageCount * SETTLED_PAGE_SIZE; - const visibleSettled = settled.slice(0, visibleSettledCount); - const handleSelect = (session: UISession) => { - markThreadOpened(session.id); - void sessionStore.selectSession(session.id); + + // Content shorter than the viewport never scrolls, so scroll-based loading + // would never fire; keep requesting pages until the list overflows. + useEffect(() => { + if ( + !sessionStore.hasLoadedInitialPage || + sessionStore.loading || + sessionStore.loadingMore || + !sessionStore.hasMore + ) { + return; + } + const element = listRef.current; + if (element && element.scrollHeight <= element.clientHeight) { + void sessionStore.loadNextPage(); + } + }); + + // Pagination parity with the original sidebar: load the next session page + // when the list is scrolled near the bottom. + const handleListScroll = useCallback(() => { + if (scrollFrameRef.current !== null) return; + scrollFrameRef.current = window.requestAnimationFrame(() => { + scrollFrameRef.current = null; + const element = listRef.current; + if (!element || sessionStore.loadingMore || !sessionStore.hasMore) return; + const distanceToBottom = element.scrollHeight - element.scrollTop - element.clientHeight; + if (distanceToBottom <= 96) void sessionStore.loadNextPage(); + }); + }, [sessionStore]); + + const applySearchQuery = (value: string) => { + setSearchQuery(value); + // Reset nav + paging inline: a reset effect would be a synchronous + // set-state-in-effect (react-doctor). + setNavIndex(-1); + setSettledPageCount(1); }; - const handleNewChat = () => { + + const handleSelect = useCallback( + (session: UISession) => { + markThreadOpened(session.id); + void sessionStore.selectSession(session.id); + }, + [sessionStore], + ); + const handleNewChat = useCallback(() => { void sessionStore.startNewConversation({ refresh: true, }); - }; - const handleRename = async (session: UISession, title: string) => { - try { - await sessionClient.renameSession(session.id, title); - } catch (renameError) { - console.warn("[threadSidebar] Failed to rename session:", renameError); - } - }; - const handleDelete = async (session: UISession) => { - try { - await sessionClient.deleteSession(session.id); - } catch (deleteError) { - console.warn("[threadSidebar] Failed to delete session:", deleteError); - } - }; - const renderSection = (label: string, rows: UISession[], variant: "active" | "settled" | "snoozed") => { - if (rows.length === 0) return null; + }, [sessionStore]); + const handleRename = useCallback( + async (session: UISession, title: string) => { + try { + await sessionStore.renameSession(session.id, title); + } catch { + reportActionError(`Failed to rename "${session.title || "Untitled session"}".`); + } + }, + [sessionStore, reportActionError], + ); + const handleDelete = useCallback( + async (session: UISession) => { + try { + await sessionStore.deleteSession(session.id); + notifySessionDeleted(session.id); + } catch { + reportActionError(`Failed to delete "${session.title || "Untitled session"}".`); + } + }, + [sessionStore, reportActionError], + ); + const handleDeleteConfirm = useCallback(() => { + const target = deleteTarget; + setDeleteTarget(null); + if (target) void handleDelete(target); + }, [deleteTarget, handleDelete]); + + // Stable row callbacks — memoized rows compare props shallowly. + const onSettle = useCallback((session: UISession) => settleSession(session.id), []); + const onUnsettle = useCallback((session: UISession) => unsettleSession(session.id), []); + const onSnooze = useCallback((session: UISession, durationMs: number) => snoozeSession(session.id, durationMs), []); + const onUnsnooze = useCallback((session: UISession) => unsnoozeSession(session.id), []); + const onTogglePin = useCallback( + (session: UISession) => { + void sessionStore.toggleSessionPinned(session.id, !session.isPinned); + }, + [sessionStore], + ); + const onRequestDelete = useCallback((session: UISession) => setDeleteTarget(session), []); + + const renderRow = (session: UISession, variant: "active" | "settled" | "snoozed") => { + const snoozedUntil = snoozedUntilById[session.id]; + const workingSince = workingSinceById[session.id]; + const isLive = + (variant === "active" && session.status === "working" && typeof workingSince === "number") || + (variant === "snoozed" && typeof snoozedUntil === "number" && snoozedUntil > now); return ( -
-

{label}

-
    - {rows.map((session) => { - const isWoke = variant === "snoozed" && isSessionWoke(session.id, now); - return ( -
  • - settleSession(target.id)} - onUnsettle={(target) => unsettleSession(target.id)} - onTogglePin={(target) => void sessionStore.toggleSessionPinned(target.id, !target.isPinned)} - onSnooze={(target, durationMs) => snoozeSession(target.id, durationMs)} - onUnsnooze={(target) => unsnoozeSession(target.id)} - onRename={(target, title) => void handleRename(target, title)} - onDelete={(target) => void handleDelete(target)} - /> -
  • - ); - })} -
-
+ ); }; - const hasAnyRows = pinned.length + active.length + snoozed.length + settled.length > 0; + const renderRows = (rows: UISession[], variant: "active" | "settled" | "snoozed") => ( +
    + {rows.map((session) => ( +
  • {renderRow(session, variant)}
  • + ))} +
+ ); + + const hasAnyRows = + filtered.pinned.length + filtered.active.length + filtered.snoozed.length + filtered.settled.length > 0; + const firstPageLoading = !sessionStore.hasLoadedInitialPage && sessionStore.loading; return (
{/* Search + New thread */} @@ -177,13 +318,7 @@ export default function ThreadSidebarList() { { - setSearchQuery(event.target.value); - // Reset nav + paging inline: a reset effect would be a - // synchronous set-state-in-effect (react-doctor). - setNavIndex(-1); - setSettledPageCount(1); - }} + onChange={(event) => applySearchQuery(event.target.value)} onKeyDown={(event) => { if (event.key === "ArrowDown") { event.preventDefault(); @@ -197,15 +332,26 @@ export default function ThreadSidebarList() { if (target) handleSelect(target); } else if (event.key === "Escape") { event.preventDefault(); - setSearchQuery(""); + applySearchQuery(""); } }} - className="h-8 rounded-xl border-0 bg-muted/60 pl-8 pr-2 text-xs shadow-none focus-visible:ring-1 focus-visible:ring-primary/30" + className="h-8 rounded-xl border-0 bg-muted/60 pl-8 pr-8 text-xs shadow-none focus-visible:ring-1 focus-visible:ring-primary/30" placeholder="Search" aria-label="Search threads" autoComplete="off" spellCheck={false} /> + {searchQuery && ( + + )}
-
- {renderSection("Pinned", pinned, "active")} - {renderSection("Active", active, "active")} + {actionError && ( +

+ {actionError} +

+ )} - {snoozed.length > 0 && ( -
- - {snoozedShelfExpanded && ( -
    - {snoozed.map((session) => ( -
  • - settleSession(target.id)} - onUnsettle={(target) => unsettleSession(target.id)} - onTogglePin={(target) => void sessionStore.toggleSessionPinned(target.id, !target.isPinned)} - onSnooze={(target, durationMs) => snoozeSession(target.id, durationMs)} - onUnsnooze={(target) => unsnoozeSession(target.id)} - onRename={(target, title) => void handleRename(target, title)} - onDelete={(target) => void handleDelete(target)} - /> -
  • - ))} -
+
+ {firstPageLoading ? ( + + ) : ( + <> + {filtered.pinned.length > 0 && ( + {renderRows(filtered.pinned, "active")} )} -
- )} - {settled.length > 0 && ( -
- - {settledShelfExpanded && ( - <> -
    - {visibleSettled.map((session) => ( -
  • - settleSession(target.id)} - onUnsettle={(target) => unsettleSession(target.id)} - onTogglePin={(target) => void sessionStore.toggleSessionPinned(target.id, !target.isPinned)} - onSnooze={(target, durationMs) => snoozeSession(target.id, durationMs)} - onUnsnooze={(target) => unsnoozeSession(target.id)} - onRename={(target, title) => void handleRename(target, title)} - onDelete={(target) => void handleDelete(target)} - /> -
  • - ))} -
- {settled.length > visibleSettled.length && ( - - )} - + {filtered.active.length > 0 && ( + {renderRows(filtered.active, "active")} )} -
- )} - {!hasAnyRows && ( -
- -

No threads yet

-
+ {filtered.snoozed.length > 0 && ( + setSnoozedShelfExpanded(!snoozedShelfExpanded)} + toggleTestId="thread-sidebar-snoozed-toggle" + > + {renderRows(filtered.snoozed, "snoozed")} + + )} + + {filtered.settled.length > 0 && ( + setSettledShelfExpanded(!settledShelfExpanded)} + toggleTestId="thread-sidebar-settled-toggle" + showMore={ + settledExpanded + ? { + remaining: filtered.settled.length - visibleSettled.length, + onClick: () => setSettledPageCount((count) => count + 1), + testId: "thread-sidebar-settled-more", + } + : undefined + } + > + {renderRows(visibleSettled, "settled")} + + )} + + {!hasAnyRows && + (searching ? ( +
+ +

No results for "{searchQuery.trim()}"

+
+ ) : ( +
+ +

No threads yet

+
+ ))} + + {sessionStore.loadingMore && ( +
Loading...
+ )} + )}
+ + setDeleteTarget(null)} + onConfirm={handleDeleteConfirm} + /> ); } diff --git a/packages/ui/src/components/threads/ThreadSidebarRow.tsx b/packages/ui/src/components/threads/ThreadSidebarRow.tsx index 6d66ad0a7..9209ceecf 100644 --- a/packages/ui/src/components/threads/ThreadSidebarRow.tsx +++ b/packages/ui/src/components/threads/ThreadSidebarRow.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { memo, useEffect, useRef, useState } from "react"; import { Icon } from "@iconify/react"; import { useAgentStore } from "#/stores/ui/agent"; import type { UISession } from "#/stores/ui/session"; @@ -39,6 +39,8 @@ interface ThreadSidebarRowProps { isWoke?: boolean; /** Settled timestamp (ms) when the row is settled; 0 = unknown (legacy). */ settledAt?: number; + /** Alt/⌘+N shortcut badge label while the modifier is held, else null. */ + shortcutBadge?: string | null; onSelect: (session: UISession) => void; onSettle: (session: UISession) => void; onUnsettle: (session: UISession) => void; @@ -46,7 +48,8 @@ interface ThreadSidebarRowProps { onSnooze: (session: UISession, durationMs: number) => void; onUnsnooze: (session: UISession) => void; onRename: (session: UISession, title: string) => void; - onDelete: (session: UISession) => void; + /** Open the shared delete-confirmation dialog for this session. */ + onRequestDelete: (session: UISession) => void; } const SNOOZE_OPTIONS: Array<{ label: string; durationMs: number }> = [ @@ -59,8 +62,12 @@ const SNOOZE_OPTIONS: Array<{ label: string; durationMs: number }> = [ * t3code-style slim thread row: avatar + title (search-highlighted) + status * pill + right-aligned time slot, with a hover Settle/Un-settle affordance and * a context menu (pin / rename / snooze / settle / delete). + * + * Settled state is data-driven, not variant-driven: a pinned row with a + * settled entry renders settled (age slot + un-settle actions) while staying + * in the Pinned section. */ -export default function ThreadSidebarRow({ +function ThreadSidebarRow({ session, variant, isSelected, @@ -71,6 +78,7 @@ export default function ThreadSidebarRow({ snoozedUntil, isWoke, settledAt, + shortcutBadge, onSelect, onSettle, onUnsettle, @@ -78,14 +86,25 @@ export default function ThreadSidebarRow({ onSnooze, onUnsnooze, onRename, - onDelete, + onRequestDelete, }: ThreadSidebarRowProps) { const { enabledAgents } = useAgentStore(); - const agent = enabledAgents.find((a) => a.id === session.agentId) ?? null; + const agent = enabledAgents.find((candidate) => candidate.id === session.agentId) ?? null; const [editing, setEditing] = useState(false); const [draftTitle, setDraftTitle] = useState(session.title); + const [syncedTitle, setSyncedTitle] = useState(session.title); const editInputRef = useRef(null); + const isSettledRow = variant === "settled" || typeof settledAt === "number"; + + // Keep the rename draft in sync with external title changes (e.g. an + // auto-title landing after mount) without a set-state-in-effect: + // adjusting state during render is the React-sanctioned pattern here. + if (session.title !== syncedTitle) { + setSyncedTitle(session.title); + if (!editing) setDraftTitle(session.title); + } + const status = resolveThreadStatus(session); const pill = variant === "active" ? resolveThreadPill(status) : null; const segments = highlightSegments(session.title || "Untitled session", query); @@ -137,7 +156,7 @@ export default function ThreadSidebarRow({ ); } - if (variant === "settled") { + if (isSettledRow) { // Prefer the settled time (t3code parity); legacy entries without one // fall back to the session's updatedAt. const label = isWoke ? "Woke" : formatAge(settledAt && settledAt > 0 ? settledAt : session.updatedAt, now); @@ -152,35 +171,39 @@ export default function ThreadSidebarRow({ const hoverAction = (() => { if (editing) return null; - if (variant === "settled") { + // Overlays the right slot (no layout shift); focus-within also matches + // the focused row itself, so keyboard users can reach the action. + const actionClassName = + "absolute right-1 top-1/2 z-10 h-5 w-5 -translate-y-1/2 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground hidden group-hover:flex group-focus-within:flex"; + if (variant === "snoozed") { return ( ); } - if (variant === "snoozed") { + if (isSettledRow) { return ( ); } @@ -193,13 +216,15 @@ export default function ThreadSidebarRow({ event.stopPropagation(); onSettle(session); }} - className="hidden h-5 w-5 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground group-hover:flex" + className={actionClassName} > ); })(); + const shortcutBadgeTitle = shortcutBadge ? `Switch with ${shortcutBadge}` : ""; + return ( {/* The trigger is a div: rows contain real action buttons (select, settle, @@ -217,6 +242,7 @@ export default function ThreadSidebarRow({ data-variant={variant} data-selected={String(isSelected)} data-nav-selected={String(isNavSelected)} + data-settled={isSettledRow ? "true" : undefined} data-editing={editing ? "true" : undefined} onClick={() => onSelect(session)} onKeyDown={(event) => { @@ -225,7 +251,7 @@ export default function ThreadSidebarRow({ onSelect(session); } }} - className={`group flex w-full cursor-default items-center gap-2 rounded-md px-2 py-1.5 text-left text-[13px] transition-colors duration-150 outline-none focus-visible:ring-1 focus-visible:ring-primary/40 active:scale-[0.99] motion-reduce:active:scale-100 ${ + className={`group relative flex w-full cursor-default items-center gap-2 rounded-md px-2 py-1.5 text-left text-[13px] transition-colors duration-150 outline-none focus-visible:ring-1 focus-visible:ring-primary/40 active:scale-[0.99] motion-reduce:active:scale-100 ${ editing ? "bg-sidebar-row-active/40" : isSelected @@ -277,6 +303,16 @@ export default function ThreadSidebarRow({ ), )} + {shortcutBadge && ( + + {shortcutBadge} + + )} {pill && ( )} - {rightSlot} + + {rightSlot} + {hoverAction} )} @@ -300,7 +338,12 @@ export default function ThreadSidebarRow({ {session.isPinned ? "Unpin thread" : "Pin thread"} - setEditing(true)}> + { + setDraftTitle(session.title); + setEditing(true); + }} + > Rename thread @@ -325,7 +368,7 @@ export default function ThreadSidebarRow({ )} - {variant === "settled" ? ( + {isSettledRow ? ( onUnsettle(session)}> Un-settle thread @@ -338,11 +381,7 @@ export default function ThreadSidebarRow({ )} { - if (window.confirm(`Delete "${session.title || "Untitled session"}"? This cannot be undone.`)) { - onDelete(session); - } - }} + onClick={() => onRequestDelete(session)} className="text-red-600 focus:text-red-600 dark:text-red-400 dark:focus:text-red-400" > @@ -352,3 +391,5 @@ export default function ThreadSidebarRow({ ); } + +export default memo(ThreadSidebarRow); diff --git a/packages/ui/src/components/threads/threadSidebarLogic.test.ts b/packages/ui/src/components/threads/threadSidebarLogic.test.ts new file mode 100644 index 000000000..66f1fb0bd --- /dev/null +++ b/packages/ui/src/components/threads/threadSidebarLogic.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from "vitest"; +import type { UISession } from "#/stores/ui/session"; +import { + filterByTitle, + formatAge, + formatWakeCountdown, + formatWorkingElapsed, + highlightSegments, + partitionThreads, + resolveThreadPill, + resolveThreadStatus, + type ThreadSections, +} from "./threadSidebarLogic"; + +const NOW = 1_000_000_000; + +function makeSession(overrides: Partial = {}): UISession { + return { + id: "s1", + title: "Session", + agentId: "agent", + status: "completed", + projectDir: "/tmp/project", + isPinned: false, + isDraft: false, + sessionKind: "regular", + parentSessionId: null, + subagentEnabled: false, + subagentMeta: null, + createdAt: NOW - 60_000, + updatedAt: NOW - 30_000, + ...overrides, + }; +} + +const emptyHelpers = { + settledAtById: {} as Record, + snoozedUntilById: {} as Record, + now: NOW, +}; + +function ids(sections: ThreadSections): Record { + return { + pinned: sections.pinned.map((s) => s.id), + active: sections.active.map((s) => s.id), + snoozed: sections.snoozed.map((s) => s.id), + settled: sections.settled.map((s) => s.id), + }; +} + +describe("resolveThreadStatus", () => { + it("maps attention states ahead of activity", () => { + expect(resolveThreadStatus({ status: "blocked" })).toBe("approval"); + expect(resolveThreadStatus({ status: "error" })).toBe("failed"); + expect(resolveThreadStatus({ status: "working" })).toBe("working"); + expect(resolveThreadStatus({ status: "new_results" })).toBe("unseen"); + expect(resolveThreadStatus({ status: "completed" })).toBe("ready"); + expect(resolveThreadStatus({ status: "none" })).toBe("ready"); + }); + + it("maps statuses to pills; quiet statuses have no pill", () => { + expect(resolveThreadPill("approval")?.label).toBe("Pending approval"); + expect(resolveThreadPill("failed")?.label).toBe("Failed"); + expect(resolveThreadPill("working")?.label).toBe("Working"); + expect(resolveThreadPill("unseen")?.label).toBe("Completed"); + expect(resolveThreadPill("ready")).toBeNull(); + }); +}); + +describe("partitionThreads", () => { + it("hides drafts and non-regular sessions", () => { + const sections = partitionThreads( + [ + makeSession({ id: "draft", isDraft: true }), + makeSession({ id: "subagent", sessionKind: "subagent" }), + makeSession({ id: "regular" }), + ], + emptyHelpers, + ); + expect(ids(sections)).toEqual({ pinned: [], active: ["regular"], snoozed: [], settled: [] }); + }); + + it("pinned wins over settled; working sessions never render as settled", () => { + const sections = partitionThreads( + [ + makeSession({ id: "pinned-settled", isPinned: true }), + makeSession({ id: "settled-working", status: "working" }), + makeSession({ id: "settled" }), + ], + { + ...emptyHelpers, + settledAtById: { "pinned-settled": NOW - 1000, settled: NOW - 2000, "settled-working": NOW - 3000 }, + }, + ); + const sectionIds = ids(sections); + expect(sectionIds.pinned).toEqual(["pinned-settled"]); + expect(sectionIds.settled).toEqual(["settled"]); + expect(sectionIds.active).toEqual(["settled-working"]); + }); + + it("keeps the currently open session out of the snoozed shelf", () => { + const session = makeSession({ id: "open" }); + const helpers = { ...emptyHelpers, snoozedUntilById: { open: NOW + 60_000 } }; + expect(partitionThreads([session], helpers).snoozed.map((s) => s.id)).toEqual(["open"]); + expect(partitionThreads([session], { ...helpers, activeSessionId: "open" }).active.map((s) => s.id)).toEqual([ + "open", + ]); + }); + + it("sorts settled newest first; legacy settledAt=0 entries render as settled via the updatedAt fallback", () => { + const a = makeSession({ id: "a", createdAt: NOW - 3000, updatedAt: NOW - 3000 }); + const b = makeSession({ id: "b", createdAt: NOW - 1000, updatedAt: NOW - 1000 }); + const c = makeSession({ id: "c", createdAt: NOW - 9000, updatedAt: NOW - 9000 }); + const sections = partitionThreads([a, c, b], { + ...emptyHelpers, + settledAtById: { a: NOW - 1000, b: NOW - 4000, c: NOW - 2000 }, + }); + expect(sections.settled.map((s) => s.id)).toEqual(["a", "c", "b"]); + + // v1-migrated booleans map to 0 (unknown time): settled by key presence, + // ordered by their updatedAt fallback (matches the Row's settled + // rendering, which falls back to updatedAt too). + const legacy = makeSession({ id: "legacy", createdAt: NOW - 9000, updatedAt: NOW - 500 }); + const migrated = partitionThreads([legacy, a], { + ...emptyHelpers, + settledAtById: { legacy: 0, a: NOW - 1000 }, + }); + expect(migrated.active).toEqual([]); + expect(migrated.settled.map((s) => s.id)).toEqual(["legacy", "a"]); + + const snoozedFirst = makeSession({ id: "first", createdAt: NOW - 5000 }); + const snoozedLater = makeSession({ id: "later", createdAt: NOW - 1000 }); + const snoozed = partitionThreads([snoozedLater, snoozedFirst], { + ...emptyHelpers, + snoozedUntilById: { first: NOW + 1000, later: NOW + 60_000 }, + }); + expect(snoozed.snoozed.map((s) => s.id)).toEqual(["first", "later"]); + }); +}); + +describe("title filtering and highlighting", () => { + it("filterByTitle is case-insensitive and preserves order", () => { + const sessions = [makeSession({ id: "1", title: "Fix the bug" }), makeSession({ id: "2", title: "Add feature" })]; + expect(filterByTitle(sessions, " BUG ").map((s) => s.id)).toEqual(["1"]); + expect(filterByTitle(sessions, "")).toHaveLength(2); + }); + + it("highlightSegments marks matches case-insensitively and keeps gaps", () => { + expect(highlightSegments("Fix the Bug", "bug")).toEqual([ + { text: "Fix the ", match: false }, + { text: "Bug", match: true }, + ]); + expect(highlightSegments("abab", "ab")).toEqual([ + { text: "ab", match: true }, + { text: "ab", match: true }, + ]); + expect(highlightSegments("plain", "")).toEqual([{ text: "plain", match: false }]); + }); +}); + +describe("time formatting", () => { + it("formatAge buckets elapsed time", () => { + expect(formatAge(NOW - 500, NOW)).toBe("now"); + expect(formatAge(NOW - 5 * 60_000, NOW)).toBe("5m"); + expect(formatAge(NOW - 3 * 3_600_000, NOW)).toBe("3h"); + expect(formatAge(NOW - 2 * 86_400_000, NOW)).toBe("2d"); + expect(formatAge(NOW - 14 * 86_400_000, NOW)).toBe("2w"); + expect(formatAge(NOW - 90 * 86_400_000, NOW)).toBe("3mo"); + }); + + it("formatWorkingElapsed renders t3code-style durations", () => { + expect(formatWorkingElapsed(NOW - 45_000, NOW)).toBe("45s"); + expect(formatWorkingElapsed(NOW - 5 * 60_000, NOW)).toBe("5m"); + expect(formatWorkingElapsed(NOW - 2 * 3_600_000 - 7 * 60_000, NOW)).toBe("2h 7m"); + }); + + it("formatWakeCountdown counts down to the wake time", () => { + expect(formatWakeCountdown(NOW + 30_000, NOW)).toBe("in <1m"); + expect(formatWakeCountdown(NOW + 59 * 60_000, NOW)).toBe("in 59m"); + expect(formatWakeCountdown(NOW + 2 * 3_600_000 + 5 * 60_000, NOW)).toBe("in 2h 05m"); + }); +}); diff --git a/packages/ui/src/components/threads/threadSidebarLogic.ts b/packages/ui/src/components/threads/threadSidebarLogic.ts index b4e194559..08790eaa2 100644 --- a/packages/ui/src/components/threads/threadSidebarLogic.ts +++ b/packages/ui/src/components/threads/threadSidebarLogic.ts @@ -90,15 +90,21 @@ export interface PartitionHelpers { /** Absolute wake time per snoozed session id (ms). */ snoozedUntilById: Record; now: number; + /** + * The currently open session. A snoozed open session stays in Active so the + * conversation the user is looking at never disappears (t3 deviation). + */ + activeSessionId?: string | null; } /** * t3code partition semantics: pinned is an explicit section; active is the * default lifecycle state (everything not pinned/snoozed/settled); snoozed * hides threads until their wake time; settled sorts by settled time, newest - * first (unknown times fall back to updatedAt so migrated data keeps a stable - * order). Live sessions always participate in Active — a working thread can - * never render as Settled. + * first. A settled record is detected by key presence: legacy v1 entries + * carry timestamp 0 (unknown time) and still count as settled, ordered by + * their updatedAt fallback. Live sessions always participate in Active — a + * working thread can never render as Settled. */ export function partitionThreads(sessions: readonly UISession[], helpers: PartitionHelpers): ThreadSections { const visible = sessions.filter(isSidebarVisibleSession); @@ -106,12 +112,16 @@ export function partitionThreads(sessions: readonly UISession[], helpers: Partit for (const session of visible) { const snoozedUntil = helpers.snoozedUntilById[session.id]; - if (typeof snoozedUntil === "number" && snoozedUntil > helpers.now) { + const isSnoozed = typeof snoozedUntil === "number" && snoozedUntil > helpers.now; + if (isSnoozed && session.id !== helpers.activeSessionId) { sections.snoozed.push(session); continue; } const settledAt = helpers.settledAtById[session.id]; - const isSettled = typeof settledAt === "number" && settledAt > 0; + // Key presence, not > 0: legacy v1 records use 0 (unknown time) and must + // land in Settled with the updatedAt sort fallback, matching the Row's + // settled rendering (which also falls back to updatedAt). + const isSettled = typeof settledAt === "number"; if (session.isPinned) { sections.pinned.push(session); } else if (isSettled && session.status !== "working") { @@ -165,10 +175,6 @@ export function highlightSegments(title: string, query: string): TitleSegment[] return segments.filter((segment) => segment.text.length > 0); } -export function matchesTitle(session: Pick, query: string): boolean { - return session.title.toLowerCase().includes(query.trim().toLowerCase()); -} - /** Case-insensitive title filter that preserves the incoming (section) order. */ export function filterByTitle(sessions: readonly UISession[], query: string): UISession[] { const normalized = query.trim().toLowerCase(); diff --git a/packages/ui/src/stores/ui/threadSidebar.ts b/packages/ui/src/stores/ui/threadSidebar.ts index 9c0819981..5788b62d8 100644 --- a/packages/ui/src/stores/ui/threadSidebar.ts +++ b/packages/ui/src/stores/ui/threadSidebar.ts @@ -2,10 +2,19 @@ import { Store } from "@tanstack/store"; import { useSelector } from "@tanstack/react-store"; import { createConfigClient } from "../../../api/ConfigClient"; import { sessionStore, type UISession } from "./session"; +import { + diffWorkingTransitions, + omitKey, + parseSettledRecord, + pruneLifecycleEntries, + type SettledAtMap, + type SnoozedUntilMap, +} from "./threadSidebarState"; /** * Thread sidebar experiment state (v2, t3code parity — - * docs/features/thread-sidebar-t3-parity). + * docs/features/thread-sidebar-t3-parity; fixes in docs/issues/thread-sidebar-fixes, + * polish in docs/features/thread-sidebar-polish). * * When enabled, the main left sidebar renders a t3code-style thread * lifecycle view (Pinned / Active / Snoozed / Settled) instead of the @@ -21,17 +30,25 @@ import { sessionStore, type UISession } from "./session"; * so rows can show a "Woke" pill until the thread is opened. * - `workingSinceById`: persisted so the live "Working Ns" pill survives * restarts instead of resetting to 0s. - * - `settledShelfExpanded`: settled shelf collapse state (t3code parity). + * - `settledShelfExpanded` / `snoozedShelfExpanded`: shelf collapse states. + * + * Pure pieces of this module (storage parsing, working-since diffing, + * pruning) live in `threadSidebarState.ts` for direct unit testing. */ const THREAD_SIDEBAR_ENABLED_KEY = "thread_sidebar_enabled"; const SETTLED_STORAGE_KEY = "argos:thread-sidebar:settled"; const SNOOZED_STORAGE_KEY = "argos:thread-sidebar:snoozed"; const SETTLED_SHELF_EXPANDED_KEY = "argos:thread-sidebar:settled-expanded"; +const SNOOZED_SHELF_EXPANDED_KEY = "argos:thread-sidebar:snoozed-expanded"; const WORKING_SINCE_STORAGE_KEY = "argos:thread-sidebar:working-since"; -type SettledAtMap = Record; -type SnoozedUntilMap = Record; +const LIFECYCLE_STORAGE_KEYS = new Set([ + SETTLED_STORAGE_KEY, + SNOOZED_STORAGE_KEY, + SETTLED_SHELF_EXPANDED_KEY, + SNOOZED_SHELF_EXPANDED_KEY, +]); function readJson(key: string): T | null { if (typeof window === "undefined") return null; @@ -53,27 +70,8 @@ function writeJson(key: string, value: unknown): void { } } -/** v2: { v: 2, byId: { id: settledAtMs } }. v1 booleans migrate to 0 (unknown). */ function loadSettledFromStorage(): SettledAtMap { - const raw = readJson(SETTLED_STORAGE_KEY); - const next: SettledAtMap = {}; - if (!raw || typeof raw !== "object" || Array.isArray(raw)) return next; - const record = raw as Record; - if (record.v === 2 && record.byId && typeof record.byId === "object") { - for (const [id, at] of Object.entries(record.byId as Record)) { - if (typeof at === "number" && at >= 0) next[id] = at; - } - return next; - } - // v1: { id: true } - for (const [id, value] of Object.entries(record)) { - if (value === true) next[id] = 0; - } - return next; -} - -function persistSettled(settledAtById: SettledAtMap): void { - writeJson(SETTLED_STORAGE_KEY, { v: 2, byId: settledAtById }); + return parseSettledRecord(readJson(SETTLED_STORAGE_KEY)); } function loadSnoozedFromStorage(): SnoozedUntilMap { @@ -86,38 +84,44 @@ function loadSnoozedFromStorage(): SnoozedUntilMap { return next; } -function loadSettledShelfExpanded(): boolean { - const raw = readJson(SETTLED_SHELF_EXPANDED_KEY); - // t3code defaults the settled shelf to expanded. +function loadShelfExpanded(key: string): boolean { + const raw = readJson(key); + // t3code defaults both shelves to expanded. return typeof raw === "boolean" ? raw : true; } -function loadWorkingSinceFromStorage(): Record { - const raw = readJson>(WORKING_SINCE_STORAGE_KEY); - const next: Record = {}; - if (!raw) return next; - for (const [id, since] of Object.entries(raw)) { - if (typeof since === "number" && since > 0) next[id] = since; - } - return next; +function persistWorkingSince(workingSinceById: Record): void { + writeJson(WORKING_SINCE_STORAGE_KEY, workingSinceById); } export const threadSidebarStore = new Store<{ enabled: boolean; enabledLoaded: boolean; workingSinceById: Record; - tick: number; settledAtById: SettledAtMap; snoozedUntilById: SnoozedUntilMap; settledShelfExpanded: boolean; + snoozedShelfExpanded: boolean; }>({ enabled: false, enabledLoaded: false, - workingSinceById: loadWorkingSinceFromStorage(), - tick: 0, + workingSinceById: (() => { + // Drop persisted entries for sessions that are no longer working at load + // time. Sessions usually load after this module initializes, so entries + // for still-working sessions are kept here and re-validated by the + // first-observation diff below once the session list arrives. + const persisted = readJson>(WORKING_SINCE_STORAGE_KEY); + const next: Record = {}; + if (!persisted) return next; + for (const [id, since] of Object.entries(persisted)) { + if (typeof since === "number" && since > 0) next[id] = since; + } + return next; + })(), settledAtById: loadSettledFromStorage(), snoozedUntilById: loadSnoozedFromStorage(), - settledShelfExpanded: loadSettledShelfExpanded(), + settledShelfExpanded: loadShelfExpanded(SETTLED_SHELF_EXPANDED_KEY), + snoozedShelfExpanded: loadShelfExpanded(SNOOZED_SHELF_EXPANDED_KEY), }); const configClient = createConfigClient(); @@ -153,68 +157,65 @@ export async function setThreadSidebarEnabled(enabled: boolean): Promise { } } -const WORKING_STATUS = "working" as const; - -function persistWorkingSince(workingSinceById: Record): void { - writeJson(WORKING_SINCE_STORAGE_KEY, workingSinceById); -} - -function recordWorkingTransition(current: UISession[], previous: UISession[]): boolean { - if (current === previous) return false; - const prevById = new Map(previous.map((s) => [s.id, s.status])); - const next: Record = { ...threadSidebarStore.state.workingSinceById }; - let changed = false; - const now = Date.now(); - for (const session of current) { - const previousStatus = prevById.get(session.id); - if (session.status === WORKING_STATUS && previousStatus !== WORKING_STATUS) { - next[session.id] = now; - changed = true; - } else if (session.status !== WORKING_STATUS && previousStatus === WORKING_STATUS) { - delete next[session.id]; - changed = true; - } - } - if (!changed) return false; - threadSidebarStore.setState((prev) => ({ ...prev, workingSinceById: next })); - persistWorkingSince(next); - return true; -} - if (typeof window !== "undefined") { - // Seed working-since on first import: prefer the persisted value (survives - // restarts), fall back to the session's updatedAt — better than resetting - // the pill to "0s" for a turn that has been running for minutes. - const persisted = threadSidebarStore.state.workingSinceById; - const seed: Record = { ...persisted }; - const sessionsById = new Map(sessionStore.state.sessions.map((s) => [s.id, s])); - for (const id of Object.keys(seed)) { - const session = sessionsById.get(id); - if (session && session.status === WORKING_STATUS) continue; - // Drop entries for sessions that are no longer working. - delete seed[id]; - } - for (const session of sessionStore.state.sessions) { - if (session.status === WORKING_STATUS && seed[session.id] === undefined) { - seed[session.id] = session.updatedAt || Date.now(); - } - } - threadSidebarStore.setState((prev) => ({ ...prev, workingSinceById: seed })); - persistWorkingSince(seed); - - // Reflect subsequent status flips. TanStack Store's `subscribe(fn)` only - // receives the new state, so we keep a closure reference to the previous - // `sessions` array to diff status transitions. + // Reflect session-status flips into workingSinceById. TanStack Store's + // `subscribe(fn)` only receives the new state, so we keep a closure + // reference to the previous `sessions` array to diff transitions. The diff + // is first-observation-aware: the first loaded batch keeps persisted + // working-since values instead of resetting them to `now` (restart + // survival — see diffWorkingTransitions). let previousSessions: UISession[] = sessionStore.state.sessions; + let lifecycleSwept = false; sessionStore.subscribe((state) => { - recordWorkingTransition(state.sessions, previousSessions); + const working = diffWorkingTransitions( + state.sessions, + previousSessions, + threadSidebarStore.state.workingSinceById, + Date.now(), + ); previousSessions = state.sessions; + if (working.changed) { + threadSidebarStore.setState((prev) => ({ ...prev, workingSinceById: working.next })); + persistWorkingSince(working.next); + } + // One-time sweep once the whole history is loaded: drop lifecycle + // entries for sessions that no longer exist. Gate on hasLoadedInitialPage + // (hasMore starts false and upserts can land before the initial page) and + // never sweep while pages remain unloaded — with paging, an absent id is + // not proof of deletion. + if (!lifecycleSwept && state.hasLoadedInitialPage && state.sessions.length > 0 && !state.hasMore) { + lifecycleSwept = true; + const knownIds = new Set(state.sessions.map((session) => session.id)); + const pruned = pruneLifecycleEntries( + { + settledAtById: threadSidebarStore.state.settledAtById, + snoozedUntilById: threadSidebarStore.state.snoozedUntilById, + workingSinceById: working.changed ? working.next : threadSidebarStore.state.workingSinceById, + }, + knownIds, + ); + if (pruned.changed) { + threadSidebarStore.setState((prev) => ({ ...prev, ...pruned.next })); + writeJson(SETTLED_STORAGE_KEY, { v: 2, byId: pruned.next.settledAtById }); + writeJson(SNOOZED_STORAGE_KEY, pruned.next.snoozedUntilById); + persistWorkingSince(pruned.next.workingSinceById); + } + } }); -} -/** Bump the tick to force a re-render of live pills (working elapsed, wake countdowns). */ -export function bumpThreadSidebarTick(): void { - threadSidebarStore.setState((prev) => ({ ...prev, tick: (prev.tick + 1) % 1_000_000 })); + // Cross-window sync: `storage` events fire only in *other* windows, so the + // writing window never loops. workingSinceById is intentionally not synced + // (per-window timing would fight the transition diff above). + window.addEventListener("storage", (event) => { + if (event.key !== null && !LIFECYCLE_STORAGE_KEYS.has(event.key)) return; + threadSidebarStore.setState((prev) => ({ + ...prev, + settledAtById: loadSettledFromStorage(), + snoozedUntilById: loadSnoozedFromStorage(), + settledShelfExpanded: loadShelfExpanded(SETTLED_SHELF_EXPANDED_KEY), + snoozedShelfExpanded: loadShelfExpanded(SNOOZED_SHELF_EXPANDED_KEY), + })); + }); } // --- Settle (t3code: explicit lifecycle action; settles sort by settledAt) --- @@ -223,15 +224,37 @@ export function settleSession(id: string): void { const at = Date.now(); const next: SettledAtMap = { ...threadSidebarStore.state.settledAtById, [id]: at }; threadSidebarStore.setState((prev) => ({ ...prev, settledAtById: next })); - persistSettled(next); + writeJson(SETTLED_STORAGE_KEY, { v: 2, byId: next }); } export function unsettleSession(id: string): void { if (!(id in threadSidebarStore.state.settledAtById)) return; - const next: SettledAtMap = { ...threadSidebarStore.state.settledAtById }; - delete next[id]; + const next: SettledAtMap = omitKey(threadSidebarStore.state.settledAtById, id); threadSidebarStore.setState((prev) => ({ ...prev, settledAtById: next })); - persistSettled(next); + writeJson(SETTLED_STORAGE_KEY, { v: 2, byId: next }); +} + +/** + * Lifecycle cleanup for a deleted session: remove its settled/snoozed/ + * working-since entries from state and storage. Called from every delete + * path (experiment rows and the original sidebar's delete dialog). + */ +export function notifySessionDeleted(id: string): void { + const prev = threadSidebarStore.state; + const settledAtById = omitKey(prev.settledAtById, id); + const snoozedUntilById = omitKey(prev.snoozedUntilById, id); + const workingSinceById = omitKey(prev.workingSinceById, id); + if ( + settledAtById === prev.settledAtById && + snoozedUntilById === prev.snoozedUntilById && + workingSinceById === prev.workingSinceById + ) { + return; + } + threadSidebarStore.setState((state) => ({ ...state, settledAtById, snoozedUntilById, workingSinceById })); + writeJson(SETTLED_STORAGE_KEY, { v: 2, byId: settledAtById }); + writeJson(SNOOZED_STORAGE_KEY, snoozedUntilById); + persistWorkingSince(workingSinceById); } /** Settled at ms (0 = legacy entry with unknown time), or undefined when not settled. */ @@ -258,8 +281,7 @@ export function snoozeSession(id: string, durationMs: number): void { export function unsnoozeSession(id: string): void { if (!(id in threadSidebarStore.state.snoozedUntilById)) return; - const next: SnoozedUntilMap = { ...threadSidebarStore.state.snoozedUntilById }; - delete next[id]; + const next: SnoozedUntilMap = omitKey(threadSidebarStore.state.snoozedUntilById, id); threadSidebarStore.setState((prev) => ({ ...prev, snoozedUntilById: next })); writeJson(SNOOZED_STORAGE_KEY, next); } @@ -278,13 +300,18 @@ export function markThreadOpened(id: string): void { unsnoozeSession(id); } -// --- Settled shelf collapse state --- +// --- Shelf collapse states --- export function setSettledShelfExpanded(expanded: boolean): void { threadSidebarStore.setState((prev) => ({ ...prev, settledShelfExpanded: expanded })); writeJson(SETTLED_SHELF_EXPANDED_KEY, expanded); } +export function setSnoozedShelfExpanded(expanded: boolean): void { + threadSidebarStore.setState((prev) => ({ ...prev, snoozedShelfExpanded: expanded })); + writeJson(SNOOZED_SHELF_EXPANDED_KEY, expanded); +} + export function useThreadSidebarStore() { const state = useSelector(threadSidebarStore); return { diff --git a/packages/ui/src/stores/ui/threadSidebarState.test.ts b/packages/ui/src/stores/ui/threadSidebarState.test.ts new file mode 100644 index 000000000..790cdf573 --- /dev/null +++ b/packages/ui/src/stores/ui/threadSidebarState.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; +import { diffWorkingTransitions, omitKey, parseSettledRecord, pruneLifecycleEntries } from "./threadSidebarState"; + +const NOW = 1_000_000_000; + +function session(id: string, status: "working" | "completed", updatedAt = NOW - 1000) { + return { id, status, updatedAt } as const; +} + +describe("parseSettledRecord", () => { + it("parses v2 records and rejects invalid entries", () => { + expect(parseSettledRecord({ v: 2, byId: { a: 123, b: -5, c: "x" } })).toEqual({ a: 123 }); + }); + + it("migrates v1 boolean records to timestamp 0", () => { + expect(parseSettledRecord({ a: true, b: false })).toEqual({ a: 0 }); + }); + + it("tolerates garbage input", () => { + expect(parseSettledRecord(null)).toEqual({}); + expect(parseSettledRecord("nope")).toEqual({}); + expect(parseSettledRecord([1, 2])).toEqual({}); + expect(parseSettledRecord({ v: 2, byId: [1] })).toEqual({}); + }); +}); + +describe("diffWorkingTransitions", () => { + it("keeps persisted elapsed time on first observation of a working session (restart survival)", () => { + const result = diffWorkingTransitions([session("a", "working")], [], { a: NOW - 60_000 }, NOW); + expect(result.changed).toBe(false); + expect(result.next).toEqual({ a: NOW - 60_000 }); + }); + + it("seeds from updatedAt on first observation without a persisted value", () => { + const result = diffWorkingTransitions([session("a", "working", NOW - 30_000)], [], {}, NOW); + expect(result.changed).toBe(true); + expect(result.next).toEqual({ a: NOW - 30_000 }); + }); + + it("stamps now for a known session transitioning into working", () => { + const result = diffWorkingTransitions([session("a", "working")], [session("a", "completed")], {}, NOW); + expect(result.changed).toBe(true); + expect(result.next).toEqual({ a: NOW }); + }); + + it("clears entries when a session leaves working", () => { + const result = diffWorkingTransitions([session("a", "completed")], [session("a", "working")], { a: NOW - 5 }, NOW); + expect(result.changed).toBe(true); + expect(result.next).toEqual({}); + }); + + it("only manages ids present in the current batch (unknown ids are the sweep's job)", () => { + const result = diffWorkingTransitions([session("a", "completed")], [], { a: NOW - 5, ghost: NOW - 9 }, NOW); + expect(result.changed).toBe(true); + expect(result.next).toEqual({ ghost: NOW - 9 }); + }); + + it("leaves working-to-working sessions untouched", () => { + const result = diffWorkingTransitions([session("a", "working")], [session("a", "working")], { a: 42 }, NOW); + expect(result.changed).toBe(false); + expect(result.next).toEqual({ a: 42 }); + }); +}); + +describe("pruneLifecycleEntries", () => { + it("removes entries for unknown ids across all maps and reports change", () => { + const result = pruneLifecycleEntries( + { + settledAtById: { kept: 1, gone: 2 }, + snoozedUntilById: { gone: 3 }, + workingSinceById: { kept: 4 }, + }, + new Set(["kept"]), + ); + expect(result.changed).toBe(true); + expect(result.next).toEqual({ + settledAtById: { kept: 1 }, + snoozedUntilById: {}, + workingSinceById: { kept: 4 }, + }); + }); + + it("returns the same references when nothing is prunable", () => { + const maps = { + settledAtById: { kept: 1 }, + snoozedUntilById: {}, + workingSinceById: {}, + }; + const result = pruneLifecycleEntries(maps, new Set(["kept"])); + expect(result.changed).toBe(false); + expect(result.next.settledAtById).toBe(maps.settledAtById); + }); +}); + +describe("omitKey", () => { + it("omits the key or returns the original reference when absent", () => { + const map = { a: 1, b: 2 }; + expect(omitKey(map, "a")).toEqual({ b: 2 }); + expect(omitKey(map, "missing")).toBe(map); + }); +}); diff --git a/packages/ui/src/stores/ui/threadSidebarState.ts b/packages/ui/src/stores/ui/threadSidebarState.ts new file mode 100644 index 000000000..81d02400a --- /dev/null +++ b/packages/ui/src/stores/ui/threadSidebarState.ts @@ -0,0 +1,133 @@ +import type { UISession } from "./session"; + +/** + * Pure thread-sidebar lifecycle-state helpers (no React, no store, no side + * effects) — the testable core behind `stores/ui/threadSidebar.ts`: + * settled-storage parsing (v1→v2), working-since transition diffing, and + * lifecycle-map pruning. View logic lives in + * `components/threads/threadSidebarLogic.ts`. + */ + +/** Timestamp map keyed by session id (ms). */ +export type TimestampMap = Record; +export type SettledAtMap = TimestampMap; +export type SnoozedUntilMap = TimestampMap; + +export const WORKING_STATUS = "working" as const; + +/** + * Parse the persisted settled record. v2: `{ v: 2, byId: { id: settledAtMs } }`. + * v1: `{ id: true }` booleans migrate to 0 (unknown settled time, sorted by + * updatedAt downstream). + */ +export function parseSettledRecord(raw: unknown): SettledAtMap { + const next: SettledAtMap = {}; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return next; + const record = raw as Record; + if (record.v === 2 && record.byId && typeof record.byId === "object" && !Array.isArray(record.byId)) { + for (const [id, at] of Object.entries(record.byId as Record)) { + if (typeof at === "number" && at >= 0) next[id] = at; + } + return next; + } + // v1: { id: true } + for (const [id, value] of Object.entries(record)) { + if (value === true) next[id] = 0; + } + return next; +} + +export interface WorkingTransitionResult { + next: TimestampMap; + changed: boolean; +} + +/** + * Diff session-status transitions into the persisted working-since map. + * + * Semantics: + * - First observation of a session (id absent from `previous`): a working + * session keeps its persisted elapsed time (restart survival) or seeds from + * `updatedAt`; a non-working session drops any stale persisted entry. This + * is what makes the "Working Ns" pill survive restarts — sessions load + * asynchronously after this module initializes, so the first batch must not + * stamp `now` over persisted values. + * - Known session transitioning into `working`: fresh `now` stamp. + * - Known session leaving `working`: entry removed. + */ +export function diffWorkingTransitions( + current: readonly Pick[], + previous: readonly Pick[], + existing: TimestampMap, + now: number, +): WorkingTransitionResult { + const prevStatusById = new Map(previous.map((session) => [session.id, session.status])); + const next: TimestampMap = { ...existing }; + let changed = false; + for (const session of current) { + const previousStatus = prevStatusById.get(session.id); + if (session.status === WORKING_STATUS && previousStatus !== WORKING_STATUS) { + if (previousStatus === undefined) { + const persisted = next[session.id]; + if (typeof persisted !== "number" || persisted <= 0) { + next[session.id] = session.updatedAt > 0 ? session.updatedAt : now; + changed = true; + } + } else { + next[session.id] = now; + changed = true; + } + } else if (session.status !== WORKING_STATUS && previousStatus === WORKING_STATUS) { + delete next[session.id]; + changed = true; + } else if (previousStatus === undefined && session.status !== WORKING_STATUS && session.id in next) { + delete next[session.id]; + changed = true; + } + } + return { next, changed }; +} + +export interface ThreadLifecycleMaps { + settledAtById: SettledAtMap; + snoozedUntilById: SnoozedUntilMap; + workingSinceById: TimestampMap; +} + +/** Copy of `map` without `id`; same reference when the id is absent. */ +export function omitKey(map: TimestampMap, id: string): TimestampMap { + if (!(id in map)) return map; + const next = { ...map }; + delete next[id]; + return next; +} + +export interface PruneResult { + next: ThreadLifecycleMaps; + changed: boolean; +} + +/** + * Drop lifecycle entries for session ids that are no longer known. Only call + * with the *complete* id set (never a partial page) — an absent id is only + * proof of deletion once the whole history is loaded. + */ +export function pruneLifecycleEntries(maps: ThreadLifecycleMaps, knownIds: ReadonlySet): PruneResult { + const prune = (map: TimestampMap): TimestampMap => { + let pruned: TimestampMap | null = null; + for (const id of Object.keys(map)) { + if (knownIds.has(id)) continue; + if (!pruned) pruned = { ...map }; + delete pruned[id]; + } + return pruned ?? map; + }; + const settledAtById = prune(maps.settledAtById); + const snoozedUntilById = prune(maps.snoozedUntilById); + const workingSinceById = prune(maps.workingSinceById); + const changed = + settledAtById !== maps.settledAtById || + snoozedUntilById !== maps.snoozedUntilById || + workingSinceById !== maps.workingSinceById; + return { next: { settledAtById, snoozedUntilById, workingSinceById }, changed }; +}