Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions apps/daemon/src/host/toolchains/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,13 @@ export async function loadState(dataDir: string): Promise<ToolchainStateFile> {
}
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
Expand Down
3 changes: 2 additions & 1 deletion apps/daemon/test/toolchainsService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
44 changes: 44 additions & 0 deletions docs/features/thread-sidebar-polish/plan.md
Original file line number Diff line number Diff line change
@@ -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).
100 changes: 100 additions & 0 deletions docs/features/thread-sidebar-polish/spec.md
Original file line number Diff line number Diff line change
@@ -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 '<query>'" (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.
9 changes: 9 additions & 0 deletions docs/features/thread-sidebar-polish/tasks.md
Original file line number Diff line number Diff line change
@@ -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.
38 changes: 38 additions & 0 deletions docs/issues/thread-sidebar-fixes/plan.md
Original file line number Diff line number Diff line change
@@ -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).
84 changes: 84 additions & 0 deletions docs/issues/thread-sidebar-fixes/spec.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions docs/issues/thread-sidebar-fixes/tasks.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion packages/ui/settings/components/DisplaySettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,8 @@ export default function DisplaySettings() {
</div>
</div>
<div className="text-xs text-muted-foreground">
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
</div>
</div>
</div>
Expand Down
48 changes: 48 additions & 0 deletions packages/ui/src/components/DeleteConversationDialog.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Dialog
open={open}
onOpenChange={(value) => {
if (!value) onCancel();
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Conversation</DialogTitle>
<DialogDescription>
Are you sure you want to delete this conversation? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={onCancel}>
Cancel
</Button>
<Button variant="destructive" onClick={onConfirm}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
Loading
Loading