From 54460922d8dd58dc284125bf18a81a7f412c388e Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 01:48:48 -0700 Subject: [PATCH 01/30] docs: add implementation plan for deck-tile-redesign --- docs/plans/2026-07-29-deck-tile-redesign.md | 1614 +++++++++++++++++++ 1 file changed, 1614 insertions(+) create mode 100644 docs/plans/2026-07-29-deck-tile-redesign.md diff --git a/docs/plans/2026-07-29-deck-tile-redesign.md b/docs/plans/2026-07-29-deck-tile-redesign.md new file mode 100644 index 000000000..7eba0780f --- /dev/null +++ b/docs/plans/2026-07-29-deck-tile-redesign.md @@ -0,0 +1,1614 @@ +# Deck Tile Redesign Implementation Plan + +> **For agentic workers:** This plan is executed task-by-task by the +> workflow's execute stage: a fresh implementer per task, with a spec + +> quality review after each task. Steps use checkbox (`- [ ]`) syntax +> for tracking. + +**Goal:** Replace the Stream Deck key tiles' terminal-preview + status-ring design with a tab-bar-matching design (title on top, centered repo icons, status-driven background fill, white active ring) and sort deck keys by status priority. + +**Architecture:** Client-only change confined to `src/deck/` plus two touch points (`src/components/TerminalView.tsx` hook removal, `src/components/VirtualDeckPanel.tsx` ctx/renderer wiring). Tile state (fill / dot / sort priority) is derived from the *same* underlying conditions the tab bar uses (`getBusyPaneIdsForTab`, `state.turnCompletion.attentionByTab`, per-pane `status === 'running'`), repo icons reuse the tab bar's resolution pipeline (`resolvePaneRepoCwd` → `state.repoIcons.byCwd` → `buildRepoIconUrl` / letter-avatar fallback). A new singleton `IconImageCache` loads repo-icon bitmaps asynchronously for canvas drawing with letter-avatar fallback. The virtual deck panel shares the same renderer and controller, so it updates automatically. + +**Tech Stack:** React/TypeScript, Redux Toolkit, canvas 2D (via the existing injectable `CtxFactory` seam), Vitest (jsdom, no real canvas — drawing-call spies and spec-encoding renderers). + +## Global Constraints + +- Work in the worktree `.worktrees/deck-tile-redesign`, branch from `origin/main`, PR targets `main`; do **not** create/open a PR without explicit user approval (stop before `gh pr create`). +- NEVER restart the live Rust server on port 3002; no broad kill patterns. This change is client-only TypeScript — no server changes, no deploy needed during development. +- Red-Green-Refactor TDD for every task; unit **and** e2e coverage. +- Focused test runs: `npm run test:vitest -- run --config config/vitest/vitest.config.ts` (there is no root vitest config — `--config` is mandatory). Broad runs go through the coordinator gate (`npm test` / `npm run check`); check `npm run test:status` first and never kill a foreign holder. +- Typecheck gate: `npm run typecheck:client`. Lint gate (incl. jsx-a11y): `npm run lint`. +- Commits: Conventional Commits with `(deck)` scope, lowercase imperative subject, one commit per task slice. +- `console.error` is fatal in tests (`test/setup/dom.ts` throws in `afterEach`) — no code path may log errors under test. +- Path aliases: `@/` → `src/`, `@test/` → `test/`. +- The tab bar itself (`TabBar.tsx`, `TabItem.tsx`) must be visually and behaviorally unchanged — this plan only *reads* its helper libs; the only file outside `src/deck/` whose behavior changes is `TerminalView.tsx` (preview-registration hook removal, invisible to users). +- Do NOT touch `/api/panes/:id/capture` or `server/agent-api/capture.ts` — that endpoint is used by `server/cli/index.ts:664` and `server/mcp/freshell-tool.ts:808` and is unrelated to the deck preview machinery (which reads xterm buffers). + +## Investigation results this plan is built on (verified 2026-07-29) + +These facts were verified by direct code reading; task steps cite them. Implementers can trust them but should re-verify line numbers before editing (the worktree may drift). + +**Tab bar state conditions** (`src/components/TabItem.tsx:158-184`, `src/components/TabBar.tsx:329-338`): +- `needsAttention` = `!!state.turnCompletion.attentionByTab[tab.id]`; `busyPaneIds` = `getBusyPaneIdsForTab(...)` (`src/lib/pane-activity.ts:263-309`); setting `tabAttentionStyle: 'highlight'|'darken'|'pulse'|'none'` (default `'highlight'`) read from `state.settings.settings.panes.tabAttentionStyle`. +- **Bar-on-top** ⟺ `isActive && needsAttention && tabAttentionStyle !== 'none'` → 3px `border-t-success` (= `hsl(142 71% 45%)` ≈ `#21c45d`) + `bg-success/15` wash. +- **Green filled** ⟺ `!isActive && needsAttention && tabAttentionStyle !== 'none'` → `bg-emerald-100` (`#d1fae5`) light / `dark:bg-emerald-900/40` dark. +- **Green icon** ⟺ pane not in `busyPaneIds` AND effective status `'running'` (`TabItem.tsx:135-147`; non-terminal pane kinds are hard-coded `'running'` at `TabItem.tsx:136`) → `text-success` `#21c45d`. +- **Blue icon** ⟺ `busyPaneIds.includes(paneId)` → `text-blue-500` `#3b82f6`. +- Repo icons are **never tinted** in the tab bar (`TabItem.tsx:133` passes no color class); pane icons are tinted via `currentColor`. No CSS filters anywhere. + +**Repo icon pipeline**: per-pane cwd via `resolvePaneRepoCwd(content, tab, state.terminalMeta.byTerminalId)` (`src/lib/repo-icon.ts:13-27`); probed meta cached at `state.repoIcons.byCwd` as `RepoIconEntry { status: 'loading'|'ready'|'error'; repoRoot?; checkoutRoot?; repoName?; hasIcon? }` (`src/store/repoIconsSlice.ts:5-11`); real icon = `` (`/api/repo-icon?cwd=…`), fallback = letter avatar with `hsl(hueFromString(repoName), 60%, 42%)` circle + white letter (`src/components/icons/RepoIcon.tsx:33-65`; `hueFromString` exported at `:19`). Distinct repo icons cap at 3 (`MAX_REPO_ICONS = 3`, `TabItem.tsx:35`), silently truncated. `TabBar.tsx` (~`:229-242`) dispatches `fetchRepoIconMeta(cwd)` probes for all visible tabs' panes — the tab bar is always mounted in the app shell, so the deck can read `state.repoIcons.byCwd` without probing. + +**Deck internals** (`src/deck/`): `KeySpec` in `frame.ts:6-10` (tab variant: `{ kind:'tab'; tabId; title; previewLines; ring; active }`); `renderKey(spec, caps, createCtx)` with narrow `Ctx2D = Pick + {fillStyle,font,textBaseline}` (`tile-renderer.ts:8-13`) — **no `drawImage`**; per-key paint cache is `JSON.stringify(spec)` (`deck-controller.ts:126`) so *anything a tile draws must be a KeySpec field*; controller repaint bail-out is `JSON.stringify(selectDeckModel(state))` (`deck-controller.ts:164-176`); preview machinery is `terminal-text-registry.ts` (xterm buffer readers) with sole producer `TerminalView.tsx:103,676-677` and sole consumer `DeckController.previewFor` (`deck-controller.ts:151-160,24`) — **nothing else uses it**; `keyDown` stores only a timestamp (`deck-controller.ts:185-188`) and `handleKeyUp` re-resolves slot→tab from live state at release (`:204-234`) — **no press snapshot exists today**; `selectDeckModel` maps `state.tabs.tabs` verbatim (no sort anywhere); `buildFrame` (`frame.ts:87-113`) assigns keys via `planLayout` + `visibleTabs`; pager = last key when `tabCount > keyCount`, Deck+ pages via dial 1; `VirtualDeckPanel.tsx` uses the same `renderKey` + a real `DeckController` over `FakeDeckDevice` (`:11,81-88`), with `noopCtx`/`safeCtxFactory` (`:21-38`). + +**Test landscape**: unit tests in `test/unit/client/deck/`, e2e (fake transport, Vitest not Playwright) in `test/e2e/stream-deck-flow.test.tsx`; renderer tests use a `recordingCtx()` drawing-call spy; controller/e2e tests use spec-encoding renderers (`encodeSpec`/`decodeKey` — pixels are KeySpec JSON); jsdom canvas `getContext` is stubbed to `null`; **no image-loading mock exists** (jsdom `Image` never fires `onload` — tests must inject a fake loader); `makeDeckStore(opts)` fixture builder is deliberately duplicated in `deck-controller.test.ts`, `stream-deck-flow.test.tsx`, `VirtualDeckPanel.test.tsx`. + +## Design decisions (settled — carry through all tasks) + +1. **Sort lives in `selectDeckModel`.** The spec says short-press, long-press, dials, paging all "operate on the sorted order" — sorting the model gives that everywhere for free (dial-0 tab cycling included), and the model-JSON bail-out repaints automatically on re-sorts. Sort is stable (`Array.prototype.sort` is spec-stable): priority ascending, tab-bar order preserved within groups. +2. **Priority buckets** (0 = leftmost keys): 0 bar-on-top (`attention && active`), 1 green-filled (`attention && !active`), 2 green-icon (not busy, has a running pane), 3 blue-icon (any busy pane), 4 rest. A tab with both busy and green panes classifies **blue-icon** (busy dominates: "still working"). Attention is gated on `tabAttentionStyle !== 'none'` (mirroring the tab bar: with `'none'` the bar/fill states don't exist). With style `'darken'` the tab bar shows a darkened treatment instead of green; the deck keeps its single green palette (the *condition* is shared; the deck has one fixed skin). +3. **Green-icon condition is the tab bar's literal condition** (`status === 'running'` and not busy, non-terminal panes always `'running'`). Consequence: most healthy idle tabs are bucket 2 and bucket 4 holds only tabs whose panes are all exited/error/creating (or tabs with no panes). This is faithful to the tab bar's own coloring — do not "improve" it. +4. **Status dot instead of tinted icons.** The tab bar never tints *repo* icons — green/blue tinting applies to *pane* icons. The deck centers repo icons (per spec), so the green-icon/blue-icon states are made visible with a small status dot (bottom-center of the tile) using the exact tab-bar tint colors (`#21c45d` / `#3b82f6`) and the exact same conditions. This mirrors the tab bar's own `StatusDot` fallback vocabulary (`fill-success` / `fill-blue-500`). +5. **Backgrounds:** `none` → `#0a0a0a` (existing near-black); `green` (green-filled state) → solid light green `#a7f3d0` (emerald-200 — recognizably the tab bar's emerald attention fill, tuned for the small LCD); `barTop` (bar-on-top state) → same light green fill **plus** a 3px `#21c45d` border ring (the tab bar's `--success` bar color). Active tab keeps a white ring: 3px at inset 0 normally, 2px at inset 3 when the barTop border occupies inset 0 (matching today's status+active ring nesting). +6. **Status rings are removed entirely**, including the amber pending-approval ring — the spec replaces rings with the three-state background and doesn't map amber. Pending approval still works via the long-press action layer (`findApproveTarget` untouched). +7. **Repo icons on tiles ignore `settings.panes.repoIconsOnTabs`** (a tab-bar clutter preference; the deck tile needs its center glyph) and derive from **all** panes in the tab (distinct repos, first-appearance order, cap 3) — the tab bar additionally only considers the first 3 pane icons when picking repo groups; the deck follows the headline "cap repo icons at 3" rule. Resolution logic (cwd → meta → url/letter/hue) is shared, not reimplemented. +8. **Icon bitmaps:** singleton `IconImageCache` with injectable loader; while loading or on failure the renderer draws the letter avatar (hue swatch + white letter — canvas analogue of `RepoIcon`'s SVG circle; drawn as a square to keep `Ctx2D` minimal). A tab with no repo info renders title-only (banner + fill + dot + rings). Icon readiness is a KeySpec field (`ready`) so loads trigger repaints through the per-key diff; the controller subscribes to the cache and repaints on load completion. +9. **Press-snapshot guard:** `keyDown` resolves and stores the key's target (`pager` / `tab tabId` / `none`); `keyUp` acts on the snapshot, so a re-sort between press-down and press-up acts on the tab that was displayed at press-down. If the snapshot tab no longer exists at release, the press is a no-op. +10. **Idle dimming, multi-window locking, action layer, dials: unchanged** (they now simply see the sorted model). Re-sort repaints waking a dimmed deck is pre-existing behavior for any repaint (`deck-controller.ts:138`) and stays as-is. + +## File structure + +| File | Change | Responsibility | +|---|---|---| +| `src/deck/tile-state.ts` | **Create** | Pure per-tab tile classification: `TileFill`, `TileDot`, `TabStatusFlags`, `tileFill()`, `tileDot()`, `tilePriority()` | +| `src/deck/icon-image-cache.ts` | **Create** | Singleton async bitmap cache for repo icons (injectable loader, subscribe/notify, permanent-failure caching) | +| `src/deck/deck-selectors.ts` | Modify | `getTabStatusFlags`, `getTabRepoIcons`, reshaped + sorted `selectDeckModel`; delete `getTabRingStatus`/`TabRingStatus` at cleanup | +| `src/deck/frame.ts` | Modify | `KeySpec` tab variant gains `fill`/`dot`/`icons`, loses `previewLines`/`ring`; `buildFrame` takes `iconReady` instead of `previewFor`; `ringColor`/`RingColor` deleted; `stripText` counts from flags | +| `src/deck/tile-renderer.ts` | Modify | New `drawTab` (fill, icons, dot, banner, rings); `Ctx2D` gains `drawImage`; `iconLayout()`; preview constants/helpers deleted | +| `src/deck/deck-controller.ts` | Modify | Icon-cache wiring (iconReady + subscribe→repaint + getIcon into default renderer), preview path removal, press-down target snapshot | +| `src/deck/terminal-text-registry.ts` | **Delete** | Dead preview machinery (sole consumer was the deck) | +| `src/components/TerminalView.tsx` | Modify | Remove `useTerminalTextRegistration` hook call + import (lines ~103, ~676-677) | +| `src/components/VirtualDeckPanel.tsx` | Modify | `noopCtx` gains `drawImage`; renderer closure passes the icon cache | +| `test/unit/client/deck/tile-state.test.ts` | **Create** | Classification truth table | +| `test/unit/client/deck/icon-image-cache.test.ts` | **Create** | Cache load/fail/subscribe behavior | +| `test/unit/client/deck/deck-selectors.test.ts` | Modify | Flags, repo icons, sorted model | +| `test/unit/client/deck/frame.test.ts` | Modify | New KeySpec shape, `iconReady`, `ringColor` tests removed | +| `test/unit/client/deck/tile-renderer.test.ts` | Modify | Rewritten `drawTab` assertions (fills, icons, dot, rings) | +| `test/unit/client/deck/deck-controller.test.ts` | Modify | Icon repaint, preview-timer removal, press snapshot | +| `test/unit/client/deck/terminal-text-registry.test.tsx` | **Delete** | With its module | +| `test/e2e/stream-deck-flow.test.tsx` | Modify | Updated KeySpec expectations; new scenarios: sort priority, three backgrounds, icon fallback→ready, sorted paging, mid-press re-sort | + +Interfaces consumed from outside `src/deck/` (read-only, all verified to exist): +`getBusyPaneIdsForTab` (`@/lib/pane-activity`), `collectPaneEntries(node: PaneNode): Array<{ paneId: string; content: PaneContent }>` (`@/lib/pane-utils:72-80`), `resolvePaneRepoCwd`, `pathBasename`, `buildRepoIconUrl` (`@/lib/repo-icon`), `hueFromString` (`@/components/icons/RepoIcon`), `state.terminalMeta.byTerminalId`, `state.repoIcons.byCwd`, `state.turnCompletion.attentionByTab`, `state.settings.settings.panes.tabAttentionStyle`. + +--- + +### Task 1: Pure tile classification module (`tile-state.ts`) + +**Files:** +- Create: `src/deck/tile-state.ts` +- Test: `test/unit/client/deck/tile-state.test.ts` + +**Interfaces:** +- Consumes: nothing (pure module, no imports). +- Produces (later tasks rely on these exact names): + - `type TileFill = 'barTop' | 'green' | 'none'` + - `type TileDot = 'green' | 'blue' | null` + - `type TabStatusFlags = { busy: boolean; attention: boolean; greenIcon: boolean }` + - `tileFill(active: boolean, flags: TabStatusFlags): TileFill` + - `tileDot(flags: TabStatusFlags): TileDot` + - `tilePriority(active: boolean, flags: TabStatusFlags): number` (0..4) + +- [ ] **Step 1: Write the failing test** + +Create `test/unit/client/deck/tile-state.test.ts`: + +```ts +import { describe, it, expect } from 'vitest' +import { tileFill, tileDot, tilePriority, type TabStatusFlags } from '@/deck/tile-state' + +const f = (over: Partial = {}): TabStatusFlags => ({ + busy: false, attention: false, greenIcon: false, ...over, +}) + +describe('tileFill', () => { + it('bar-on-top for active tab with attention (tab bar: border-t-success + bg wash)', () => { + expect(tileFill(true, f({ attention: true }))).toBe('barTop') + }) + it('green fill for inactive tab with attention (tab bar: bg-emerald-100)', () => { + expect(tileFill(false, f({ attention: true }))).toBe('green') + }) + it('no fill without attention, regardless of busy/green-icon/active', () => { + expect(tileFill(true, f())).toBe('none') + expect(tileFill(false, f({ busy: true, greenIcon: true }))).toBe('none') + }) +}) + +describe('tileDot', () => { + it('blue when any pane is busy (tab bar: text-blue-500), even if green icons exist', () => { + expect(tileDot(f({ busy: true, greenIcon: true }))).toBe('blue') + }) + it('green for a running non-busy pane (tab bar: text-success)', () => { + expect(tileDot(f({ greenIcon: true }))).toBe('green') + }) + it('null otherwise', () => { + expect(tileDot(f())).toBe(null) + }) +}) + +describe('tilePriority', () => { + it('orders: barTop(0) < greenFill(1) < greenIcon(2) < blueIcon(3) < rest(4)', () => { + expect(tilePriority(true, f({ attention: true }))).toBe(0) + expect(tilePriority(false, f({ attention: true }))).toBe(1) + expect(tilePriority(false, f({ greenIcon: true }))).toBe(2) + expect(tilePriority(false, f({ busy: true, greenIcon: true }))).toBe(3) // busy dominates + expect(tilePriority(false, f())).toBe(4) + expect(tilePriority(true, f())).toBe(4) // active alone is not a priority bucket + }) + it('attention outranks busy/greenIcon', () => { + expect(tilePriority(false, f({ attention: true, busy: true, greenIcon: true }))).toBe(1) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm run test:vitest -- run test/unit/client/deck/tile-state.test.ts --config config/vitest/vitest.config.ts` +Expected: FAIL — cannot resolve `@/deck/tile-state`. + +- [ ] **Step 3: Write minimal implementation** + +Create `src/deck/tile-state.ts`: + +```ts +// Pure per-tab tile classification for the Stream Deck tiles. +// Mirrors the tab bar's visual states (src/components/TabItem.tsx): +// bar-on-top <-> active tab with attention -> fill 'barTop' +// green fill <-> inactive tab with attention -> fill 'green' +// green icon <-> a running, non-busy pane -> dot 'green' +// blue icon <-> any busy pane -> dot 'blue' +// Sort priority (spec): barTop, greenFill, greenIcon, blueIcon, rest. + +export type TileFill = 'barTop' | 'green' | 'none' +export type TileDot = 'green' | 'blue' | null + +export type TabStatusFlags = { + /** Any pane in the tab is busy (getBusyPaneIdsForTab). */ + busy: boolean + /** Turn-complete attention (turnCompletion.attentionByTab), gated on tabAttentionStyle !== 'none'. */ + attention: boolean + /** Any non-busy pane with effective status 'running' (TabItem.tsx:135-147). */ + greenIcon: boolean +} + +export function tileFill(active: boolean, flags: TabStatusFlags): TileFill { + if (flags.attention) return active ? 'barTop' : 'green' + return 'none' +} + +export function tileDot(flags: TabStatusFlags): TileDot { + if (flags.busy) return 'blue' + if (flags.greenIcon) return 'green' + return null +} + +/** 0 bar-on-top, 1 green-filled, 2 green-icon, 3 blue-icon, 4 rest. Busy dominates greenIcon. */ +export function tilePriority(active: boolean, flags: TabStatusFlags): number { + if (flags.attention) return active ? 0 : 1 + if (flags.busy) return 3 + if (flags.greenIcon) return 2 + return 4 +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm run test:vitest -- run test/unit/client/deck/tile-state.test.ts --config config/vitest/vitest.config.ts` +Expected: PASS (all tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/deck/tile-state.ts test/unit/client/deck/tile-state.test.ts +git commit -m "feat(deck): pure tile classification - fill, dot, and sort priority from tab-bar state flags" +``` + +--- + +### Task 2: Selector — `getTabStatusFlags` + +**Files:** +- Modify: `src/deck/deck-selectors.ts` +- Test: `test/unit/client/deck/deck-selectors.test.ts` + +**Interfaces:** +- Consumes: `TabStatusFlags` from Task 1; existing private `activityInputs(state)` helper (`deck-selectors.ts:13-22`); `getBusyPaneIdsForTab` from `@/lib/pane-activity`; `collectPaneEntries` from `@/lib/pane-utils` (already imported in this file for `tabHasPendingApproval` — verify the import list at the top of the file and add it if it's imported elsewhere). +- Produces: `getTabStatusFlags(state: RootState, tab: Tab): TabStatusFlags` — exact per-tab busy/attention/greenIcon derivation reused by Task 4. + +- [ ] **Step 1: Write the failing test** + +Open `test/unit/client/deck/deck-selectors.test.ts` and study how the existing tests build stores (`configureStore` with ~10 reducers + `preloadedState` — reuse the file's existing store-building helper verbatim). Add a new `describe` block. The fixture shapes below follow the file's existing conventions (tabs `t1..tN`, panes `p1..pN`, terminals `term-N` in `claude` mode) — adapt names to the helper actually present in the file: + +```ts +describe('getTabStatusFlags', () => { + it('greenIcon: running non-busy pane sets greenIcon (tab bar green icon condition)', () => { + const store = makeStore({ tabs: 1 }) // default fixture: claude terminal, status running, not busy + const state = store.getState() + const tab = state.tabs.tabs[0] + expect(getTabStatusFlags(state, tab)).toEqual({ busy: false, attention: false, greenIcon: true }) + }) + + it('busy pane sets busy and suppresses greenIcon when it is the only pane', () => { + const store = makeStore({ tabs: 1, busy: ['term-1'] }) + const state = store.getState() + expect(getTabStatusFlags(state, state.tabs.tabs[0])).toEqual({ busy: true, attention: false, greenIcon: false }) + }) + + it('attention flag mirrors turnCompletion.attentionByTab', () => { + const store = makeStore({ tabs: 1, attention: { t1: true } }) + const state = store.getState() + expect(getTabStatusFlags(state, state.tabs.tabs[0]).attention).toBe(true) + }) + + it("attention is gated off when tabAttentionStyle is 'none' (tab bar shows no bar/fill then)", () => { + const store = makeStore({ tabs: 1, attention: { t1: true } }) + // Patch the settings slice the way the suite's existing settings-dependent tests do; if none + // exist, build the store with preloadedState.settings.settings.panes.tabAttentionStyle = 'none'. + const state = withTabAttentionStyle(store.getState(), 'none') + expect(getTabStatusFlags(state, state.tabs.tabs[0]).attention).toBe(false) + }) + + it('exited terminal pane yields no greenIcon', () => { + const store = makeStore({ tabs: 1, paneStatus: { p1: 'exited' } }) + const state = store.getState() + expect(getTabStatusFlags(state, state.tabs.tabs[0]).greenIcon).toBe(false) + }) +}) +``` + +If the suite's fixture builder has no `paneStatus` option, extend it: it constructs `TerminalPaneContent` leaves — add `status: opts.paneStatus?.[paneId] ?? 'running'`. Add a small local `withTabAttentionStyle(state, style)` helper that returns a state copy with `settings.settings.panes.tabAttentionStyle` overridden (structured clone + assignment is fine for a test). + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm run test:vitest -- run test/unit/client/deck/deck-selectors.test.ts --config config/vitest/vitest.config.ts` +Expected: FAIL — `getTabStatusFlags` is not exported. + +- [ ] **Step 3: Write minimal implementation** + +In `src/deck/deck-selectors.ts`, add (import `collectPaneEntries` from `@/lib/pane-utils` if not already imported at top; import `type TabStatusFlags` from `./tile-state`): + +```ts +import type { TabStatusFlags } from './tile-state' + +/** + * Per-tab status flags, derived from the SAME conditions the tab bar uses: + * - busy: any pane busy (getBusyPaneIdsForTab, TabBar.tsx:329-338) + * - attention: turnCompletion.attentionByTab gated on tabAttentionStyle !== 'none' + * (TabItem.tsx:158-184 renders no bar/fill when the style is 'none') + * - greenIcon: any non-busy pane whose effective status is 'running' + * (TabItem.tsx:135-147; non-terminal pane kinds count as 'running') + */ +export function getTabStatusFlags(state: RootState, tab: Tab): TabStatusFlags { + const busyIds = getBusyPaneIdsForTab({ + tab, + paneLayouts: state.panes.layouts as Record, + ...activityInputs(state), + }) + const layout = state.panes.layouts[tab.id] + const entries = layout ? collectPaneEntries(layout) : [] + const greenIcon = entries.some(({ paneId, content }) => { + if (busyIds.includes(paneId)) return false + const status = content.kind === 'terminal' ? content.status : 'running' + return status === 'running' + }) + const attentionStyle = state.settings.settings.panes.tabAttentionStyle + return { + busy: busyIds.length > 0, + attention: !!state.turnCompletion.attentionByTab[tab.id] && attentionStyle !== 'none', + greenIcon, + } +} +``` + +(If `getBusyPaneIdsForTab`'s exact input object differs, mirror the existing `getTabRingStatus` body at `deck-selectors.ts:38-49`, which calls it the same way.) + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm run test:vitest -- run test/unit/client/deck/deck-selectors.test.ts --config config/vitest/vitest.config.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/deck/deck-selectors.ts test/unit/client/deck/deck-selectors.test.ts +git commit -m "feat(deck): getTabStatusFlags - busy/attention/greenIcon from the tab bar's own conditions" +``` + +--- + +### Task 3: Selector — `getTabRepoIcons` + +**Files:** +- Modify: `src/deck/deck-selectors.ts` +- Test: `test/unit/client/deck/deck-selectors.test.ts` + +**Interfaces:** +- Consumes: `resolvePaneRepoCwd(content, tab, terminalMetaById)`, `pathBasename`, `buildRepoIconUrl` from `@/lib/repo-icon`; `hueFromString` from `@/components/icons/RepoIcon`; `collectPaneEntries`; `state.terminalMeta.byTerminalId`; `state.repoIcons.byCwd` (`RepoIconEntry`). +- Produces: `type TileRepoIcon = { url: string | null; letter: string; hue: number }` and `getTabRepoIcons(state: RootState, tab: Tab): TileRepoIcon[]` (max 3, distinct repos, first-appearance order) — consumed by Task 4's model and Task 5's KeySpec. + +- [ ] **Step 1: Write the failing test** + +Add to `test/unit/client/deck/deck-selectors.test.ts`. Extend the fixture builder to support seeding `repoIcons.byCwd` and `terminalMeta.byTerminalId` via `preloadedState` (both are plain records). Fixture panes in this suite are claude-mode terminals, so `resolvePaneRepoCwd` uses `meta?.repoRoot || meta?.checkoutRoot || meta?.cwd || content.initialCwd || tab?.initialCwd` — seed `terminalMeta.byTerminalId['term-1'] = { cwd: '/repos/alpha' }` (match the record shape used by the `terminalMeta` slice; check its initial state for exact field names). + +```ts +describe('getTabRepoIcons', () => { + it('maps a resolved repo cwd with an icon to a repo-icon URL + letter + hue', () => { + const store = makeStore({ + tabs: 1, + terminalMeta: { 'term-1': { cwd: '/repos/alpha' } }, + repoIcons: { '/repos/alpha': { status: 'ready', repoRoot: '/repos/alpha', repoName: 'alpha', hasIcon: true } }, + }) + const state = store.getState() + expect(getTabRepoIcons(state, state.tabs.tabs[0])).toEqual([ + { url: buildRepoIconUrl('/repos/alpha'), letter: 'A', hue: hueFromString('alpha') }, + ]) + }) + + it('falls back to letter-only (url null) when the repo has no icon', () => { + const store = makeStore({ + tabs: 1, + terminalMeta: { 'term-1': { cwd: '/repos/beta' } }, + repoIcons: { '/repos/beta': { status: 'error', hasIcon: false, repoName: 'beta' } }, + }) + const state = store.getState() + expect(getTabRepoIcons(state, state.tabs.tabs[0])).toEqual([ + { url: null, letter: 'B', hue: hueFromString('beta') }, + ]) + }) + + it('skips cwds still loading, dedupes by repoKey, caps at 3 distinct repos', () => { + // 5 panes in one tab across cwds: loading, r1, r1 (dupe), r2, r3, r4 -> expect r1,r2,r3 + // Build with a multi-pane tab; assert result length 3 and first-appearance order. + }) + + it('returns [] for a tab with no repo-resolvable panes', () => { + const store = makeStore({ tabs: 1 }) // no terminalMeta seeded, no initialCwd + const state = store.getState() + expect(getTabRepoIcons(state, state.tabs.tabs[0])).toEqual([]) + }) +}) +``` + +Write the cap/dedupe test in full (build a tab with 6 panes via the fixture builder's multi-pane support, or extend it; assert exact array). Note: the default fixture may set `initialCwd` on panes/tabs — if so, the "returns []" test needs the fixture's cwd to have no `repoIcons.byCwd` entry (unknown cwd → no meta → skipped), which also passes; assert accordingly. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm run test:vitest -- run test/unit/client/deck/deck-selectors.test.ts --config config/vitest/vitest.config.ts` +Expected: FAIL — `getTabRepoIcons` is not exported. + +- [ ] **Step 3: Write minimal implementation** + +In `src/deck/deck-selectors.ts`: + +```ts +import { resolvePaneRepoCwd, pathBasename, buildRepoIconUrl } from '@/lib/repo-icon' +import { hueFromString } from '@/components/icons/RepoIcon' + +/** Mirrors MAX_REPO_ICONS in TabItem.tsx (locked decision: cap distinct repo icons at 3). */ +export const MAX_TILE_REPO_ICONS = 3 + +export type TileRepoIcon = { + /** /api/repo-icon URL when the repo has a detected icon, else null (letter avatar). */ + url: string | null + letter: string + hue: number +} + +/** + * Repo icons for a tab, using the SAME resolution pipeline as the tab bar + * (TabBar.tsx getPaneEntries -> repoIconInfoByCwd): resolvePaneRepoCwd per pane, + * meta from state.repoIcons.byCwd (probed by the always-mounted TabBar), + * distinct repos in first-appearance order, capped at 3, silently truncated. + * Deliberate divergences from TabItem: considers ALL panes (not just the first + * 3 pane icons) and ignores settings.panes.repoIconsOnTabs (deck tiles always + * show their center glyph). + */ +export function getTabRepoIcons(state: RootState, tab: Tab): TileRepoIcon[] { + const layout = state.panes.layouts[tab.id] + if (!layout) return [] + const terminalMetaById = state.terminalMeta.byTerminalId + const byCwd = state.repoIcons.byCwd + const seen = new Set() + const icons: TileRepoIcon[] = [] + for (const entry of collectPaneEntries(layout)) { + const cwd = resolvePaneRepoCwd(entry.content, tab, terminalMetaById) + if (!cwd) continue + const meta = byCwd[cwd] + if (!meta || meta.status === 'loading') continue + const repoKey = meta.repoRoot || cwd + if (seen.has(repoKey)) continue + seen.add(repoKey) + const repoName = meta.repoName || pathBasename(repoKey) + icons.push({ + url: meta.hasIcon ? buildRepoIconUrl(cwd) : null, + letter: (repoName.trim()[0] || '?').toUpperCase(), + hue: hueFromString(repoName), + }) + if (icons.length >= MAX_TILE_REPO_ICONS) break + } + return icons +} +``` + +If importing `hueFromString` from the `.tsx` component file trips any lint/typecheck rule about importing components into non-React modules, move `hueFromString` into `src/lib/repo-icon.ts` and re-export it from `RepoIcon.tsx` (keeping the tab bar unchanged) — that keeps one shared implementation. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm run test:vitest -- run test/unit/client/deck/deck-selectors.test.ts --config config/vitest/vitest.config.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/deck/deck-selectors.ts test/unit/client/deck/deck-selectors.test.ts +git commit -m "feat(deck): getTabRepoIcons - tab-bar repo icon pipeline reused for tiles, cap 3" +``` + +--- + +### Task 4: Sorted `selectDeckModel` with the new `DeckTab` shape + +**Files:** +- Modify: `src/deck/deck-selectors.ts` +- Test: `test/unit/client/deck/deck-selectors.test.ts` + +**Interfaces:** +- Consumes: Tasks 1–3 (`tileFill`, `tileDot`, `tilePriority`, `getTabStatusFlags`, `getTabRepoIcons`, `TileRepoIcon`); existing `getTabRingStatus` (kept temporarily so `frame.ts` still compiles — removed in Task 9). +- Produces (Tasks 5, 8, 11 rely on this exact shape): + +```ts +export type DeckTab = { + id: string + title: string + active: boolean + busy: boolean // for stripText counts + attention: boolean // for stripText counts + fill: TileFill + dot: TileDot + priority: number + repoIcons: TileRepoIcon[] + status: TabRingStatus // TRANSITIONAL - deleted in Task 9 +} +export type DeckModel = { tabs: DeckTab[]; activeTabId: string | null } +``` + +- [ ] **Step 1: Write the failing test** + +Add to `test/unit/client/deck/deck-selectors.test.ts`: + +```ts +describe('selectDeckModel (sorted, tile fields)', () => { + it('sorts tabs by priority: barTop, greenFill, greenIcon, blueIcon, rest', () => { + // t1 exited pane (rest), t2 busy (blueIcon), t3 running idle (greenIcon), + // t4 attention inactive (greenFill), t5 attention + active (barTop) + const store = makeStore({ + tabs: 5, + activeTab: 't5', + paneStatus: { p1: 'exited' }, + busy: ['term-2'], + attention: { t4: true, t5: true }, + }) + const model = selectDeckModel(store.getState()) + expect(model.tabs.map((t) => t.id)).toEqual(['t5', 't4', 't3', 't2', 't1']) + expect(model.tabs.map((t) => t.priority)).toEqual([0, 1, 2, 3, 4]) + }) + + it('is stable within a priority group (tab-bar order preserved)', () => { + const store = makeStore({ tabs: 3 }) // all three are greenIcon + const model = selectDeckModel(store.getState()) + expect(model.tabs.map((t) => t.id)).toEqual(['t1', 't2', 't3']) + }) + + it('carries fill, dot, and repoIcons per tab', () => { + const store = makeStore({ + tabs: 2, + activeTab: 't1', + attention: { t1: true }, + busy: ['term-2'], + terminalMeta: { 'term-1': { cwd: '/repos/alpha' } }, + repoIcons: { '/repos/alpha': { status: 'ready', repoRoot: '/repos/alpha', repoName: 'alpha', hasIcon: true } }, + }) + const model = selectDeckModel(store.getState()) + const t1 = model.tabs.find((t) => t.id === 't1')! + const t2 = model.tabs.find((t) => t.id === 't2')! + expect(t1.fill).toBe('barTop') + expect(t1.repoIcons).toEqual([{ url: buildRepoIconUrl('/repos/alpha'), letter: 'A', hue: hueFromString('alpha') }]) + expect(t2.fill).toBe('none') + expect(t2.dot).toBe('blue') + }) +}) +``` + +The fixture builders' documented options are `tabs`, `busy`, `attention`, `freshAgentTab`, `pendingPermissions`, `freshAgentRunning` — they default the active tab to `t1`. Add an `activeTab?: string` option (sets `preloadedState.tabs.activeTabId`) wherever these tests (and Tasks 10–11) pass it. + +Also update any existing `selectDeckModel` tests in this file that assert the old `{ id, title, active, status }` shape — extend their expected objects with the new fields (or switch them to `toMatchObject`). + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm run test:vitest -- run test/unit/client/deck/deck-selectors.test.ts --config config/vitest/vitest.config.ts` +Expected: FAIL — new fields absent, order unsorted. + +- [ ] **Step 3: Write minimal implementation** + +Replace `selectDeckModel` and the `DeckTab`/`DeckModel` types in `src/deck/deck-selectors.ts`: + +```ts +import { tileFill, tileDot, tilePriority, type TileFill, type TileDot } from './tile-state' + +export type DeckTab = { + id: string + title: string + active: boolean + busy: boolean + attention: boolean + fill: TileFill + dot: TileDot + priority: number + repoIcons: TileRepoIcon[] + /** TRANSITIONAL: consumed by frame.ts ringColor/stripText until Task 9 removes rings. */ + status: TabRingStatus +} +export type DeckModel = { tabs: DeckTab[]; activeTabId: string | null } + +export function selectDeckModel(state: RootState): DeckModel { + const activeTabId = state.tabs.activeTabId + const tabs = state.tabs.tabs.map((tab) => { + const active = tab.id === activeTabId + const flags = getTabStatusFlags(state, tab) + return { + id: tab.id, + title: tab.title, + active, + busy: flags.busy, + attention: flags.attention, + fill: tileFill(active, flags), + dot: tileDot(flags), + priority: tilePriority(active, flags), + repoIcons: getTabRepoIcons(state, tab), + status: getTabRingStatus(state, tab), + } + }) + // Status-priority sort; Array.prototype.sort is stable, so tab-bar order + // is preserved within each priority group. Paging slices this sorted list + // (visibleTabs), so the pager pages over the sorted order automatically. + tabs.sort((a, b) => a.priority - b.priority) + return { activeTabId, tabs } +} +``` + +- [ ] **Step 4: Run tests to verify they pass — and check downstream compile** + +Run: `npm run test:vitest -- run test/unit/client/deck/ --config config/vitest/vitest.config.ts` +Expected: `deck-selectors.test.ts` PASS. `frame.test.ts` / `deck-controller.test.ts` may fail on fixture DeckTab shapes (they construct model objects) — update their fixture tab objects to include the new fields (add `busy:false, attention:false, fill:'none', dot:null, priority:4, repoIcons:[]` as appropriate; a local `makeDeckTab(over)` helper in each test file keeps this readable). The e2e suite also builds models indirectly through the real store — run it too: + +`npm run test:vitest -- run test/e2e/stream-deck-flow.test.tsx --config config/vitest/vitest.config.ts` + +The e2e "tabs appear on keys" scenario asserts key order from tab order — with sorting, a busy t1 now lands after green-icon tabs. Update expected key indices to the sorted order (this is the intended behavior change). Then: + +Run: `npm run typecheck:client` +Expected: clean. + +- [ ] **Step 5: Commit** + +```bash +git add src/deck/deck-selectors.ts test/unit/client/deck/ test/e2e/stream-deck-flow.test.tsx +git commit -m "feat(deck): status-priority sorted DeckModel with fill/dot/repoIcons per tab" +``` + +--- + +### Task 5: `KeySpec` reshape (additive) + `buildFrame` `iconReady` + +**Files:** +- Modify: `src/deck/frame.ts` +- Modify: `src/deck/deck-controller.ts` (one line: pass `iconReady`) +- Test: `test/unit/client/deck/frame.test.ts` (plus fixture updates in `deck-controller.test.ts`, `test/e2e/stream-deck-flow.test.tsx`) + +**Interfaces:** +- Consumes: `DeckTab` (Task 4), `TileFill`/`TileDot` (Task 1), `TileRepoIcon` (Task 3). +- Produces (renderer Task 7 and controller Task 8 rely on): + +```ts +export type TileIcon = { url: string | null; letter: string; hue: number; ready: boolean } +// tab variant of KeySpec becomes: +// { kind: 'tab'; tabId: string; title: string; previewLines: string[]; ring: RingColor; +// active: boolean; fill: TileFill; dot: TileDot; icons: TileIcon[] } +// buildFrame inputs gain: iconReady: (url: string) => boolean +``` + +(`previewLines`/`ring` stay populated until Task 9 — additive first, remove later.) + +- [ ] **Step 1: Write the failing test** + +In `test/unit/client/deck/frame.test.ts`, extend the `buildFrame` tests. Follow the file's existing model-fixture style: + +```ts +it('buildFrame carries fill/dot/icons onto tab keys, with iconReady resolving readiness', () => { + const model = { + activeTabId: 't1', + tabs: [makeDeckTab({ + id: 't1', title: 'alpha', active: true, fill: 'barTop', dot: 'green', + repoIcons: [ + { url: '/api/repo-icon?cwd=%2Fr%2Fa', letter: 'A', hue: 120 }, + { url: null, letter: 'B', hue: 200 }, + ], + })], + } + const frame = buildFrame({ + model, caps: MINI_CAPS, page: 1, actionLayer: null, + previewFor: () => [], + iconReady: (url) => url === '/api/repo-icon?cwd=%2Fr%2Fa', + }) + expect(frame.keys[0]).toMatchObject({ + kind: 'tab', tabId: 't1', fill: 'barTop', dot: 'green', + icons: [ + { url: '/api/repo-icon?cwd=%2Fr%2Fa', letter: 'A', hue: 120, ready: true }, + { url: null, letter: 'B', hue: 200, ready: false }, + ], + }) +}) +``` + +Add a `makeDeckTab(over: Partial): DeckTab` helper at the top of `frame.test.ts` filling all required fields with defaults (`busy:false, attention:false, fill:'none', dot:null, priority:4, repoIcons:[], status:{busy:false,green:false,amber:false}, active:false, title:'tab'`), and refactor the file's existing model fixtures to use it. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm run test:vitest -- run test/unit/client/deck/frame.test.ts --config config/vitest/vitest.config.ts` +Expected: FAIL — `iconReady` unknown input, `fill/dot/icons` missing from the produced KeySpec. + +- [ ] **Step 3: Write minimal implementation** + +In `src/deck/frame.ts`: + +```ts +import type { TileFill, TileDot } from './tile-state' + +export type TileIcon = { url: string | null; letter: string; hue: number; ready: boolean } + +// KeySpec tab variant (replace the existing line): +export type KeySpec = + | { kind: 'empty' } + | { kind: 'tab'; tabId: string; title: string; previewLines: string[]; ring: RingColor; + active: boolean; fill: TileFill; dot: TileDot; icons: TileIcon[] } + | { kind: 'pager'; page: number; pageCount: number } + | { kind: 'action'; action: DeckAction; enabled: boolean } +``` + +In `buildFrame`, add `iconReady` to the input type (`iconReady: (url: string) => boolean`) and extend the tab-key construction (`frame.ts:104-111`): + +```ts +keys[keyIndex] = { + kind: 'tab', tabId: tab.id, title: tab.title, + previewLines: previewFor(tab.id), ring: ringColor(tab.status), active: tab.active, + fill: tab.fill, dot: tab.dot, + icons: tab.repoIcons.map((icon) => ({ + ...icon, + ready: icon.url !== null && iconReady(icon.url), + })), +} +``` + +In `src/deck/deck-controller.ts`, the `buildFrame` call site (`repaint()`, ~`:122`) must now pass `iconReady` — pass a stub for this task (`iconReady: () => false`); Task 8 wires the real cache. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm run test:vitest -- run test/unit/client/deck/ test/e2e/stream-deck-flow.test.tsx --config config/vitest/vitest.config.ts` +Expected: frame tests PASS. Decoded-KeySpec `toEqual` assertions in `deck-controller.test.ts` and `stream-deck-flow.test.tsx` now fail on the added fields — update every decoded tab-KeySpec expectation to include `fill`, `dot`, `icons` (e.g. the e2e scenario's expectation becomes `{ kind: 'tab', tabId: 't1', title: 'tab1', previewLines: [...], ring: 'blue', active: true, fill: 'none', dot: 'blue', icons: [] }`). Prefer switching bulky ones to `toMatchObject` where the full shape isn't the point. Then `npm run typecheck:client` — clean. + +- [ ] **Step 5: Commit** + +```bash +git add src/deck/frame.ts src/deck/deck-controller.ts test/unit/client/deck/ test/e2e/stream-deck-flow.test.tsx +git commit -m "feat(deck): KeySpec gains fill/dot/icons; buildFrame resolves icon readiness" +``` + +--- + +### Task 6: `IconImageCache` + +**Files:** +- Create: `src/deck/icon-image-cache.ts` +- Test: `test/unit/client/deck/icon-image-cache.test.ts` + +**Interfaces:** +- Consumes: nothing app-specific (DOM `Image` in the default loader only). +- Produces (Tasks 7, 8, 12 rely on): + - `class IconImageCache { constructor(loader?: IconLoader); bitmapFor(url: string): CanvasImageSource | null; subscribe(cb: () => void): () => void }` + - `type IconLoader = (url: string) => Promise` + - `getIconImageCache(): IconImageCache` (singleton), `resetIconImageCacheForTests(cache?: IconImageCache): void` + +- [ ] **Step 1: Write the failing test** + +Create `test/unit/client/deck/icon-image-cache.test.ts`: + +```ts +import { describe, it, expect, vi } from 'vitest' +import { IconImageCache, getIconImageCache, resetIconImageCacheForTests } from '@/deck/icon-image-cache' + +const fakeBitmap = { width: 16, height: 16 } as unknown as CanvasImageSource + +function deferredLoader() { + const pending = new Map void; reject: (e: Error) => void }>() + const loader = (url: string) => + new Promise((resolve, reject) => pending.set(url, { resolve, reject })) + return { loader, pending } +} + +describe('IconImageCache', () => { + it('returns null while loading, kicks off exactly one load per url, notifies on completion', async () => { + const { loader, pending } = deferredLoader() + const cache = new IconImageCache(loader) + const listener = vi.fn() + cache.subscribe(listener) + expect(cache.bitmapFor('/i/a')).toBe(null) + expect(cache.bitmapFor('/i/a')).toBe(null) // second call: no second load + expect(pending.size).toBe(1) + pending.get('/i/a')!.resolve(fakeBitmap) + await Promise.resolve() // flush microtasks + await Promise.resolve() + expect(listener).toHaveBeenCalledTimes(1) + expect(cache.bitmapFor('/i/a')).toBe(fakeBitmap) + }) + + it('caches failures permanently (null forever, no retry) and still notifies', async () => { + const { loader, pending } = deferredLoader() + const cache = new IconImageCache(loader) + const listener = vi.fn() + cache.subscribe(listener) + cache.bitmapFor('/i/broken') + pending.get('/i/broken')!.reject(new Error('404')) + await Promise.resolve() + await Promise.resolve() + expect(listener).toHaveBeenCalledTimes(1) + expect(cache.bitmapFor('/i/broken')).toBe(null) + expect(pending.size).toBe(1) // no second load attempt + }) + + it('unsubscribe stops notifications', async () => { + const { loader, pending } = deferredLoader() + const cache = new IconImageCache(loader) + const listener = vi.fn() + cache.subscribe(listener)() + cache.bitmapFor('/i/a') + pending.get('/i/a')!.resolve(fakeBitmap) + await Promise.resolve() + await Promise.resolve() + expect(listener).not.toHaveBeenCalled() + }) + + it('singleton: getIconImageCache returns the same instance; reset swaps it for tests', () => { + resetIconImageCacheForTests() + const a = getIconImageCache() + expect(getIconImageCache()).toBe(a) + const fake = new IconImageCache(async () => fakeBitmap) + resetIconImageCacheForTests(fake) + expect(getIconImageCache()).toBe(fake) + resetIconImageCacheForTests() + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm run test:vitest -- run test/unit/client/deck/icon-image-cache.test.ts --config config/vitest/vitest.config.ts` +Expected: FAIL — module does not exist. + +- [ ] **Step 3: Write minimal implementation** + +Create `src/deck/icon-image-cache.ts`: + +```ts +// Async bitmap cache for repo icons drawn on Stream Deck tiles. +// Canvas analogue of RepoIcon.tsx's + onError-fallback: while a URL is +// loading (or after it fails) bitmapFor returns null and the tile renderer +// draws the letter avatar; when a load completes, subscribers (the deck +// controller) are notified so tiles repaint with the real icon. +// Failures are cached permanently for the session (like -> +// letter avatar; the server caches negatives too). + +export type IconLoader = (url: string) => Promise + +const defaultLoader: IconLoader = (url) => + new Promise((resolve, reject) => { + const img = new Image() + img.onload = () => resolve(img) + img.onerror = () => reject(new Error(`repo icon load failed: ${url}`)) + img.src = url + }) + +export class IconImageCache { + private bitmaps = new Map() + private failed = new Set() + private pending = new Set() + private listeners = new Set<() => void>() + + constructor(private loader: IconLoader = defaultLoader) {} + + /** Returns the decoded bitmap, or null while loading / after failure. Requests the load on first miss. */ + bitmapFor(url: string): CanvasImageSource | null { + const hit = this.bitmaps.get(url) + if (hit) return hit + if (!this.failed.has(url) && !this.pending.has(url)) { + this.pending.add(url) + void this.loader(url).then( + (bitmap) => { + this.pending.delete(url) + this.bitmaps.set(url, bitmap) + this.notify() + }, + () => { + this.pending.delete(url) + this.failed.add(url) + this.notify() + }, + ) + } + return null + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + private notify(): void { + for (const listener of [...this.listeners]) listener() + } +} + +let singleton: IconImageCache | null = null + +export function getIconImageCache(): IconImageCache { + if (!singleton) singleton = new IconImageCache() + return singleton +} + +export function resetIconImageCacheForTests(cache?: IconImageCache): void { + singleton = cache ?? null +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm run test:vitest -- run test/unit/client/deck/icon-image-cache.test.ts --config config/vitest/vitest.config.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/deck/icon-image-cache.ts test/unit/client/deck/icon-image-cache.test.ts +git commit -m "feat(deck): IconImageCache - async repo-icon bitmaps with letter-avatar fallback semantics" +``` + +--- + +### Task 7: Tile renderer redesign + +**Files:** +- Modify: `src/deck/tile-renderer.ts` +- Modify: `src/components/VirtualDeckPanel.tsx` (`noopCtx` gains `drawImage`; renderer closures pass the cache getter) +- Test: `test/unit/client/deck/tile-renderer.test.ts` + +**Interfaces:** +- Consumes: `TileIcon` KeySpec fields (Task 5), `getIconImageCache` (Task 6, in VirtualDeckPanel wiring). +- Produces (Task 8 and 12 rely on): + - `Ctx2D` now includes `'drawImage'` in the `Pick`. + - `type IconSource = (url: string) => CanvasImageSource | null` + - `renderKey(spec: KeySpec, caps: DeckCapabilities, createCtx: CtxFactory, getIcon?: IconSource): Uint8ClampedArray` (default `() => null`) + - `iconLayout(w: number, h: number, count: number): Array<{ x: number; y: number; size: number }>` + - New constants: `TILE_BG = '#0a0a0a'`, `TILE_FILL_GREEN = '#a7f3d0'`, `BAR_TOP_BORDER = '#21c45d'`, `DOT_GREEN = '#21c45d'`, `DOT_BLUE = '#3b82f6'`, `DOT_SIZE = 8` + +- [ ] **Step 1: Write the failing tests** + +Rewrite the `renderKey` tab-tile tests in `test/unit/client/deck/tile-renderer.test.ts`. Extend the file's `recordingCtx()` with `drawImage` recording and add an `images` array: + +```ts +type Img = { x: number; y: number; w: number; h: number } +// inside recordingCtx(): +const images: Img[] = [] +// add to the ctx object: +drawImage(_src: CanvasImageSource, x: number, y: number, w: number, h: number) { + images.push({ x, y, w, h }) +}, +// return { ctx, rects, texts, images } +``` + +New/updated tests (keep the existing `truncateTitle`/`fitLabel`/`drawRing`/pager/action tests; delete the `previewGeometry`/`cropPreviewLines` tests in Task 9, not here): + +```ts +const tabSpec = (over: Partial> = {}): KeySpec => ({ + kind: 'tab', tabId: 't1', title: 'build', previewLines: [], ring: null, + active: false, fill: 'none', dot: null, icons: [], ...over, +}) + +it('no-fill tile: near-black bg, banner, white title, no rings, no dot, no preview text', () => { + const { out, rects, texts } = renderTab(tabSpec()) + expect(out).toBeInstanceOf(Uint8ClampedArray) + expect(rects[0]).toMatchObject({ x: 0, y: 0, w: 80, h: 80, style: TILE_BG }) + expect(rects.some((r) => r.y === 0 && r.h === 20 && r.style.startsWith('rgba'))).toBe(true) // banner + expect(texts.some((t) => t.text === 'build' && t.style === '#ffffff')).toBe(true) // title + expect(rects.filter((r) => r.style === ACTIVE_COLOR)).toHaveLength(0) + expect(texts.filter((t) => t.style === '#a8a8a8')).toHaveLength(0) // preview text gone from drawTab (literal: the constant dies in Task 9) +}) + +it('green fill state paints the light-green background', () => { + const { rects } = renderTab(tabSpec({ fill: 'green' })) + expect(rects[0]).toMatchObject({ x: 0, y: 0, w: 80, h: 80, style: TILE_FILL_GREEN }) +}) + +it('barTop state paints light-green background + 3px green border ring', () => { + const { rects } = renderTab(tabSpec({ fill: 'barTop', active: true })) + expect(rects[0].style).toBe(TILE_FILL_GREEN) + expect(rects.filter((r) => r.style === BAR_TOP_BORDER).length).toBeGreaterThan(0) + // active tab keeps its white ring nested inside the border + expect(rects.filter((r) => r.style === ACTIVE_COLOR && r.h <= 1).length).toBeGreaterThan(0) +}) + +it('active tab without fill gets the plain white ring', () => { + const { rects } = renderTab(tabSpec({ active: true })) + expect(rects.filter((r) => r.style === ACTIVE_COLOR).length).toBeGreaterThan(0) +}) + +it('ready icon draws via drawImage at the centered layout slot', () => { + const bitmap = {} as CanvasImageSource + const { images } = renderTab( + tabSpec({ icons: [{ url: '/i/a', letter: 'A', hue: 120, ready: true }] }), + (url) => (url === '/i/a' ? bitmap : null), + ) + const [slot] = iconLayout(80, 80, 1) + expect(images).toEqual([{ x: slot.x, y: slot.y, w: slot.size, h: slot.size }]) +}) + +it('unready or letter-only icon draws the hue swatch + white letter fallback', () => { + const { rects, texts, images } = renderTab( + tabSpec({ icons: [{ url: null, letter: 'B', hue: 200, ready: false }] }), + ) + expect(images).toHaveLength(0) + expect(rects.some((r) => r.style === 'hsl(200, 60%, 42%)')).toBe(true) + expect(texts.some((t) => t.text === 'B' && t.style === '#ffffff')).toBe(true) +}) + +it('status dot: green and blue variants at bottom-center; absent when null', () => { + const green = renderTab(tabSpec({ dot: 'green' })) + expect(green.rects.some((r) => r.style === DOT_GREEN && r.w === DOT_SIZE && r.h === DOT_SIZE)).toBe(true) + const blue = renderTab(tabSpec({ dot: 'blue' })) + expect(blue.rects.some((r) => r.style === DOT_BLUE && r.w === DOT_SIZE && r.h === DOT_SIZE)).toBe(true) + const none = renderTab(tabSpec()) + expect(none.rects.some((r) => r.w === DOT_SIZE && r.h === DOT_SIZE)).toBe(false) +}) + +it('iconLayout: 1 icon centered large; 3 icons in a centered row below the banner', () => { + const one = iconLayout(80, 80, 1) + expect(one).toHaveLength(1) + expect(one[0].size).toBe(30) // round(min(80, 60) * 0.5) + expect(one[0].x).toBe(Math.round((80 - 30) / 2)) + expect(one[0].y).toBe(Math.round(20 + (60 - 30) / 2)) + const three = iconLayout(80, 80, 3) + expect(three).toHaveLength(3) + expect(three.every((s) => s.size === 18)).toBe(true) // round(60 * 0.3) + expect(three[1].x - three[0].x).toBe(18 + 3) // size + gap +}) +``` + +Add a local `renderTab(spec, getIcon?)` helper wrapping the file's existing factory-capture pattern (captures `recordingCtx` output and calls `renderKey(spec, MINI_CAPS, factory, getIcon)`). + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm run test:vitest -- run test/unit/client/deck/tile-renderer.test.ts --config config/vitest/vitest.config.ts` +Expected: FAIL — `TILE_BG`/`iconLayout`/`IconSource` not exported, `drawTab` still draws previews/rings. + +- [ ] **Step 3: Write the implementation** + +In `src/deck/tile-renderer.ts`: + +1. Widen the ctx seam: + +```ts +export type Ctx2D = Pick< + CanvasRenderingContext2D, + 'fillRect' | 'fillText' | 'measureText' | 'getImageData' | 'drawImage' +> & { fillStyle: string | CanvasGradient | CanvasPattern; font: string; textBaseline: CanvasTextBaseline } + +export type IconSource = (url: string) => CanvasImageSource | null +``` + +2. New constants (keep `BANNER_HEIGHT`, `BANNER_FILL`, `TITLE_FONT_SIZE`, `ACTIVE_COLOR`, `MAX_TITLE_CHARS`, action/pager/strip constants; `PREVIEW_*` constants stay until Task 9): + +```ts +export const TILE_BG = '#0a0a0a' +/** Light green fill - the tab bar's emerald attention fill, tuned for the LCD (emerald-200). */ +export const TILE_FILL_GREEN = '#a7f3d0' +/** The tab bar's bar-on-top green (--success, hsl(142 71% 45%)). */ +export const BAR_TOP_BORDER = '#21c45d' +/** Status dot: the tab bar's icon tint colors (text-success / text-blue-500). */ +export const DOT_GREEN = '#21c45d' +export const DOT_BLUE = '#3b82f6' +export const DOT_SIZE = 8 +export const ICON_GAP = 3 +``` + +3. Layout helper: + +```ts +/** Centered icon slots in the area below the title banner. */ +export function iconLayout(w: number, h: number, count: number): Array<{ x: number; y: number; size: number }> { + if (count <= 0) return [] + const areaTop = BANNER_HEIGHT + const areaH = h - areaTop + const scale = count === 1 ? 0.5 : 0.3 + const size = Math.round(Math.min(w, areaH) * scale) + const rowW = count * size + (count - 1) * ICON_GAP + const x0 = Math.round((w - rowW) / 2) + const y = Math.round(areaTop + (areaH - size) / 2) + return Array.from({ length: count }, (_, i) => ({ x: x0 + i * (size + ICON_GAP), y, size })) +} +``` + +4. Rewrite `drawTab` (replace the whole function; the preview-drawing block is deleted from `drawTab` here, the helpers/constants themselves are deleted in Task 9): + +```ts +function drawTab(ctx: Ctx2D, w: number, h: number, spec: Extract, getIcon: IconSource): void { + // 1. Background mirrors the tab bar state: no fill / green fill / barTop (fill + border below). + ctx.fillStyle = spec.fill === 'none' ? TILE_BG : TILE_FILL_GREEN + ctx.fillRect(0, 0, w, h) + + // 2. Centered repo icons; letter avatar while loading, on failure, or when the repo has no icon. + const slots = iconLayout(w, h, spec.icons.length) + spec.icons.forEach((icon, i) => { + const { x, y, size } = slots[i] + const bitmap = icon.url && icon.ready ? getIcon(icon.url) : null + if (bitmap) { + ctx.drawImage(bitmap, x, y, size, size) + return + } + // Letter avatar (canvas analogue of RepoIcon's SVG circle): hue swatch + white letter. + ctx.fillStyle = `hsl(${icon.hue}, 60%, 42%)` + ctx.fillRect(x, y, size, size) + ctx.font = `600 ${Math.round(size * 0.6)}px sans-serif` + ctx.textBaseline = 'top' + ctx.fillStyle = '#ffffff' + const letterWidth = ctx.measureText(icon.letter).width + ctx.fillText(icon.letter, Math.round(x + (size - letterWidth) / 2), Math.round(y + size * 0.2)) + }) + + // 3. Status dot: the tab bar's green/blue icon-tint states, visible on the deck. + if (spec.dot) { + ctx.fillStyle = spec.dot === 'green' ? DOT_GREEN : DOT_BLUE + ctx.fillRect(Math.round((w - DOT_SIZE) / 2), h - DOT_SIZE - 5, DOT_SIZE, DOT_SIZE) + } + + // 4. Title banner across the top (unchanged treatment). + ctx.fillStyle = BANNER_FILL + ctx.fillRect(0, 0, w, BANNER_HEIGHT) + ctx.font = `${TITLE_FONT_SIZE}px sans-serif` + ctx.textBaseline = 'top' + ctx.fillStyle = ACTIVE_COLOR + const label = fitLabel((t) => ctx.measureText(t).width, truncateTitle(spec.title), w - 4) + drawCenteredText(ctx, label, w, 2) + + // 5. Borders/rings: barTop green border; white ring marks the active tab. + if (spec.fill === 'barTop') { + drawRing(ctx, w, h, BAR_TOP_BORDER, 3, 0) + if (spec.active) drawRing(ctx, w, h, ACTIVE_COLOR, 2, 3) + } else if (spec.active) { + drawRing(ctx, w, h, ACTIVE_COLOR, 3, 0) + } +} +``` + +5. Thread `getIcon` through `renderKey`: + +```ts +export function renderKey( + spec: KeySpec, + caps: DeckCapabilities, + createCtx: CtxFactory, + getIcon: IconSource = () => null, +): Uint8ClampedArray { + // ... existing body; the 'tab' case becomes: drawTab(ctx, w, h, spec, getIcon) +} +``` + +6. In `src/components/VirtualDeckPanel.tsx`: add a no-op `drawImage() {}` to `noopCtx` (`:21-31`), and change the controller's renderer wiring (`:81-88`) to: + +```ts +renderKey: (spec, c) => renderKey(spec, c, safeCtxFactory, (url) => getIconImageCache().bitmapFor(url)), +``` + +with `import { getIconImageCache } from '@/deck/icon-image-cache'`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm run test:vitest -- run test/unit/client/deck/ test/unit/client/components/VirtualDeckPanel.test.tsx --config config/vitest/vitest.config.ts` +Expected: PASS (old drawTab ring/preview assertions were replaced in Step 1; VirtualDeckPanel tests assert DOM/store, unaffected). Then `npm run typecheck:client` — clean (this catches any other fake ctx missing `drawImage`). + +- [ ] **Step 5: Commit** + +```bash +git add src/deck/tile-renderer.ts src/components/VirtualDeckPanel.tsx test/unit/client/deck/tile-renderer.test.ts +git commit -m "feat(deck): tab-bar-matching tile rendering - fills, repo icons, status dot, active ring" +``` + +--- + +### Task 8: Controller — icon-cache wiring + preview path removal + +**Files:** +- Modify: `src/deck/deck-controller.ts` +- Test: `test/unit/client/deck/deck-controller.test.ts` + +**Interfaces:** +- Consumes: `IconImageCache`/`getIconImageCache` (Task 6), `renderKey` 4-arg form (Task 7). +- Produces: `DeckControllerOptions` gains `iconCache?: IconImageCache`; `previewFor` and the 3s preview repaint are gone (Task 9 deletes the registry module itself). + +- [ ] **Step 1: Write the failing tests** + +In `test/unit/client/deck/deck-controller.test.ts` (uses the spec-encoding renderer + `FakeDeckDevice` + fake timers): + +```ts +import { IconImageCache } from '@/deck/icon-image-cache' + +it('repaints keys when an icon bitmap finishes loading (cache subscription)', async () => { + // Deferred loader as in icon-image-cache.test.ts + const { loader, pending } = deferredLoader() + const cache = new IconImageCache(loader) + const { device } = setup({ + tabs: 1, + terminalMeta: { 'term-1': { cwd: '/repos/alpha' } }, + repoIcons: { '/repos/alpha': { status: 'ready', repoRoot: '/repos/alpha', repoName: 'alpha', hasIcon: true } }, + }, undefined, defaultSettings, { iconCache: cache }) + const before = decodeKey(device, 0)! + expect(before.kind === 'tab' && before.icons[0].ready).toBe(false) + pending.get(before.kind === 'tab' ? before.icons[0].url! : '')!.resolve({} as CanvasImageSource) + await vi.advanceTimersByTimeAsync(0) // flush the load microtask under fake timers + const after = decodeKey(device, 0)! + expect(after.kind === 'tab' && after.icons[0].ready).toBe(true) +}) + +it('no periodic preview repaint: 3s of ticks with unchanged state paints nothing new', () => { + const { device } = setup({ tabs: 1 }) + device.keyImages.clear() + vi.advanceTimersByTime(3_000) + expect(device.keyImages.size).toBe(0) +}) +``` + +Extend the suite's `setup()` helper to accept extra `DeckController` options (4th arg) and to seed `terminalMeta`/`repoIcons` preloaded state (mirror Task 3's fixture additions). Also update/remove the existing test that asserts the 3s preview refresh (the suite has diff-paint tests around `PREVIEW_REFRESH_TICKS`). + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm run test:vitest -- run test/unit/client/deck/deck-controller.test.ts --config config/vitest/vitest.config.ts` +Expected: FAIL — no `iconCache` option; icons never become ready; the 3s tick still repaints (registry snapshot reads). + +- [ ] **Step 3: Write the implementation** + +In `src/deck/deck-controller.ts`: + +1. Options + field: + +```ts +import { IconImageCache, getIconImageCache } from './icon-image-cache' +// DeckControllerOptions gains: +// iconCache?: IconImageCache +private readonly iconCache: IconImageCache +// constructor: +this.iconCache = options.iconCache ?? getIconImageCache() +``` + +2. Default renderer (where `options.renderKey` falls back to the real renderer) passes the cache: + +```ts +this.renderKeyFn = options.renderKey ?? + ((spec, caps) => renderKey(spec, caps, defaultCtxFactory, (url) => this.iconCache.bitmapFor(url))) +``` + +3. `start()`: subscribe; `stop()`: unsubscribe: + +```ts +this.unsubscribeIcons = this.iconCache.subscribe(() => this.repaint()) +// stop(): +this.unsubscribeIcons?.() +this.unsubscribeIcons = null +``` + +4. `repaint()`: replace `previewFor` with the real `iconReady`: + +```ts +const frame = buildFrame({ + model, caps, page: this.page, + actionLayer: this.actionLayerInputs(state), + iconReady: (url) => this.iconCache.bitmapFor(url) !== null, +}) +``` + +(`bitmapFor` both reports readiness and requests the load — first paint of a tile with an unloaded icon starts the fetch.) + +5. Delete `previewFor` (`:151-160`), the `getTerminalTextSnapshot` import (`:24`), the `PREVIEW_REFRESH_TICKS` constant (`:40`) and its branch in `tick()` (`:327-331`) — `tick()` keeps running `dutyChecks()` every 500ms for the action-layer timeout and idle dim. Remove `previewFor` from `buildFrame`'s input type in `frame.ts` and delete the `previewLines: previewFor(tab.id)` line — set `previewLines: []` for now (field dies in Task 9). Update `frame.test.ts` call sites to drop `previewFor`. + +6. Also remove the "ORDERING (load-bearing)" comment in `onStoreChange` referencing previews (`:164-170`) — the bail-out itself stays. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm run test:vitest -- run test/unit/client/deck/ test/e2e/stream-deck-flow.test.tsx --config config/vitest/vitest.config.ts` +Expected: controller tests PASS. The e2e suite's preview expectations now decode `previewLines: []` — update those expectations (full preview deletion lands in Task 9). Then `npm run typecheck:client` — clean. + +- [ ] **Step 5: Commit** + +```bash +git add src/deck/deck-controller.ts src/deck/frame.ts test/unit/client/deck/ test/e2e/stream-deck-flow.test.tsx +git commit -m "feat(deck): controller loads repo icons via IconImageCache and drops the preview repaint" +``` + +--- + +### Task 9: Remove dead preview + ring machinery + +**Files:** +- Delete: `src/deck/terminal-text-registry.ts`, `test/unit/client/deck/terminal-text-registry.test.tsx` +- Modify: `src/components/TerminalView.tsx` (remove import at ~`:103` and hook call at ~`:676-677`) +- Modify: `src/deck/frame.ts` (drop `previewLines`/`ring` from KeySpec; delete `RingColor`, `ringColor`; `stripText` from flags) +- Modify: `src/deck/tile-renderer.ts` (delete `PREVIEW_BG`→ replaced by `TILE_BG` already, `PREVIEW_TEXT_COLOR`, `PREVIEW_FONT_SIZE`, `PREVIEW_LINE_HEIGHT`, `PREVIEW_CHAR_WIDTH`, `PREVIEW_LEFT_MARGIN`, `previewGeometry`, `cropPreviewLines`, `RING_COLORS`) +- Modify: `src/deck/deck-selectors.ts` (delete `getTabRingStatus`, `TabRingStatus`, and the transitional `status` field on `DeckTab`) +- Test: `test/unit/client/deck/frame.test.ts`, `tile-renderer.test.ts`, `deck-selectors.test.ts`, `deck-controller.test.ts`, `test/e2e/stream-deck-flow.test.tsx` (fixture/expectation updates) + +**Interfaces:** +- Consumes: everything from Tasks 4–8 in place. +- Produces: final `KeySpec` tab variant `{ kind: 'tab'; tabId: string; title: string; active: boolean; fill: TileFill; dot: TileDot; icons: TileIcon[] }`; final `DeckTab` without `status`; `stripText` computes `busyCount = tabs.filter(t => t.busy).length`, `waitingCount = tabs.filter(t => t.attention).length`. + +- [ ] **Step 1: Write the failing tests (RED via deletion)** + +Update `frame.test.ts`: remove the `ringColor` describe block; remove `previewLines`/`ring` from every expected KeySpec; assert `stripText` still reports `X busy Y waiting` from the new flags: + +```ts +it('stripText counts busy and waiting from tab flags', () => { + const model = { + activeTabId: 't1', + tabs: [ + makeDeckTab({ id: 't1', title: 'alpha', active: true, busy: true }), + makeDeckTab({ id: 't2', attention: true }), + makeDeckTab({ id: 't3' }), + ], + } + expect(stripText(model, 1, 1)).toContain('1 busy 1 waiting') +}) +``` + +Update `tile-renderer.test.ts`: delete `previewGeometry`/`cropPreviewLines` tests; remove `previewLines`/`ring` from `tabSpec`. Update `deck-selectors.test.ts` expectations to drop `status`. Update `deck-controller.test.ts` and `stream-deck-flow.test.tsx` fixtures/expectations to the final KeySpec shape and remove `registerTerminalTextReader` / `resetTerminalTextRegistryForTests` usage. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm run test:vitest -- run test/unit/client/deck/ test/e2e/stream-deck-flow.test.tsx --config config/vitest/vitest.config.ts` +Expected: FAIL — produced KeySpecs still carry `previewLines`/`ring`, `status` still on model tabs. + +- [ ] **Step 3: Delete the machinery** + +- `frame.ts`: KeySpec tab variant → `{ kind: 'tab'; tabId: string; title: string; active: boolean; fill: TileFill; dot: TileDot; icons: TileIcon[] }`; delete `export type RingColor`, `export function ringColor`, remove `previewLines`/`ring` from `buildFrame`; `stripText` busy/waiting counts switch to `tab.busy` / `tab.attention`. +- `tile-renderer.ts`: delete the preview constants + `previewGeometry` + `cropPreviewLines` + `RING_COLORS` (keep `drawRing`, `ACTION_RING`, action/pager rendering; keep `TILE_BG` as the sole background constant — if `PREVIEW_BG` was still referenced anywhere, replace with `TILE_BG`). +- `deck-selectors.ts`: delete `getTabRingStatus`, `TabRingStatus`, and `status` from `DeckTab`/`selectDeckModel`. +- Delete `src/deck/terminal-text-registry.ts` and `test/unit/client/deck/terminal-text-registry.test.tsx` (`git rm`). +- `src/components/TerminalView.tsx`: remove the `useTerminalTextRegistration` import and the call (`// Register live terminal text reader for Stream Deck previews` block). + +- [ ] **Step 4: Verify — tests, dead-reference grep, typecheck** + +Run: `npm run test:vitest -- run test/unit/client/deck/ test/e2e/stream-deck-flow.test.tsx test/unit/client/components/VirtualDeckPanel.test.tsx --config config/vitest/vitest.config.ts` +Expected: PASS. + +Run: `grep -rn "terminal-text-registry\|registerTerminalTextReader\|getTerminalTextSnapshot\|useTerminalTextRegistration\|readXtermTail\|previewLines\|previewGeometry\|cropPreviewLines\|ringColor\|RingColor\|RING_COLORS\|getTabRingStatus\|TabRingStatus\|PREVIEW_REFRESH_TICKS" src/ test/ shared/` +Expected: **no matches** (confirms zero dead references; `/api/panes/:id/capture` in `server/` is untouched by design). + +Run: `npm run typecheck:client` — clean. Run: `npm run lint` — clean. + +- [ ] **Step 5: Commit** + +```bash +git add -A src/deck/ src/components/TerminalView.tsx test/ +git commit -m "refactor(deck): remove terminal preview machinery and status rings" +``` + +--- + +### Task 10: Press-down target snapshot (surprise-press guard) + +**Files:** +- Modify: `src/deck/deck-controller.ts` +- Test: `test/unit/client/deck/deck-controller.test.ts` + +**Interfaces:** +- Consumes: sorted `selectDeckModel` (Task 4), `planLayout`/`visibleTabs`/`clampPage`/`pageCount` (existing). +- Produces: `pressedAt: Map` with `type PressTarget = { kind: 'pager' } | { kind: 'tab'; tabId: string } | { kind: 'none' }` (private; observable behavior below). + +- [ ] **Step 1: Write the failing test** + +In `test/unit/client/deck/deck-controller.test.ts` (fake timers; store from the suite's fixture builder): + +```ts +it('acts on the tab displayed at press-down even if the sort changes mid-press', () => { + // t1 greenIcon (key 0), t2 greenIcon (key 1) + const { store, device } = setup({ tabs: 2, activeTab: 't1' }) + device.emit({ type: 'keyDown', keyIndex: 1 }) // user is pressing "t2" + // Mid-press: t2 gains attention -> re-sort moves t2 to key 0; key 1 now shows t1 + store.dispatch(markTabAttention('t2')) // use the suite's existing attention dispatch helper/action + vi.advanceTimersByTime(100) + device.emit({ type: 'keyUp', keyIndex: 1 }) + // Snapshot guard: the press focuses t2 (what the user saw), not t1 (what the slot shows now) + expect(store.getState().tabs.activeTabId).toBe('t2') +}) + +it('press on a tab that was closed mid-press is a no-op', () => { + const { store, device } = setup({ tabs: 2, activeTab: 't1' }) + device.emit({ type: 'keyDown', keyIndex: 1 }) + store.dispatch(closeTab('t2')) // suite's existing tab-close action + vi.advanceTimersByTime(100) + device.emit({ type: 'keyUp', keyIndex: 1 }) + expect(store.getState().tabs.activeTabId).toBe('t1') +}) + +it('long-press opens the action layer for the press-down tab despite a mid-press re-sort', () => { + const { store, device } = setup({ tabs: 2, activeTab: 't1' }) + device.emit({ type: 'keyDown', keyIndex: 1 }) + store.dispatch(markTabAttention('t2')) + vi.advanceTimersByTime(600) + device.emit({ type: 'keyUp', keyIndex: 1 }) + // Action layer shows BACK/APPROVE/STOP; verify it targets t2 via the frame or controller state + expect(decodeKey(device, 0)).toMatchObject({ kind: 'action', action: 'back' }) + // approve/stop targets resolve against t2 - assert via the suite's existing action-layer helpers +}) +``` + +Use whatever attention/close dispatch the suite already uses (the fixture builder seeds `turnCompletion.attentionByTab` — if there is no runtime action, dispatch the store's real `turnCompletion` slice action; check the slice's exported actions). The essential assertion: acting on key 1 after the re-sort affects **t2**. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm run test:vitest -- run test/unit/client/deck/deck-controller.test.ts --config config/vitest/vitest.config.ts` +Expected: FAIL — current code resolves slot→tab at release, so the press lands on t1. + +- [ ] **Step 3: Write the implementation** + +In `src/deck/deck-controller.ts`: + +```ts +type PressTarget = { kind: 'pager' } | { kind: 'tab'; tabId: string } | { kind: 'none' } +private pressedAt = new Map() + +// keyDown case in handleInput: +case 'keyDown': + this.pressedAt.set(event.keyIndex, { at: this.now(), target: this.resolveKeyTarget(event.keyIndex) }) + this.noteActivity() + break + +/** What this key DISPLAYS right now - captured at press-down so re-sorts can't retarget a press. */ +private resolveKeyTarget(keyIndex: number): PressTarget { + const model = selectDeckModel(this.store.getState()) + const plan = planLayout(this.device.capabilities, model.tabs.length) + if (plan.pagerKey !== null && keyIndex === plan.pagerKey) return { kind: 'pager' } + const slot = plan.tabSlots.indexOf(keyIndex) + if (slot === -1) return { kind: 'none' } + const pages = pageCount(model.tabs.length, plan.tabsPerPage) + const tab = visibleTabs(model.tabs, clampPage(this.page, pages), plan.tabsPerPage)[slot] + return tab ? { kind: 'tab', tabId: tab.id } : { kind: 'none' } +} + +private handleKeyUp(keyIndex: number): void { + const press = this.pressedAt.get(keyIndex) + this.pressedAt.delete(keyIndex) + this.noteActivity() + if (press === undefined) return + if (this.actionLayer) { + this.handleActionKey(keyIndex) + return + } + const duration = this.now() - press.at + if (press.target.kind === 'pager') { + const model = selectDeckModel(this.store.getState()) + const plan = planLayout(this.device.capabilities, model.tabs.length) + const pages = pageCount(model.tabs.length, plan.tabsPerPage) + this.page = this.page >= pages ? 1 : this.page + 1 + this.repaint() + return + } + if (press.target.kind !== 'tab') return + const tabId = press.target.tabId + const model = selectDeckModel(this.store.getState()) + if (!model.tabs.some((tab) => tab.id === tabId)) return // tab closed mid-press + if (duration >= LONG_PRESS_MS) { + this.actionLayer = { tabId, openedAt: this.now() } + this.repaint() + } else { + focusTabFromDeck(this.store, tabId) + this.repaint() + } +} +``` + +Note: when the action layer is open, `keyDown` still snapshots (harmlessly — a wrong-model target) but `handleKeyUp` branches to `handleActionKey` first, exactly as today. Action keys are fixed indices 0/1/2 and unaffected by sorting. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm run test:vitest -- run test/unit/client/deck/deck-controller.test.ts test/e2e/stream-deck-flow.test.tsx --config config/vitest/vitest.config.ts` +Expected: PASS (existing press/pager/long-press tests keep passing — same observable behavior when nothing changes mid-press). + +- [ ] **Step 5: Commit** + +```bash +git add src/deck/deck-controller.ts test/unit/client/deck/deck-controller.test.ts +git commit -m "feat(deck): snapshot key target at press-down so re-sorts cannot retarget a press" +``` + +--- + +### Task 11: E2E scenarios for the redesign + +**Files:** +- Modify: `test/e2e/stream-deck-flow.test.tsx` + +**Interfaces:** +- Consumes: everything above through the REAL store + REAL `DeckController` + `FakeDeckDevice` + spec-encoding renderer; `IconImageCache` with a deferred fake loader; fixture-builder extensions from Tasks 2–4 (`paneStatus`, `terminalMeta`, `repoIcons` seeding — port them into this suite's `makeDeckStore`). +- Produces: user-story coverage for the redesign. + +- [ ] **Step 1: Write the new scenarios (they must fail only if the feature regresses — write them, run, expect PASS since Tasks 1–10 landed; any failure here is a real integration bug to fix before commit)** + +Add these scenarios (full code, following the suite's existing `setup()`/`decodeKey` style): + +```ts +it('keys are sorted by status priority and stable within groups', () => { + // 5 tabs: t1 exited(rest), t2 busy(blue), t3 idle-running(green icon), + // t4 attention(green fill), t5 active+attention(barTop) + const { device } = setup({ + tabs: 5, activeTab: 't5', + paneStatus: { p1: 'exited' }, busy: ['term-2'], attention: { t4: true, t5: true }, + }) + const ids = [0, 1, 2, 3, 4].map((k) => { + const spec = decodeKey(device, k) + return spec?.kind === 'tab' ? spec.tabId : null + }) + expect(ids).toEqual(['t5', 't4', 't3', 't2', 't1']) +}) + +it('tiles carry the three background treatments and the active ring flag', () => { + const { device } = setup({ tabs: 3, activeTab: 't1', attention: { t1: true, t2: true } }) + expect(decodeKey(device, 0)).toMatchObject({ tabId: 't1', fill: 'barTop', active: true }) + expect(decodeKey(device, 1)).toMatchObject({ tabId: 't2', fill: 'green', active: false }) + expect(decodeKey(device, 2)).toMatchObject({ tabId: 't3', fill: 'none', active: false }) +}) + +it('busy and idle-running tabs expose blue/green dots', () => { + const { device } = setup({ tabs: 2, busy: ['term-2'] }) + expect(decodeKey(device, 0)).toMatchObject({ tabId: 't1', dot: 'green' }) // idle running + expect(decodeKey(device, 1)).toMatchObject({ tabId: 't2', dot: 'blue' }) // busy sorts after green +}) + +it('repo icons: unready at first paint, repaint to ready when the bitmap loads', async () => { + const { loader, pending } = deferredLoader() + const cache = new IconImageCache(loader) + const { device } = setup({ + tabs: 1, + terminalMeta: { 'term-1': { cwd: '/repos/alpha' } }, + repoIcons: { '/repos/alpha': { status: 'ready', repoRoot: '/repos/alpha', repoName: 'alpha', hasIcon: true } }, + }, undefined, defaultSettings, { iconCache: cache }) + const before = decodeKey(device, 0) + expect(before).toMatchObject({ icons: [{ letter: 'A', ready: false }] }) + pending.get((before as Extract).icons[0].url!)!.resolve({} as CanvasImageSource) + await vi.advanceTimersByTimeAsync(0) + expect(decodeKey(device, 0)).toMatchObject({ icons: [{ letter: 'A', ready: true }] }) +}) + +it('pager pages over the SORTED order', () => { + // 8 tabs on a 6-key Mini -> 5 tab slots + pager. Make t8 attention: it must appear on page 1 key 0. + const { device } = setup({ tabs: 8, attention: { t8: true } }) + expect(decodeKey(device, 0)).toMatchObject({ kind: 'tab', tabId: 't8' }) + expect(decodeKey(device, 5)).toMatchObject({ kind: 'pager', page: 1, pageCount: 2 }) + device.press(5) // next page + // Sorted order: t8,t1,t2,t3,t4 on page 1 (5 tab slots); t5,t6,t7 on page 2. + expect(decodeKey(device, 0)).toMatchObject({ kind: 'tab', tabId: 't5' }) +}) + +it('a mid-press re-sort does not retarget the press (e2e)', () => { + const { store, device } = setup({ tabs: 2, activeTab: 't1' }) + device.emit({ type: 'keyDown', keyIndex: 1 }) + store.dispatch(/* the suite's real turnCompletion attention action for t2 */) + vi.advanceTimersByTime(100) + device.emit({ type: 'keyUp', keyIndex: 1 }) + expect(store.getState().tabs.activeTabId).toBe('t2') +}) + +it('short-press focuses, long-press opens the action layer - on the sorted layout', () => { + const { store, device } = setup({ tabs: 3, attention: { t3: true } }) // t3 sorts to key 0 + device.press(0) + expect(store.getState().tabs.activeTabId).toBe('t3') + holdKey(device, 1, 600) // long-press whatever now occupies key 1 + expect(decodeKey(device, 0)).toMatchObject({ kind: 'action', action: 'back' }) +}) +``` + +Also review the 9 existing scenarios: update key indices for sorted order where fixtures produce mixed statuses, and confirm the idle-dim, dial, STOP-escalation, and teardown scenarios still pass unmodified (they are order-agnostic or now operate on the sorted model by design). + +- [ ] **Step 2: Run the suite** + +Run: `npm run test:vitest -- run test/e2e/stream-deck-flow.test.tsx --config config/vitest/vitest.config.ts` +Expected: PASS, all scenarios. Fix any real integration bug this surfaces before committing. + +- [ ] **Step 3: Commit** + +```bash +git add test/e2e/stream-deck-flow.test.tsx +git commit -m "test(deck): e2e coverage for sorted keys, background states, repo icons, press snapshot" +``` + +--- + +### Task 12: Full verification sweep + +**Files:** +- Modify (only if issues found): any of the above; possibly `docs/index.html`. + +- [ ] **Step 1: Focused full deck run** + +```bash +npm run test:vitest -- run test/unit/client/deck/ test/e2e/stream-deck-flow.test.tsx \ + test/unit/client/components/VirtualDeckPanel.test.tsx \ + test/unit/client/components/settings/StreamDeckSettings.test.tsx \ + test/unit/shared/settings.stream-deck.test.ts \ + --config config/vitest/vitest.config.ts +``` +Expected: all PASS. + +- [ ] **Step 2: Typecheck + lint** + +Run: `npm run typecheck:client` — clean. Run: `npm run lint` — clean (jsx-a11y included; `VirtualDeckPanel` buttons already carry `aria-label`s — verify nothing regressed). + +- [ ] **Step 3: TerminalView regression check** + +Run the terminal view's own tests (locate with `ls test/unit/client/components/ | grep -i terminal`) to confirm the hook removal broke nothing: +`npm run test:vitest -- run test/unit/client/components/TerminalView* --config config/vitest/vitest.config.ts` (adjust to actual filenames; if none exist, note it and move on). + +- [ ] **Step 4: Coordinated broad run** + +```bash +npm run test:status # respect the gate; wait if held +FRESHELL_TEST_SUMMARY="deck tile redesign" npm run check +``` +Expected: typecheck + full coordinated suite green. Never kill a foreign gate holder; wait instead. + +- [ ] **Step 5: docs/index.html check** + +AGENTS.md requires updating `docs/index.html` for significant UI changes. Search it for Stream Deck tile descriptions (`grep -in "stream deck\|deck" docs/index.html`). If it describes the old tile design (terminal previews / status rings), update that copy to the new design (title + repo icons + status backgrounds + priority sorting); if it only mentions the feature generically, no change needed. + +- [ ] **Step 6: Commit any fixes** + +```bash +git add -A && git commit -m "chore(deck): verification sweep fixes for tile redesign" # only if changes exist +``` + +Do NOT create a PR — stop after committing; PR creation requires explicit user approval. + +--- + +## Self-review record + +**1. Spec coverage:** +- Title on top (unchanged banner) → Task 7 step 3 (§4 of `drawTab`). +- Repo icons centered, tab-bar pipeline reuse, cap-3 → Tasks 3, 5, 7; async load + cache + fallback → Tasks 6, 7, 8; no-repo tab renders title-only → Task 7 (`icons: []` → no draws) + Task 3 test. +- Preview removal + dead machinery deletion (registry, TerminalView hook, 3s repaint, preview constants) with `/capture` untouched → Tasks 8, 9 (grep gate in Task 9 step 4). +- Three background treatments driven by shared tab-bar conditions + exact color mapping → Tasks 1, 2, 7 (colors from verified `theme-variables.css` / Tailwind values). +- Icon tinting: investigation showed the tab bar never tints repo icons (only pane icons); green/blue visibility on deck is delivered via the status dot with the same conditions/colors → Design decision 4, Tasks 1, 7. +- Active tab white ring kept → Task 7 (§5) + tests. +- Sorting with stable within-group order, pager over sorted list, dials on sorted order → Tasks 4, 11. +- Surprise-press guard (verified: NO existing snapshot; added) → Task 10 + e2e in Task 11. +- Virtual deck shares renderer (verified) → Task 7 wiring + Task 12 tests. +- Client-only; tab bar unchanged (read-only reuse; `hueFromString` fallback note in Task 3 keeps a single implementation). +- Unit + e2e coverage for sort priority and the three backgrounds → Tasks 1, 4 (unit), 11 (e2e). Lint/typecheck/coordinated suite → Task 12. + +**1b. No silent deferrals:** The only test doubles are the established suite seams (spec-encoding renderer, `FakeDeckDevice`, fake icon loader) — the same doubles the merged feature already ships with; production behavior (real canvas via `defaultCtxFactory`, real `Image` loader, WebHID transport) is exercised at runtime and unchanged in kind. No requirement is stubbed without a production path: the default `IconLoader` uses a real `Image` (Task 6), and the default renderer path passes the real cache (Task 8 step 3.2). No "known limitations" introduced. + +**2. Placeholder scan:** Two intentional adapt-to-fixture instructions remain (Task 2/3: "match the fixture builder actually present in the file") — these are file-drift guards with concrete fallback code shown, not deferrals. The Task 3 cap/dedupe test body is specified by exact expected behavior with construction guidance; implementer writes the fixture wiring. All other steps carry full code. + +**3. Type consistency check:** `TileFill`/`TileDot`/`TabStatusFlags` (Task 1) ← used in Tasks 2, 4, 5, 7. `TileRepoIcon { url, letter, hue }` (Task 3) → `TileIcon = TileRepoIcon & { ready }` shape (Task 5) → renderer reads `icon.url/letter/hue/ready` (Task 7). `iconReady(url) => boolean` named consistently (Tasks 5, 8). `IconImageCache.bitmapFor/subscribe` consistent (Tasks 6, 7, 8, 11). `DeckTab` transitional `status` added Task 4, removed Task 9 — both sides documented. `renderKey` 4-arg signature consistent (Tasks 7, 8, VirtualDeckPanel). From e8b2950ce21d87200059c58c80e99a96fabe08ba Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 02:35:14 -0700 Subject: [PATCH 02/30] docs(deck): harden tile-redesign plan with load-bearing validation findings - deck owns un-gated fetchRepoIconMeta probing (TabBar probe is setting-gated and conditionally mounted; leader window may lack TabBar) - panesForTab synthesized-pane fallback for layout-less tabs (addTab never seeds a layout), mirroring TabBar.tsx:203-221 - IconImageCache drawn-empty probe (<1% alpha) for dimensionless SVGs that load but draw blank; explicit-dims drawImage rule in the renderer - verified jsdom NoOpResourceLoader constraints encoded in test guidance - Rust-server terminal-meta coverage documented as accepted tab-bar parity --- docs/plans/2026-07-29-deck-tile-redesign.md | 271 +++++++++++++++++--- 1 file changed, 236 insertions(+), 35 deletions(-) diff --git a/docs/plans/2026-07-29-deck-tile-redesign.md b/docs/plans/2026-07-29-deck-tile-redesign.md index 7eba0780f..f110eaf3e 100644 --- a/docs/plans/2026-07-29-deck-tile-redesign.md +++ b/docs/plans/2026-07-29-deck-tile-redesign.md @@ -36,22 +36,24 @@ These facts were verified by direct code reading; task steps cite them. Implemen - **Blue icon** ⟺ `busyPaneIds.includes(paneId)` → `text-blue-500` `#3b82f6`. - Repo icons are **never tinted** in the tab bar (`TabItem.tsx:133` passes no color class); pane icons are tinted via `currentColor`. No CSS filters anywhere. -**Repo icon pipeline**: per-pane cwd via `resolvePaneRepoCwd(content, tab, state.terminalMeta.byTerminalId)` (`src/lib/repo-icon.ts:13-27`); probed meta cached at `state.repoIcons.byCwd` as `RepoIconEntry { status: 'loading'|'ready'|'error'; repoRoot?; checkoutRoot?; repoName?; hasIcon? }` (`src/store/repoIconsSlice.ts:5-11`); real icon = `` (`/api/repo-icon?cwd=…`), fallback = letter avatar with `hsl(hueFromString(repoName), 60%, 42%)` circle + white letter (`src/components/icons/RepoIcon.tsx:33-65`; `hueFromString` exported at `:19`). Distinct repo icons cap at 3 (`MAX_REPO_ICONS = 3`, `TabItem.tsx:35`), silently truncated. `TabBar.tsx` (~`:229-242`) dispatches `fetchRepoIconMeta(cwd)` probes for all visible tabs' panes — the tab bar is always mounted in the app shell, so the deck can read `state.repoIcons.byCwd` without probing. +**Repo icon pipeline**: per-pane cwd via `resolvePaneRepoCwd(content, tab, state.terminalMeta.byTerminalId)` (`src/lib/repo-icon.ts:13-27`); probed meta cached at `state.repoIcons.byCwd` as `RepoIconEntry { status: 'loading'|'ready'|'error'; repoRoot?; checkoutRoot?; repoName?; hasIcon? }` (`src/store/repoIconsSlice.ts:5-11`); real icon = `` (`/api/repo-icon?cwd=…`), fallback = letter avatar with `hsl(hueFromString(repoName), 60%, 42%)` circle + white letter (`src/components/icons/RepoIcon.tsx:33-65`; `hueFromString` exported at `:19`). Distinct repo icons cap at 3 (`MAX_REPO_ICONS = 3`, `TabItem.tsx:35`), silently truncated. **Probing (corrected by validation):** `TabBar.tsx:240` is the ONLY `fetchRepoIconMeta` dispatcher in the app, gated at `:230` by `if (!repoIconsOnTabs) return` (setting defaults true, `:189`), and TabBar is *conditionally* mounted (`App.tsx:1644` — hidden in the mobile-landscape terminal view) while deck leader election (`deck-manager.ts:166-203`; the lock-less fallback makes every window an auto-leader) can elect a window whose TabBar is unmounted. The deck therefore CANNOT rely on TabBar to populate `state.repoIcons.byCwd`: the DeckController owns its own probe, un-gated by `repoIconsOnTabs` (Task 8). Double-probing alongside TabBar is harmless — the thunk self-dedupes via its `condition` guard (`repoIconsSlice.ts:36-40`). + +**Rust-server terminal-meta coverage (accepted parity limitation):** on the shipping Rust server the `terminal.inventory` handshake carries no terminal meta (`freshell-ws/lib.rs:465-468` hard-codes an empty `terminal_meta`), and only create-time pushes carry a bare `cwd`. `resolvePaneRepoCwd` therefore resolves via the `initialCwd` fallback for coding-CLI/fresh-agent panes and resolves nothing for plain-shell panes. Deck icon coverage on Rust thus EQUALS the tab bar's existing coverage (same resolver, degrades identically); tiles without a resolvable cwd render title-only by design. This is an accepted, documented parity limitation — not a bug this plan fixes. **Deck internals** (`src/deck/`): `KeySpec` in `frame.ts:6-10` (tab variant: `{ kind:'tab'; tabId; title; previewLines; ring; active }`); `renderKey(spec, caps, createCtx)` with narrow `Ctx2D = Pick + {fillStyle,font,textBaseline}` (`tile-renderer.ts:8-13`) — **no `drawImage`**; per-key paint cache is `JSON.stringify(spec)` (`deck-controller.ts:126`) so *anything a tile draws must be a KeySpec field*; controller repaint bail-out is `JSON.stringify(selectDeckModel(state))` (`deck-controller.ts:164-176`); preview machinery is `terminal-text-registry.ts` (xterm buffer readers) with sole producer `TerminalView.tsx:103,676-677` and sole consumer `DeckController.previewFor` (`deck-controller.ts:151-160,24`) — **nothing else uses it**; `keyDown` stores only a timestamp (`deck-controller.ts:185-188`) and `handleKeyUp` re-resolves slot→tab from live state at release (`:204-234`) — **no press snapshot exists today**; `selectDeckModel` maps `state.tabs.tabs` verbatim (no sort anywhere); `buildFrame` (`frame.ts:87-113`) assigns keys via `planLayout` + `visibleTabs`; pager = last key when `tabCount > keyCount`, Deck+ pages via dial 1; `VirtualDeckPanel.tsx` uses the same `renderKey` + a real `DeckController` over `FakeDeckDevice` (`:11,81-88`), with `noopCtx`/`safeCtxFactory` (`:21-38`). -**Test landscape**: unit tests in `test/unit/client/deck/`, e2e (fake transport, Vitest not Playwright) in `test/e2e/stream-deck-flow.test.tsx`; renderer tests use a `recordingCtx()` drawing-call spy; controller/e2e tests use spec-encoding renderers (`encodeSpec`/`decodeKey` — pixels are KeySpec JSON); jsdom canvas `getContext` is stubbed to `null`; **no image-loading mock exists** (jsdom `Image` never fires `onload` — tests must inject a fake loader); `makeDeckStore(opts)` fixture builder is deliberately duplicated in `deck-controller.test.ts`, `stream-deck-flow.test.tsx`, `VirtualDeckPanel.test.tsx`. +**Test landscape**: unit tests in `test/unit/client/deck/`, e2e (fake transport, Vitest not Playwright) in `test/e2e/stream-deck-flow.test.tsx`; renderer tests use a `recordingCtx()` drawing-call spy; controller/e2e tests use spec-encoding renderers (`encodeSpec`/`decodeKey` — pixels are KeySpec JSON); jsdom canvas `getContext` is stubbed to `null`; **no image-loading mock exists** — verified: with this vitest config (no `environmentOptions`), Vitest constructs JSDOM with `resources: undefined`, so jsdom 25.0.1 uses `NoOpResourceLoader`: `Image`s never fetch, never fire `load`/`error`, never complete. Consequences: (a) tests MUST inject the fake loader for any post-load assertion (default-loader promises pend forever in jsdom — harmless but never resolving); (b) `IconImageCache` error paths must be silent (no `console.error`/`console.warn` — `console.error` is fatal in tests); (c) nobody may add `environmentOptions.jsdom.resources` or `userAgent` to the vitest config — either silently enables real fetching and would break these suites. `makeDeckStore(opts)` fixture builder is deliberately duplicated in `deck-controller.test.ts`, `stream-deck-flow.test.tsx`, `VirtualDeckPanel.test.tsx`. ## Design decisions (settled — carry through all tasks) 1. **Sort lives in `selectDeckModel`.** The spec says short-press, long-press, dials, paging all "operate on the sorted order" — sorting the model gives that everywhere for free (dial-0 tab cycling included), and the model-JSON bail-out repaints automatically on re-sorts. Sort is stable (`Array.prototype.sort` is spec-stable): priority ascending, tab-bar order preserved within groups. 2. **Priority buckets** (0 = leftmost keys): 0 bar-on-top (`attention && active`), 1 green-filled (`attention && !active`), 2 green-icon (not busy, has a running pane), 3 blue-icon (any busy pane), 4 rest. A tab with both busy and green panes classifies **blue-icon** (busy dominates: "still working"). Attention is gated on `tabAttentionStyle !== 'none'` (mirroring the tab bar: with `'none'` the bar/fill states don't exist). With style `'darken'` the tab bar shows a darkened treatment instead of green; the deck keeps its single green palette (the *condition* is shared; the deck has one fixed skin). -3. **Green-icon condition is the tab bar's literal condition** (`status === 'running'` and not busy, non-terminal panes always `'running'`). Consequence: most healthy idle tabs are bucket 2 and bucket 4 holds only tabs whose panes are all exited/error/creating (or tabs with no panes). This is faithful to the tab bar's own coloring — do not "improve" it. +3. **Green-icon condition is the tab bar's literal condition** (`status === 'running'` and not busy, non-terminal panes always `'running'`). Consequence: most healthy idle tabs are bucket 2 and bucket 4 holds only tabs whose panes are all exited/error/creating (or tabs with no panes). This is faithful to the tab bar's own coloring — do not "improve" it. Tabs with no `state.panes.layouts` entry are a real transient (`addTab` never seeds a layout; `PaneLayout.tsx:30-35` initializes it post-paint) and classify via `panesForTab`'s synthesized single pane from `tab.mode`/`tab.status` (Task 2), mirroring `TabBar.tsx:203-221` — so "tabs with no panes" means genuinely mode-less tabs only. 4. **Status dot instead of tinted icons.** The tab bar never tints *repo* icons — green/blue tinting applies to *pane* icons. The deck centers repo icons (per spec), so the green-icon/blue-icon states are made visible with a small status dot (bottom-center of the tile) using the exact tab-bar tint colors (`#21c45d` / `#3b82f6`) and the exact same conditions. This mirrors the tab bar's own `StatusDot` fallback vocabulary (`fill-success` / `fill-blue-500`). 5. **Backgrounds:** `none` → `#0a0a0a` (existing near-black); `green` (green-filled state) → solid light green `#a7f3d0` (emerald-200 — recognizably the tab bar's emerald attention fill, tuned for the small LCD); `barTop` (bar-on-top state) → same light green fill **plus** a 3px `#21c45d` border ring (the tab bar's `--success` bar color). Active tab keeps a white ring: 3px at inset 0 normally, 2px at inset 3 when the barTop border occupies inset 0 (matching today's status+active ring nesting). 6. **Status rings are removed entirely**, including the amber pending-approval ring — the spec replaces rings with the three-state background and doesn't map amber. Pending approval still works via the long-press action layer (`findApproveTarget` untouched). -7. **Repo icons on tiles ignore `settings.panes.repoIconsOnTabs`** (a tab-bar clutter preference; the deck tile needs its center glyph) and derive from **all** panes in the tab (distinct repos, first-appearance order, cap 3) — the tab bar additionally only considers the first 3 pane icons when picking repo groups; the deck follows the headline "cap repo icons at 3" rule. Resolution logic (cwd → meta → url/letter/hue) is shared, not reimplemented. -8. **Icon bitmaps:** singleton `IconImageCache` with injectable loader; while loading or on failure the renderer draws the letter avatar (hue swatch + white letter — canvas analogue of `RepoIcon`'s SVG circle; drawn as a square to keep `Ctx2D` minimal). A tab with no repo info renders title-only (banner + fill + dot + rings). Icon readiness is a KeySpec field (`ready`) so loads trigger repaints through the per-key diff; the controller subscribes to the cache and repaints on load completion. +7. **Repo icons on tiles ignore `settings.panes.repoIconsOnTabs`** (a tab-bar clutter preference; the deck tile needs its center glyph) and derive from **all** panes in the tab (distinct repos, first-appearance order, cap 3) — the tab bar additionally only considers the first 3 pane icons when picking repo groups; the deck follows the headline "cap repo icons at 3" rule. Resolution logic (cwd → meta → url/letter/hue) is shared, not reimplemented. **The deck OWNS its own icon-meta probing:** the DeckController dispatches `fetchRepoIconMeta(cwd)` for every distinct resolved cwd of the tabs it renders, UN-gated by `repoIconsOnTabs` (Task 8) — this is what makes this decision actually deliverable, since `TabBar.tsx:240` is the app's only other dispatcher, is gated on that very setting (`:230`), and is conditionally mounted (`App.tsx:1644`). Double-probing alongside TabBar is harmless: the thunk self-dedupes (`repoIconsSlice.ts:36-40`). +8. **Icon bitmaps:** singleton `IconImageCache` with injectable loader; while loading or on failure (load error, **or** the cache's post-load drawn-empty probe detecting a blank draw — Task 6) the renderer draws the letter avatar (hue swatch + white letter — canvas analogue of `RepoIcon`'s SVG circle; drawn as a square to keep `Ctx2D` minimal). A tab with no repo info renders title-only (banner + fill + dot + rings). Icon readiness is a KeySpec field (`ready`) so loads trigger repaints through the per-key diff; the controller subscribes to the cache and repaints on load completion. 9. **Press-snapshot guard:** `keyDown` resolves and stores the key's target (`pager` / `tab tabId` / `none`); `keyUp` acts on the snapshot, so a re-sort between press-down and press-up acts on the tab that was displayed at press-down. If the snapshot tab no longer exists at release, the press is a no-op. 10. **Idle dimming, multi-window locking, action layer, dials: unchanged** (they now simply see the sorted model). Re-sort repaints waking a dimmed deck is pre-existing behavior for any repaint (`deck-controller.ts:138`) and stays as-is. @@ -60,11 +62,11 @@ These facts were verified by direct code reading; task steps cite them. Implemen | File | Change | Responsibility | |---|---|---| | `src/deck/tile-state.ts` | **Create** | Pure per-tab tile classification: `TileFill`, `TileDot`, `TabStatusFlags`, `tileFill()`, `tileDot()`, `tilePriority()` | -| `src/deck/icon-image-cache.ts` | **Create** | Singleton async bitmap cache for repo icons (injectable loader, subscribe/notify, permanent-failure caching) | -| `src/deck/deck-selectors.ts` | Modify | `getTabStatusFlags`, `getTabRepoIcons`, reshaped + sorted `selectDeckModel`; delete `getTabRingStatus`/`TabRingStatus` at cleanup | +| `src/deck/icon-image-cache.ts` | **Create** | Singleton async bitmap cache for repo icons (injectable loader, subscribe/notify, permanent-failure caching, runtime drawn-empty probe for blank-drawing SVGs) | +| `src/deck/deck-selectors.ts` | Modify | `panesForTab` (layout-or-synthesized pane entries), `getTabStatusFlags`, `getTabRepoIcons`, reshaped + sorted `selectDeckModel`; delete `getTabRingStatus`/`TabRingStatus` at cleanup | | `src/deck/frame.ts` | Modify | `KeySpec` tab variant gains `fill`/`dot`/`icons`, loses `previewLines`/`ring`; `buildFrame` takes `iconReady` instead of `previewFor`; `ringColor`/`RingColor` deleted; `stripText` counts from flags | | `src/deck/tile-renderer.ts` | Modify | New `drawTab` (fill, icons, dot, banner, rings); `Ctx2D` gains `drawImage`; `iconLayout()`; preview constants/helpers deleted | -| `src/deck/deck-controller.ts` | Modify | Icon-cache wiring (iconReady + subscribe→repaint + getIcon into default renderer), preview path removal, press-down target snapshot | +| `src/deck/deck-controller.ts` | Modify | Icon-cache wiring (iconReady + subscribe→repaint + getIcon into default renderer), un-gated `fetchRepoIconMeta` probe dispatch per resolved cwd, preview path removal, press-down target snapshot | | `src/deck/terminal-text-registry.ts` | **Delete** | Dead preview machinery (sole consumer was the deck) | | `src/components/TerminalView.tsx` | Modify | Remove `useTerminalTextRegistration` hook call + import (lines ~103, ~676-677) | | `src/components/VirtualDeckPanel.tsx` | Modify | `noopCtx` gains `drawImage`; renderer closure passes the icon cache | @@ -77,8 +79,8 @@ These facts were verified by direct code reading; task steps cite them. Implemen | `test/unit/client/deck/terminal-text-registry.test.tsx` | **Delete** | With its module | | `test/e2e/stream-deck-flow.test.tsx` | Modify | Updated KeySpec expectations; new scenarios: sort priority, three backgrounds, icon fallback→ready, sorted paging, mid-press re-sort | -Interfaces consumed from outside `src/deck/` (read-only, all verified to exist): -`getBusyPaneIdsForTab` (`@/lib/pane-activity`), `collectPaneEntries(node: PaneNode): Array<{ paneId: string; content: PaneContent }>` (`@/lib/pane-utils:72-80`), `resolvePaneRepoCwd`, `pathBasename`, `buildRepoIconUrl` (`@/lib/repo-icon`), `hueFromString` (`@/components/icons/RepoIcon`), `state.terminalMeta.byTerminalId`, `state.repoIcons.byCwd`, `state.turnCompletion.attentionByTab`, `state.settings.settings.panes.tabAttentionStyle`. +Interfaces consumed from outside `src/deck/` (all verified to exist; read-only **except** the one dispatch noted below): +`getBusyPaneIdsForTab` (`@/lib/pane-activity`), `collectPaneEntries(node: PaneNode): Array<{ paneId: string; content: PaneContent }>` (`@/lib/pane-utils:72-80`), `resolvePaneRepoCwd`, `pathBasename`, `buildRepoIconUrl` (`@/lib/repo-icon`), `hueFromString` (`@/components/icons/RepoIcon`), `state.terminalMeta.byTerminalId`, `state.repoIcons.byCwd`, `state.turnCompletion.attentionByTab`, `state.settings.settings.panes.tabAttentionStyle`. The deck also **dispatches** `fetchRepoIconMeta` from `@/store/repoIconsSlice` (Task 8) — the sole store write the deck performs; the thunk's own `condition` guard (`repoIconsSlice.ts:36-40`) makes it idempotent per cwd, so the deck stays a pure reader of everything else. --- @@ -222,7 +224,7 @@ git commit -m "feat(deck): pure tile classification - fill, dot, and sort priori **Interfaces:** - Consumes: `TabStatusFlags` from Task 1; existing private `activityInputs(state)` helper (`deck-selectors.ts:13-22`); `getBusyPaneIdsForTab` from `@/lib/pane-activity`; `collectPaneEntries` from `@/lib/pane-utils` (already imported in this file for `tabHasPendingApproval` — verify the import list at the top of the file and add it if it's imported elsewhere). -- Produces: `getTabStatusFlags(state: RootState, tab: Tab): TabStatusFlags` — exact per-tab busy/attention/greenIcon derivation reused by Task 4. +- Produces: `getTabStatusFlags(state: RootState, tab: Tab): TabStatusFlags` — exact per-tab busy/attention/greenIcon derivation reused by Task 4 — and `panesForTab(state: RootState, tab: Tab): Array<{ paneId: string; content: PaneContent }>` — layout-or-synthesized pane entries, reused by Task 3 (`getTabRepoIcons`) and Task 8 (probe dispatch). - [ ] **Step 1: Write the failing test** @@ -262,9 +264,22 @@ describe('getTabStatusFlags', () => { const state = store.getState() expect(getTabStatusFlags(state, state.tabs.tabs[0]).greenIcon).toBe(false) }) + + it('tab with NO pane layout classifies from the synthesized pane (tab.mode/tab.status), matching the tab bar', () => { + // Real transient: addTab (tabsSlice.ts:296) never seeds a layout — PaneLayout.tsx:30-35 + // initializes it in a post-paint useEffect, persisted-state restore can omit layout entries, + // and the deck repaints synchronously per dispatch, so it WILL paint layout-less tabs. + const store = makeStore({ tabs: 1 }) + const state = store.getState() + const tab = state.tabs.tabs[0] // fixture tab: mode 'claude', status 'running' + const noLayout = { ...state, panes: { ...state.panes, layouts: {} } } as typeof state + expect(getTabStatusFlags(noLayout, tab)).toEqual({ busy: false, attention: false, greenIcon: true }) + }) }) ``` +If the fixture builder's tabs lack `mode`/`status` fields, extend it to set them (`mode: 'claude', status: 'running'`) — the synthesis fallback reads the tab's own fields, exactly like `TabBar.tsx:203-221`. + If the suite's fixture builder has no `paneStatus` option, extend it: it constructs `TerminalPaneContent` leaves — add `status: opts.paneStatus?.[paneId] ?? 'running'`. Add a small local `withTabAttentionStyle(state, style)` helper that returns a state copy with `settings.settings.panes.tabAttentionStyle` overridden (structured clone + assignment is fine for a test). - [ ] **Step 2: Run test to verify it fails** @@ -279,6 +294,33 @@ In `src/deck/deck-selectors.ts`, add (import `collectPaneEntries` from `@/lib/pa ```ts import type { TabStatusFlags } from './tile-state' +/** + * Pane entries for a tab, tolerant of layout-less tabs. This transient is REAL: + * addTab (tabsSlice.ts:296) never seeds a layout — PaneLayout.tsx:30-35 initializes + * it in a post-paint useEffect, and persisted-state restore can omit layout entries — + * while the deck repaints synchronously per dispatch, so it WILL paint such tabs. + * Mirrors the tab bar's live synthesis fallback (TabBar.tsx:203-221): synthesize a + * single terminal pane from the tab's own fields. Do NOT touch TabBar; this is the + * deck-local twin of that fallback. + */ +export function panesForTab(state: RootState, tab: Tab): Array<{ paneId: string; content: PaneContent }> { + const layout = state.panes.layouts[tab.id] + if (layout) return collectPaneEntries(layout) + if (!tab.mode) return [] + return [{ + paneId: tab.id, + content: { + kind: 'terminal' as const, + mode: tab.mode, + shell: tab.shell, + createRequestId: tab.createRequestId, + status: tab.status, + sessionRef: tab.sessionRef, + initialCwd: tab.initialCwd, + }, + }] +} + /** * Per-tab status flags, derived from the SAME conditions the tab bar uses: * - busy: any pane busy (getBusyPaneIdsForTab, TabBar.tsx:329-338) @@ -293,8 +335,7 @@ export function getTabStatusFlags(state: RootState, tab: Tab): TabStatusFlags { paneLayouts: state.panes.layouts as Record, ...activityInputs(state), }) - const layout = state.panes.layouts[tab.id] - const entries = layout ? collectPaneEntries(layout) : [] + const entries = panesForTab(state, tab) // layout entries, or the synthesized single pane const greenIcon = entries.some(({ paneId, content }) => { if (busyIds.includes(paneId)) return false const status = content.kind === 'terminal' ? content.status : 'running' @@ -332,7 +373,7 @@ git commit -m "feat(deck): getTabStatusFlags - busy/attention/greenIcon from the - Test: `test/unit/client/deck/deck-selectors.test.ts` **Interfaces:** -- Consumes: `resolvePaneRepoCwd(content, tab, terminalMetaById)`, `pathBasename`, `buildRepoIconUrl` from `@/lib/repo-icon`; `hueFromString` from `@/components/icons/RepoIcon`; `collectPaneEntries`; `state.terminalMeta.byTerminalId`; `state.repoIcons.byCwd` (`RepoIconEntry`). +- Consumes: `resolvePaneRepoCwd(content, tab, terminalMetaById)`, `pathBasename`, `buildRepoIconUrl` from `@/lib/repo-icon`; `hueFromString` from `@/components/icons/RepoIcon`; `panesForTab` (Task 2 — layout-or-synthesized pane entries); `state.terminalMeta.byTerminalId`; `state.repoIcons.byCwd` (`RepoIconEntry`). - Produces: `type TileRepoIcon = { url: string | null; letter: string; hue: number }` and `getTabRepoIcons(state: RootState, tab: Tab): TileRepoIcon[]` (max 3, distinct repos, first-appearance order) — consumed by Task 4's model and Task 5's KeySpec. - [ ] **Step 1: Write the failing test** @@ -375,6 +416,19 @@ describe('getTabRepoIcons', () => { const state = store.getState() expect(getTabRepoIcons(state, state.tabs.tabs[0])).toEqual([]) }) + + it('tab with NO pane layout derives its icon from the synthesized pane (tab.initialCwd), matching the tab bar', () => { + const store = makeStore({ + tabs: 1, + repoIcons: { '/repos/alpha': { status: 'ready', repoRoot: '/repos/alpha', repoName: 'alpha', hasIcon: true } }, + }) + const state = store.getState() + const tab = { ...state.tabs.tabs[0], initialCwd: '/repos/alpha' } + const noLayout = { ...state, panes: { ...state.panes, layouts: {} } } as typeof state + expect(getTabRepoIcons(noLayout, tab)).toEqual([ + { url: buildRepoIconUrl('/repos/alpha'), letter: 'A', hue: hueFromString('alpha') }, + ]) + }) }) ``` @@ -405,21 +459,21 @@ export type TileRepoIcon = { /** * Repo icons for a tab, using the SAME resolution pipeline as the tab bar - * (TabBar.tsx getPaneEntries -> repoIconInfoByCwd): resolvePaneRepoCwd per pane, - * meta from state.repoIcons.byCwd (probed by the always-mounted TabBar), - * distinct repos in first-appearance order, capped at 3, silently truncated. + * (TabBar.tsx getPaneEntries -> repoIconInfoByCwd): resolvePaneRepoCwd per pane + * (panesForTab supplies layout entries or the TabBar.tsx:203-221-style synthesized + * pane for layout-less tabs), meta from state.repoIcons.byCwd (probed by the + * DeckController itself in Task 8; TabBar also probes when mounted), distinct + * repos in first-appearance order, capped at 3, silently truncated. * Deliberate divergences from TabItem: considers ALL panes (not just the first * 3 pane icons) and ignores settings.panes.repoIconsOnTabs (deck tiles always * show their center glyph). */ export function getTabRepoIcons(state: RootState, tab: Tab): TileRepoIcon[] { - const layout = state.panes.layouts[tab.id] - if (!layout) return [] const terminalMetaById = state.terminalMeta.byTerminalId const byCwd = state.repoIcons.byCwd const seen = new Set() const icons: TileRepoIcon[] = [] - for (const entry of collectPaneEntries(layout)) { + for (const entry of panesForTab(state, tab)) { const cwd = resolvePaneRepoCwd(entry.content, tab, terminalMetaById) if (!cwd) continue const meta = byCwd[cwd] @@ -720,19 +774,25 @@ git commit -m "feat(deck): KeySpec gains fill/dot/icons; buildFrame resolves ico - Test: `test/unit/client/deck/icon-image-cache.test.ts` **Interfaces:** -- Consumes: nothing app-specific (DOM `Image` in the default loader only). +- Consumes: nothing app-specific (DOM `Image` in the default loader, `document.createElement('canvas')` in the default probe only). - Produces (Tasks 7, 8, 12 rely on): - - `class IconImageCache { constructor(loader?: IconLoader); bitmapFor(url: string): CanvasImageSource | null; subscribe(cb: () => void): () => void }` + - `class IconImageCache { constructor(loader?: IconLoader, probe?: IconProbe); bitmapFor(url: string): CanvasImageSource | null; subscribe(cb: () => void): () => void }` - `type IconLoader = (url: string) => Promise` + - `type IconProbe = (bitmap: CanvasImageSource) => boolean` (true = bitmap actually draws pixels) + - `hasDrawnPixels(data: Uint8ClampedArray): boolean` (pure threshold logic, exported for tests) - `getIconImageCache(): IconImageCache` (singleton), `resetIconImageCacheForTests(cache?: IconImageCache): void` +**Verified jsdom constraints (A3):** under this vitest config (no `environmentOptions`), jsdom 25.0.1 uses `NoOpResourceLoader` — `Image`s never fetch, never fire `load`/`error`, never complete. Therefore: (a) tests MUST always inject the fake loader for any post-load assertion (default-loader promises pend forever in jsdom — harmless but never resolving); (b) every `IconImageCache` error path must be silent — no `console.error`/`console.warn` anywhere in this module (`console.error` is fatal in tests); (c) nobody may add `environmentOptions.jsdom.resources` or `userAgent` to the vitest config — either silently enables real fetching and would break these suites. + +**Verified drawn-empty trap (A4):** headless Chromium 145 confirms PNG, .ico, SVGs with width/height, and viewBox-only SVGs all draw non-blank at 96×96 when `drawImage` gets EXPLICIT width/height args — but the server verifiably serves dimensionless SVGs (`repo_icon_detect.rs:51-52` "Unknown dimensions are acceptable"), and two servable shapes fire `onload` yet draw ~0 pixels (no-viewBox SVGs with off-viewport content; width/height=0 SVGs). xmlns-less SVGs fail at load (the `onerror` fallback covers them). So after a successful load the cache runs a runtime-only drawn-empty probe (below) and records near-blank draws as FAILED, making the letter avatar render. The probe lives in the cache — not the tile renderer — so `Ctx2D` stays minimal. + - [ ] **Step 1: Write the failing test** Create `test/unit/client/deck/icon-image-cache.test.ts`: ```ts import { describe, it, expect, vi } from 'vitest' -import { IconImageCache, getIconImageCache, resetIconImageCacheForTests } from '@/deck/icon-image-cache' +import { IconImageCache, getIconImageCache, resetIconImageCacheForTests, hasDrawnPixels } from '@/deck/icon-image-cache' const fakeBitmap = { width: 16, height: 16 } as unknown as CanvasImageSource @@ -773,6 +833,30 @@ describe('IconImageCache', () => { expect(pending.size).toBe(1) // no second load attempt }) + it('drawn-empty probe failing records the entry as FAILED (letter avatar renders), no retry', async () => { + const { loader, pending } = deferredLoader() + const cache = new IconImageCache(loader, () => false) // injected probe: "drew ~0 pixels" + const listener = vi.fn() + cache.subscribe(listener) + cache.bitmapFor('/i/blank-svg') + pending.get('/i/blank-svg')!.resolve(fakeBitmap) + await Promise.resolve() + await Promise.resolve() + expect(listener).toHaveBeenCalledTimes(1) + expect(cache.bitmapFor('/i/blank-svg')).toBe(null) // failed, like a load error + expect(pending.size).toBe(1) // permanent: no second load attempt + }) + + it('drawn-empty probe passing keeps the bitmap', async () => { + const { loader, pending } = deferredLoader() + const cache = new IconImageCache(loader, () => true) + cache.bitmapFor('/i/ok') + pending.get('/i/ok')!.resolve(fakeBitmap) + await Promise.resolve() + await Promise.resolve() + expect(cache.bitmapFor('/i/ok')).toBe(fakeBitmap) + }) + it('unsubscribe stops notifications', async () => { const { loader, pending } = deferredLoader() const cache = new IconImageCache(loader) @@ -795,6 +879,23 @@ describe('IconImageCache', () => { resetIconImageCacheForTests() }) }) + +describe('hasDrawnPixels (drawn-empty threshold)', () => { + const px = (alphas: number[]): Uint8ClampedArray => { + const data = new Uint8ClampedArray(alphas.length * 4) + alphas.forEach((a, i) => { data[i * 4 + 3] = a }) + return data + } + it('false for a fully transparent draw', () => { + expect(hasDrawnPixels(px(new Array(100).fill(0)))).toBe(false) + }) + it('true at >= 1% alpha coverage', () => { + expect(hasDrawnPixels(px([255, ...new Array(99).fill(0)]))).toBe(true) // exactly 1% + }) + it('false just below 1% coverage', () => { + expect(hasDrawnPixels(px([255, ...new Array(199).fill(0)]))).toBe(false) // 0.5% + }) +}) ``` - [ ] **Step 2: Run test to verify it fails** @@ -814,6 +915,8 @@ Create `src/deck/icon-image-cache.ts`: // controller) are notified so tiles repaint with the real icon. // Failures are cached permanently for the session (like -> // letter avatar; the server caches negatives too). +// All error paths are SILENT - no console.error/console.warn (console.error is +// fatal in tests, and a failed icon is expected, not exceptional). export type IconLoader = (url: string) => Promise @@ -825,13 +928,50 @@ const defaultLoader: IconLoader = (url) => img.src = url }) +/** True when the decoded bitmap actually draws pixels (guards the SVG drawn-empty trap). */ +export type IconProbe = (bitmap: CanvasImageSource) => boolean + +export const DRAWN_EMPTY_PROBE_SIZE = 16 +/** Minimum fraction of non-transparent pixels for a draw to count as visible. */ +export const DRAWN_EMPTY_MIN_ALPHA_COVERAGE = 0.01 + +/** Pure threshold logic (exported for unit tests): >= 1% of pixels have alpha > 0. */ +export function hasDrawnPixels(data: Uint8ClampedArray): boolean { + const pixels = data.length / 4 + let opaque = 0 + for (let i = 3; i < data.length; i += 4) { + if (data[i] > 0) opaque++ + } + return pixels > 0 && opaque / pixels >= DRAWN_EMPTY_MIN_ALPHA_COVERAGE +} + +// Runtime-only drawn-empty probe. The server serves dimensionless SVGs first-class +// (repo_icon_detect.rs:51-52 "Unknown dimensions are acceptable"), and two servable +// shapes fire onload yet draw ~0 pixels in real Chromium (no-viewBox SVGs with +// off-viewport content; width/height=0 SVGs). Draw into a small internal canvas with +// EXPLICIT destination dims and count alpha; near-blank -> treat as failure so the +// letter avatar renders. In jsdom, getContext returns null: skip and trust the load. +const defaultProbe: IconProbe = (bitmap) => { + const canvas = document.createElement('canvas') + canvas.width = DRAWN_EMPTY_PROBE_SIZE + canvas.height = DRAWN_EMPTY_PROBE_SIZE + const ctx = canvas.getContext('2d') + if (!ctx) return true // jsdom / no 2D context: cannot probe, trust the load + ctx.clearRect(0, 0, DRAWN_EMPTY_PROBE_SIZE, DRAWN_EMPTY_PROBE_SIZE) + ctx.drawImage(bitmap, 0, 0, DRAWN_EMPTY_PROBE_SIZE, DRAWN_EMPTY_PROBE_SIZE) + return hasDrawnPixels(ctx.getImageData(0, 0, DRAWN_EMPTY_PROBE_SIZE, DRAWN_EMPTY_PROBE_SIZE).data) +} + export class IconImageCache { private bitmaps = new Map() private failed = new Set() private pending = new Set() private listeners = new Set<() => void>() - constructor(private loader: IconLoader = defaultLoader) {} + constructor( + private loader: IconLoader = defaultLoader, + private probe: IconProbe = defaultProbe, + ) {} /** Returns the decoded bitmap, or null while loading / after failure. Requests the load on first miss. */ bitmapFor(url: string): CanvasImageSource | null { @@ -842,7 +982,11 @@ export class IconImageCache { void this.loader(url).then( (bitmap) => { this.pending.delete(url) - this.bitmaps.set(url, bitmap) + if (this.probe(bitmap)) { + this.bitmaps.set(url, bitmap) + } else { + this.failed.add(url) // drew ~0 pixels: record as FAILED -> letter avatar + } this.notify() }, () => { @@ -886,7 +1030,7 @@ Expected: PASS. ```bash git add src/deck/icon-image-cache.ts test/unit/client/deck/icon-image-cache.test.ts -git commit -m "feat(deck): IconImageCache - async repo-icon bitmaps with letter-avatar fallback semantics" +git commit -m "feat(deck): IconImageCache - async repo-icon bitmaps with letter-avatar fallback and drawn-empty probe" ``` --- @@ -1053,7 +1197,7 @@ export function iconLayout(w: number, h: number, count: number): Array<{ x: numb } ``` -4. Rewrite `drawTab` (replace the whole function; the preview-drawing block is deleted from `drawTab` here, the helpers/constants themselves are deleted in Task 9): +4. Rewrite `drawTab` (replace the whole function; the preview-drawing block is deleted from `drawTab` here, the helpers/constants themselves are deleted in Task 9). Rule carried from validation (A4): every `drawImage` call takes EXPLICIT destination width and height — that is what rescues viewBox-only SVGs from drawing blank; the drawn-empty shapes that explicit dims cannot rescue are caught by Task 6's cache-side probe, so the renderer stays probe-free: ```ts function drawTab(ctx: Ctx2D, w: number, h: number, spec: Extract, getIcon: IconSource): void { @@ -1067,6 +1211,10 @@ function drawTab(ctx: Ctx2D, w: number, h: number, spec: Extract { + // No repoIcons seeded: the controller itself must probe /repos/alpha. TabBar cannot be + // relied on (its probe is gated on repoIconsOnTabs and TabBar is conditionally mounted). + const settings = { ...defaultSettings, panes: { ...defaultSettings.panes, repoIconsOnTabs: false } } + const { store } = setup({ tabs: 1, terminalMeta: { 'term-1': { cwd: '/repos/alpha' } } }, undefined, settings) + // The thunk's pending case records { status: 'loading' } synchronously on dispatch. + expect(store.getState().repoIcons.byCwd['/repos/alpha']).toMatchObject({ status: 'loading' }) +}) + +it('does not re-probe a cwd already present in state.repoIcons.byCwd', () => { + const { store } = setup({ + tabs: 1, + terminalMeta: { 'term-1': { cwd: '/repos/alpha' } }, + repoIcons: { '/repos/alpha': { status: 'ready', repoRoot: '/repos/alpha', repoName: 'alpha', hasIcon: true } }, + }) + expect(store.getState().repoIcons.byCwd['/repos/alpha'].status).toBe('ready') // untouched, no 'loading' overwrite +}) ``` +(If the suite's real `api` layer throws synchronously in jsdom, `vi.mock('@/lib/api', ...)` it with a never-resolving `get` — the probe assertions only need the thunk's synchronous `pending` entry.) + Extend the suite's `setup()` helper to accept extra `DeckController` options (4th arg) and to seed `terminalMeta`/`repoIcons` preloaded state (mirror Task 3's fixture additions). Also update/remove the existing test that asserts the 3s preview refresh (the suite has diff-paint tests around `PREVIEW_REFRESH_TICKS`). - [ ] **Step 2: Run tests to verify they fail** Run: `npm run test:vitest -- run test/unit/client/deck/deck-controller.test.ts --config config/vitest/vitest.config.ts` -Expected: FAIL — no `iconCache` option; icons never become ready; the 3s tick still repaints (registry snapshot reads). +Expected: FAIL — no `iconCache` option; icons never become ready; the 3s tick still repaints (registry snapshot reads); no probe dispatch exists yet, so `state.repoIcons.byCwd['/repos/alpha']` is `undefined` in the probe tests. - [ ] **Step 3: Write the implementation** @@ -1236,6 +1404,37 @@ const frame = buildFrame({ 6. Also remove the "ORDERING (load-bearing)" comment in `onStoreChange` referencing previews (`:164-170`) — the bail-out itself stays. +7. Own the repo-icon meta probe. The deck cannot rely on TabBar to populate `state.repoIcons.byCwd`: `TabBar.tsx:240` is the app's only other dispatcher, gated at `:230` on `repoIconsOnTabs`, and TabBar is conditionally mounted (`App.tsx:1644`) while leader election (`deck-manager.ts:166-203`) can elect a window without it. Add to `deck-controller.ts`: + +```ts +import { fetchRepoIconMeta } from '@/store/repoIconsSlice' +import { resolvePaneRepoCwd } from '@/lib/repo-icon' +import { panesForTab } from './deck-selectors' + +/** + * Probe repo-icon meta for every distinct resolved cwd of the tabs we render. + * Deliberately UN-gated by settings.panes.repoIconsOnTabs (Design decision 7: + * deck tiles always show their center glyph). Double-probing alongside a + * mounted TabBar is harmless - the thunk self-dedupes (repoIconsSlice.ts:36-40). + */ +private probeRepoIcons(): void { + const state = this.store.getState() + const terminalMetaById = state.terminalMeta.byTerminalId + const cwds = new Set() + for (const tab of state.tabs.tabs) { + for (const entry of panesForTab(state, tab)) { + const cwd = resolvePaneRepoCwd(entry.content, tab, terminalMetaById) + if (cwd) cwds.add(cwd) + } + } + for (const cwd of cwds) { + if (!state.repoIcons.byCwd[cwd]) this.store.dispatch(fetchRepoIconMeta(cwd)) + } +} +``` + +Call sites: once in `start()` (after the initial repaint), and in `onStoreChange` whenever the model JSON differs — i.e. inside the existing branch that already triggers `repaint()`, after the bail-out check (a probe result mutates `repoIcons`, which changes the model, which re-enters `onStoreChange` and repaints — no extra subscription needed). If the controller's `store` field is typed too narrowly to dispatch thunks, type it with the app store's `AppDispatch` (the same store type `focusTabFromDeck` already dispatches through) rather than casting at the call site. + - [ ] **Step 4: Run tests to verify they pass** Run: `npm run test:vitest -- run test/unit/client/deck/ test/e2e/stream-deck-flow.test.tsx --config config/vitest/vitest.config.ts` @@ -1245,7 +1444,7 @@ Expected: controller tests PASS. The e2e suite's preview expectations now decode ```bash git add src/deck/deck-controller.ts src/deck/frame.ts test/unit/client/deck/ test/e2e/stream-deck-flow.test.tsx -git commit -m "feat(deck): controller loads repo icons via IconImageCache and drops the preview repaint" +git commit -m "feat(deck): controller loads repo icons via IconImageCache, owns the meta probe, drops the preview repaint" ``` --- @@ -1452,6 +1651,7 @@ git commit -m "feat(deck): snapshot key target at press-down so re-sorts cannot **Interfaces:** - Consumes: everything above through the REAL store + REAL `DeckController` + `FakeDeckDevice` + spec-encoding renderer; `IconImageCache` with a deferred fake loader; fixture-builder extensions from Tasks 2–4 (`paneStatus`, `terminalMeta`, `repoIcons` seeding — port them into this suite's `makeDeckStore`). +- Fixtures note (layout-less transient): fixture stores must seed `state.panes.layouts` entries for every created tab (the real `addTab` never does — tabsSlice.ts:296), OR expectations must explicitly account for `panesForTab`'s synthesized single-pane fallback (Task 2) — otherwise sort/icon expectations flake on the layout-less transient. - Produces: user-story coverage for the redesign. - [ ] **Step 1: Write the new scenarios (they must fail only if the feature regresses — write them, run, expect PASS since Tasks 1–10 landed; any failure here is a real integration bug to fix before commit)** @@ -1596,7 +1796,8 @@ Do NOT create a PR — stop after committing; PR creation requires explicit user **1. Spec coverage:** - Title on top (unchanged banner) → Task 7 step 3 (§4 of `drawTab`). -- Repo icons centered, tab-bar pipeline reuse, cap-3 → Tasks 3, 5, 7; async load + cache + fallback → Tasks 6, 7, 8; no-repo tab renders title-only → Task 7 (`icons: []` → no draws) + Task 3 test. +- Repo icons centered, tab-bar pipeline reuse, cap-3 → Tasks 3, 5, 7; async load + cache + fallback → Tasks 6, 7, 8; no-repo tab renders title-only → Task 7 (`icons: []` → no draws) + Task 3 test; deck-owned un-gated `fetchRepoIconMeta` probing (TabBar's probe is setting-gated and conditionally mounted, so the deck cannot rely on it) → Design decision 7 + Task 8 step 3.7; drawn-empty SVG guard (<1% alpha coverage → entry FAILED → letter avatar) → Task 6; explicit-dims `drawImage` rule → Task 7; Rust-server icon-coverage parity documented as an accepted scope note (Investigation results). +- Layout-less tabs (a real transient: `addTab` never seeds a layout) classify and derive icons via `panesForTab`'s synthesized single pane mirroring `TabBar.tsx:203-221` (TabBar itself untouched) → Tasks 2, 3; e2e fixtures seed layouts or account for the fallback → Task 11. - Preview removal + dead machinery deletion (registry, TerminalView hook, 3s repaint, preview constants) with `/capture` untouched → Tasks 8, 9 (grep gate in Task 9 step 4). - Three background treatments driven by shared tab-bar conditions + exact color mapping → Tasks 1, 2, 7 (colors from verified `theme-variables.css` / Tailwind values). - Icon tinting: investigation showed the tab bar never tints repo icons (only pane icons); green/blue visibility on deck is delivered via the status dot with the same conditions/colors → Design decision 4, Tasks 1, 7. @@ -1607,8 +1808,8 @@ Do NOT create a PR — stop after committing; PR creation requires explicit user - Client-only; tab bar unchanged (read-only reuse; `hueFromString` fallback note in Task 3 keeps a single implementation). - Unit + e2e coverage for sort priority and the three backgrounds → Tasks 1, 4 (unit), 11 (e2e). Lint/typecheck/coordinated suite → Task 12. -**1b. No silent deferrals:** The only test doubles are the established suite seams (spec-encoding renderer, `FakeDeckDevice`, fake icon loader) — the same doubles the merged feature already ships with; production behavior (real canvas via `defaultCtxFactory`, real `Image` loader, WebHID transport) is exercised at runtime and unchanged in kind. No requirement is stubbed without a production path: the default `IconLoader` uses a real `Image` (Task 6), and the default renderer path passes the real cache (Task 8 step 3.2). No "known limitations" introduced. +**1b. No silent deferrals:** The only test doubles are the established suite seams (spec-encoding renderer, `FakeDeckDevice`, fake icon loader) plus the injectable drawn-empty probe — every double has a production path: the default `IconLoader` uses a real `Image` (Task 6), the default `IconProbe` draws into a real probe canvas with its pure threshold logic (`hasDrawnPixels`) unit-tested directly (Task 6), the default renderer path passes the real cache (Task 8 step 3.2), and the probe dispatch uses the real `fetchRepoIconMeta` thunk in tests and production alike (Task 8 step 3.7). One documented limitation, accepted deliberately (not silent): Rust-server icon coverage equals the tab bar's existing coverage — same resolver, same degradation (Investigation results scope note). -**2. Placeholder scan:** Two intentional adapt-to-fixture instructions remain (Task 2/3: "match the fixture builder actually present in the file") — these are file-drift guards with concrete fallback code shown, not deferrals. The Task 3 cap/dedupe test body is specified by exact expected behavior with construction guidance; implementer writes the fixture wiring. All other steps carry full code. +**2. Placeholder scan:** Two intentional adapt-to-fixture instructions remain (Task 2/3: "match the fixture builder actually present in the file") — these are file-drift guards with concrete fallback code shown, not deferrals. The Task 3 cap/dedupe test body is specified by exact expected behavior with construction guidance; implementer writes the fixture wiring. The validation-driven additions (layout-fallback tests, `panesForTab`, probe-dispatch tests + `probeRepoIcons`, drawn-empty probe + `hasDrawnPixels` tests) all carry full runnable code. All other steps carry full code. -**3. Type consistency check:** `TileFill`/`TileDot`/`TabStatusFlags` (Task 1) ← used in Tasks 2, 4, 5, 7. `TileRepoIcon { url, letter, hue }` (Task 3) → `TileIcon = TileRepoIcon & { ready }` shape (Task 5) → renderer reads `icon.url/letter/hue/ready` (Task 7). `iconReady(url) => boolean` named consistently (Tasks 5, 8). `IconImageCache.bitmapFor/subscribe` consistent (Tasks 6, 7, 8, 11). `DeckTab` transitional `status` added Task 4, removed Task 9 — both sides documented. `renderKey` 4-arg signature consistent (Tasks 7, 8, VirtualDeckPanel). +**3. Type consistency check:** `TileFill`/`TileDot`/`TabStatusFlags` (Task 1) ← used in Tasks 2, 4, 5, 7. `panesForTab(state, tab): Array<{ paneId; content }>` (Task 2) ← used by `getTabStatusFlags` (Task 2), `getTabRepoIcons` (Task 3), `probeRepoIcons` (Task 8). `TileRepoIcon { url, letter, hue }` (Task 3) → `TileIcon = TileRepoIcon & { ready }` shape (Task 5) → renderer reads `icon.url/letter/hue/ready` (Task 7). `iconReady(url) => boolean` named consistently (Tasks 5, 8). `IconImageCache` constructor `(loader?: IconLoader, probe?: IconProbe)` with `bitmapFor/subscribe` consistent (Tasks 6, 7, 8, 11); `IconProbe`/`hasDrawnPixels` (Task 6) have their production path in `defaultProbe`. `DeckTab` transitional `status` added Task 4, removed Task 9 — both sides documented. `renderKey` 4-arg signature consistent (Tasks 7, 8, VirtualDeckPanel). From b8e12375d2d54bfc6ea57d42ef49b63a72a6da00 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 02:59:08 -0700 Subject: [PATCH 03/30] docs(deck): fix plan verification gates and probe placement from fresh-eyes review - Task 4 step 4: state the required terminalMeta/repoIcons reducer registration in the controller/e2e/VirtualDeckPanel fixture stores (preloadedState alone is silently ignored without matching reducers) and correct the misdiagnosed failure mode; run VirtualDeckPanel suite too. Task 3/8 now reference it. - Task 8: make the no-periodic-repaint test able to go RED (register a changing-snapshot terminal-text reader); drop the false claim that deck-controller.test.ts has PREVIEW_REFRESH_TICKS tests (only the e2e suite covers previews); add a late-terminalMeta probe test. - Task 8 step 3.7: call probeRepoIcons BEFORE the model-JSON bail-out in onStoreChange - terminalMeta enrichment doesn't change the model JSON, so a post-bail-out probe never fires in the TabBar-less leader scenario. - Task 10/11: use the real markTabAttention({ tabId }) object payload (a bare string silently defeats the RED gate) and add an armed-gate sanity assertion. --- docs/plans/2026-07-29-deck-tile-redesign.md | 59 ++++++++++++++++----- 1 file changed, 47 insertions(+), 12 deletions(-) diff --git a/docs/plans/2026-07-29-deck-tile-redesign.md b/docs/plans/2026-07-29-deck-tile-redesign.md index f110eaf3e..937a27934 100644 --- a/docs/plans/2026-07-29-deck-tile-redesign.md +++ b/docs/plans/2026-07-29-deck-tile-redesign.md @@ -378,7 +378,7 @@ git commit -m "feat(deck): getTabStatusFlags - busy/attention/greenIcon from the - [ ] **Step 1: Write the failing test** -Add to `test/unit/client/deck/deck-selectors.test.ts`. Extend the fixture builder to support seeding `repoIcons.byCwd` and `terminalMeta.byTerminalId` via `preloadedState` (both are plain records). Fixture panes in this suite are claude-mode terminals, so `resolvePaneRepoCwd` uses `meta?.repoRoot || meta?.checkoutRoot || meta?.cwd || content.initialCwd || tab?.initialCwd` — seed `terminalMeta.byTerminalId['term-1'] = { cwd: '/repos/alpha' }` (match the record shape used by the `terminalMeta` slice; check its initial state for exact field names). +Add to `test/unit/client/deck/deck-selectors.test.ts`. First register the real `terminalMeta` and `repoIcons` reducers in this suite's store-builder `configureStore` reducer map (`terminalMeta` from `@/store/terminalMetaSlice`, `repoIcons` from `@/store/repoIconsSlice` — both slices export their reducer) — they are absent today, and `configureStore` silently ignores `preloadedState` keys that have no matching reducer, so seeding without the reducers is a silent no-op. Then extend the fixture builder to support seeding `repoIcons.byCwd` and `terminalMeta.byTerminalId` via `preloadedState` (both are plain records). Fixture panes in this suite are claude-mode terminals, so `resolvePaneRepoCwd` uses `meta?.repoRoot || meta?.checkoutRoot || meta?.cwd || content.initialCwd || tab?.initialCwd` — seed `terminalMeta.byTerminalId['term-1'] = { cwd: '/repos/alpha' }` (match the record shape used by the `terminalMeta` slice; check its initial state for exact field names). ```ts describe('getTabRepoIcons', () => { @@ -642,9 +642,14 @@ export function selectDeckModel(state: RootState): DeckModel { - [ ] **Step 4: Run tests to verify they pass — and check downstream compile** Run: `npm run test:vitest -- run test/unit/client/deck/ --config config/vitest/vitest.config.ts` -Expected: `deck-selectors.test.ts` PASS. `frame.test.ts` / `deck-controller.test.ts` may fail on fixture DeckTab shapes (they construct model objects) — update their fixture tab objects to include the new fields (add `busy:false, attention:false, fill:'none', dot:null, priority:4, repoIcons:[]` as appropriate; a local `makeDeckTab(over)` helper in each test file keeps this readable). The e2e suite also builds models indirectly through the real store — run it too: +Expected: `deck-selectors.test.ts` PASS. Two distinct downstream failure modes — do not confuse them: -`npm run test:vitest -- run test/e2e/stream-deck-flow.test.tsx --config config/vitest/vitest.config.ts` +1. `frame.test.ts` constructs `DeckTab` model objects directly — update its fixture tab objects to include the new fields (add `busy:false, attention:false, fill:'none', dot:null, priority:4, repoIcons:[]` as appropriate; a local `makeDeckTab(over)` helper keeps this readable). +2. `deck-controller.test.ts`, `test/e2e/stream-deck-flow.test.tsx`, and `test/unit/client/components/VirtualDeckPanel.test.tsx` build REAL stores (`configureStore` with ~10 reducers) that register neither `terminalMeta` nor `repoIcons` — the moment the controller calls the new `selectDeckModel`, `getTabRepoIcons` reads `state.terminalMeta.byTerminalId` and every test in those suites crashes with a TypeError. Fix by adding the real reducers to each fixture store's reducer map: `terminalMeta` (from `@/store/terminalMetaSlice`) and `repoIcons` (from `@/store/repoIconsSlice`). `preloadedState` seeding alone is NOT a fix — `configureStore` silently ignores preloadedState keys with no matching reducer. (This reducer registration is also the prerequisite that makes Task 8's and Task 11's `terminalMeta`/`repoIcons` seeding work.) + +Run the store-backed suites too: + +`npm run test:vitest -- run test/e2e/stream-deck-flow.test.tsx test/unit/client/components/VirtualDeckPanel.test.tsx --config config/vitest/vitest.config.ts` The e2e "tabs appear on keys" scenario asserts key order from tab order — with sorting, a busy t1 now lands after green-icon tabs. Update expected key indices to the sorted order (this is the intended behavior change). Then: @@ -1304,6 +1309,8 @@ In `test/unit/client/deck/deck-controller.test.ts` (uses the spec-encoding rende ```ts import { IconImageCache } from '@/deck/icon-image-cache' +import { registerTerminalTextReader } from '@/deck/terminal-text-registry' +import { upsertTerminalMeta } from '@/store/terminalMetaSlice' it('repaints keys when an icon bitmap finishes loading (cache subscription)', async () => { // Deferred loader as in icon-image-cache.test.ts @@ -1322,11 +1329,23 @@ it('repaints keys when an icon bitmap finishes loading (cache subscription)', as expect(after.kind === 'tab' && after.icons[0].ready).toBe(true) }) -it('no periodic preview repaint: 3s of ticks with unchanged state paints nothing new', () => { +it('no periodic preview repaint: 3s of ticks paints nothing even when terminal text changes', () => { + // A reader with a CHANGING snapshot is what makes this test able to go RED: with + // no reader registered, previewFor already yields [] and the per-key spec-JSON + // diff suppresses every paint, so the assertion would pass against unmodified + // code (vacuous). With the reader, current code's PREVIEW_REFRESH_TICKS branch + // repaints key 0 at the ~3s tick (new previewLines -> spec differs) and the test + // fails; it goes green only when previewFor and the tick branch are deleted. + // (Task 9 deletes the registry module itself; when it does, rework this test to + // drop the reader registration - the no-repaint guarantee becomes structural via + // Task 9's grep gate on PREVIEW_REFRESH_TICKS/registerTerminalTextReader.) + let n = 0 + const unregister = registerTerminalTextReader('term-1', () => [`line ${n++}`]) const { device } = setup({ tabs: 1 }) device.keyImages.clear() vi.advanceTimersByTime(3_000) expect(device.keyImages.size).toBe(0) + unregister() }) it('dispatches fetchRepoIconMeta for tab cwds even when settings.panes.repoIconsOnTabs is false (deck owns the probe)', () => { @@ -1346,16 +1365,28 @@ it('does not re-probe a cwd already present in state.repoIcons.byCwd', () => { }) expect(store.getState().repoIcons.byCwd['/repos/alpha'].status).toBe('ready') // untouched, no 'loading' overwrite }) + +it('probes a cwd that only becomes resolvable AFTER start (late terminalMeta, model JSON unchanged)', () => { + // Fixture panes have no initialCwd, so nothing is resolvable at start(). A later + // upsertTerminalMeta makes term-1's cwd resolvable but does NOT change the deck + // model JSON (icons stay [] until meta AND repoIcons both exist), so this test + // proves the probe runs BEFORE onStoreChange's model-JSON bail-out - the exact + // TabBar-less leader scenario the deck-owned probe exists for. + const { store } = setup({ tabs: 1 }) // no terminalMeta seeded + expect(store.getState().repoIcons.byCwd['/repos/alpha']).toBeUndefined() + store.dispatch(upsertTerminalMeta([{ terminalId: 'term-1', cwd: '/repos/alpha', updatedAt: Date.now() }])) + expect(store.getState().repoIcons.byCwd['/repos/alpha']).toMatchObject({ status: 'loading' }) +}) ``` (If the suite's real `api` layer throws synchronously in jsdom, `vi.mock('@/lib/api', ...)` it with a never-resolving `get` — the probe assertions only need the thunk's synchronous `pending` entry.) -Extend the suite's `setup()` helper to accept extra `DeckController` options (4th arg) and to seed `terminalMeta`/`repoIcons` preloaded state (mirror Task 3's fixture additions). Also update/remove the existing test that asserts the 3s preview refresh (the suite has diff-paint tests around `PREVIEW_REFRESH_TICKS`). +Extend the suite's `setup()` helper to accept a settings override (3rd arg) and extra `DeckController` options (4th arg), and extend the fixture builder to seed `terminalMeta`/`repoIcons` via `preloadedState` — this works only because Task 4 already registered the real `terminalMeta`/`repoIcons` reducers in this suite's reducer map (`configureStore` silently drops preloadedState keys with no matching reducer). Note this suite has NO existing preview or `PREVIEW_REFRESH_TICKS` tests to update or remove — the only preview coverage lives in `test/e2e/stream-deck-flow.test.tsx` (exact `toEqual` assertions on full KeySpecs including `previewLines`); those e2e expectations are updated in Step 4. - [ ] **Step 2: Run tests to verify they fail** Run: `npm run test:vitest -- run test/unit/client/deck/deck-controller.test.ts --config config/vitest/vitest.config.ts` -Expected: FAIL — no `iconCache` option; icons never become ready; the 3s tick still repaints (registry snapshot reads); no probe dispatch exists yet, so `state.repoIcons.byCwd['/repos/alpha']` is `undefined` in the probe tests. +Expected: FAIL — no `iconCache` option; icons never become ready; the no-periodic-repaint test fails because the registered reader's changing snapshot makes the `PREVIEW_REFRESH_TICKS` tick repaint key 0 with new `previewLines`; no probe dispatch exists yet, so `state.repoIcons.byCwd['/repos/alpha']` stays `undefined` in the un-gated probe test and the late-terminalMeta test. (The no-re-probe test guards the implementation once it exists and may already pass here — that is fine; the other probe tests carry the RED gate.) - [ ] **Step 3: Write the implementation** @@ -1433,7 +1464,7 @@ private probeRepoIcons(): void { } ``` -Call sites: once in `start()` (after the initial repaint), and in `onStoreChange` whenever the model JSON differs — i.e. inside the existing branch that already triggers `repaint()`, after the bail-out check (a probe result mutates `repoIcons`, which changes the model, which re-enters `onStoreChange` and repaints — no extra subscription needed). If the controller's `store` field is typed too narrowly to dispatch thunks, type it with the app store's `AppDispatch` (the same store type `focusTabFromDeck` already dispatches through) rather than casting at the call site. +Call sites: once in `start()` (after the initial repaint), and in `onStoreChange` on EVERY store change, BEFORE the `modelJson === this.lastModelJson` bail-out. This placement is load-bearing: the store events that first make a cwd resolvable — `upsertTerminalMeta`/`setTerminalMetaSnapshot` enriching `terminalMeta.byTerminalId` — do NOT change the model JSON (with no `repoIcons.byCwd` entry yet, `icons` is `[]` both before and after), so a probe placed after the bail-out would never fire in exactly the TabBar-less leader scenario this probe exists for. Pre-bail-out probing is cheap (a Set build over tabs/panes per store change; it dispatches only for unprobed cwds) and cannot loop: the thunk's synchronous `pending` entry lands in `repoIcons.byCwd`, so the `!state.repoIcons.byCwd[cwd]` guard skips that cwd on the re-entrant store change, and when the meta arrives the model JSON changes and the normal repaint path takes over. If the controller's `store` field is typed too narrowly to dispatch thunks, type it with the app store's `AppDispatch` (the same store type `focusTabFromDeck` already dispatches through) rather than casting at the call site. - [ ] **Step 4: Run tests to verify they pass** @@ -1534,8 +1565,12 @@ it('acts on the tab displayed at press-down even if the sort changes mid-press', // t1 greenIcon (key 0), t2 greenIcon (key 1) const { store, device } = setup({ tabs: 2, activeTab: 't1' }) device.emit({ type: 'keyDown', keyIndex: 1 }) // user is pressing "t2" - // Mid-press: t2 gains attention -> re-sort moves t2 to key 0; key 1 now shows t1 - store.dispatch(markTabAttention('t2')) // use the suite's existing attention dispatch helper/action + // Mid-press: t2 gains attention -> re-sort moves t2 to key 0; key 1 now shows t1. + // NOTE the object payload: markTabAttention takes { tabId } (the suite already + // dispatches markTabAttention({ tabId: 't1' }) elsewhere) - a bare-string payload + // would silently never set attentionByTab, no re-sort would occur, and this test + // would pass vacuously against unmodified code. + store.dispatch(markTabAttention({ tabId: 't2' })) vi.advanceTimersByTime(100) device.emit({ type: 'keyUp', keyIndex: 1 }) // Snapshot guard: the press focuses t2 (what the user saw), not t1 (what the slot shows now) @@ -1554,7 +1589,7 @@ it('press on a tab that was closed mid-press is a no-op', () => { it('long-press opens the action layer for the press-down tab despite a mid-press re-sort', () => { const { store, device } = setup({ tabs: 2, activeTab: 't1' }) device.emit({ type: 'keyDown', keyIndex: 1 }) - store.dispatch(markTabAttention('t2')) + store.dispatch(markTabAttention({ tabId: 't2' })) vi.advanceTimersByTime(600) device.emit({ type: 'keyUp', keyIndex: 1 }) // Action layer shows BACK/APPROVE/STOP; verify it targets t2 via the frame or controller state @@ -1563,7 +1598,7 @@ it('long-press opens the action layer for the press-down tab despite a mid-press }) ``` -Use whatever attention/close dispatch the suite already uses (the fixture builder seeds `turnCompletion.attentionByTab` — if there is no runtime action, dispatch the store's real `turnCompletion` slice action; check the slice's exported actions). The essential assertion: acting on key 1 after the re-sort affects **t2**. +`markTabAttention` is the real runtime action, exported from `@/store/turnCompletionSlice` with payload `{ tabId: string }` — the suite already imports and dispatches it as `markTabAttention({ tabId: 't1' })`. After each attention dispatch, sanity-check the RED gate is armed: `expect(store.getState().turnCompletion.attentionByTab['t2']).toBe(true)` (or the slice's equivalent flag) — this guards against a payload-shape mistake making the mid-press re-sort never happen and the test passing vacuously. The essential assertion: acting on key 1 after the re-sort affects **t2**. - [ ] **Step 2: Run test to verify it fails** @@ -1714,7 +1749,7 @@ it('pager pages over the SORTED order', () => { it('a mid-press re-sort does not retarget the press (e2e)', () => { const { store, device } = setup({ tabs: 2, activeTab: 't1' }) device.emit({ type: 'keyDown', keyIndex: 1 }) - store.dispatch(/* the suite's real turnCompletion attention action for t2 */) + store.dispatch(markTabAttention({ tabId: 't2' })) // from '@/store/turnCompletionSlice' - object payload vi.advanceTimersByTime(100) device.emit({ type: 'keyUp', keyIndex: 1 }) expect(store.getState().tabs.activeTabId).toBe('t2') From cf360740f7270b7bbde4e8deadadfe4ff2aa816b Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 03:25:15 -0700 Subject: [PATCH 04/30] docs(deck): fix plan test gates - fixture tab modes, loader call counting, controller-suite API, grep scope Fresh-eyes review iteration 2 blocking fixes: - Task 3: layout-less tests override tab.mode to 'claude' (fixture tabs are mode 'shell'; synthesized pane inherits tab.mode, so greenIcon and repo-cwd resolution were unreachable as written); corrected the wrong fixture-fact comment and the drift-guard note - Task 6: deferredLoader counts loader invocations; dedup/no-retry assertions use calls() instead of the vacuous Map-keyed pending.size, pinning the load-bearing no-retry-after-failure property - Task 8: test snippets rewritten against deck-controller.test.ts's real API (tabCount, 2-arg setup + 3rd-arg controller options, no defaultSettings); repoIconsOnTabs seeded pre-start via updateSettingsLocal through a new StoreOpts option; setup-extension instructions rewritten with verified facts - Task 4: per-suite fixture API claim corrected (makeState vs makeStore vs makeDeckStore) - Task 9: dead-reference grep excludes SettingsView.core.test.tsx's unrelated previewLines local so the no-matches gate is achievable - Task 10: snippets use tabCount (active tab defaults to t1); closeTab described accurately as the tabsSlice async thunk --- docs/plans/2026-07-29-deck-tile-redesign.md | 84 ++++++++++++++------- 1 file changed, 56 insertions(+), 28 deletions(-) diff --git a/docs/plans/2026-07-29-deck-tile-redesign.md b/docs/plans/2026-07-29-deck-tile-redesign.md index 937a27934..3c5ba659f 100644 --- a/docs/plans/2026-07-29-deck-tile-redesign.md +++ b/docs/plans/2026-07-29-deck-tile-redesign.md @@ -271,14 +271,17 @@ describe('getTabStatusFlags', () => { // and the deck repaints synchronously per dispatch, so it WILL paint layout-less tabs. const store = makeStore({ tabs: 1 }) const state = store.getState() - const tab = state.tabs.tabs[0] // fixture tab: mode 'claude', status 'running' + // Fixture tabs carry mode: 'shell' (verified in all three suites' builders - only pane + // CONTENTS are mode 'claude'). The synthesized pane inherits tab.mode, and a shell-mode + // pane never yields greenIcon - so override the tab under test. + const tab = { ...state.tabs.tabs[0], mode: 'claude' as const, status: 'running' as const } const noLayout = { ...state, panes: { ...state.panes, layouts: {} } } as typeof state expect(getTabStatusFlags(noLayout, tab)).toEqual({ busy: false, attention: false, greenIcon: true }) }) }) ``` -If the fixture builder's tabs lack `mode`/`status` fields, extend it to set them (`mode: 'claude', status: 'running'`) — the synthesis fallback reads the tab's own fields, exactly like `TabBar.tsx:203-221`. +The fixture builders' tabs already HAVE `mode`/`status` fields — but with `mode: 'shell'`, not `'claude'` (verified: all three suites build tabs as `{ ..., status: 'running', mode: 'shell' }`; only pane contents are `mode: 'claude'`). The synthesis fallback reads the tab's own fields, exactly like `TabBar.tsx:203-221`, which is why both layout-less tests above/below override `mode` on the tab object they pass instead of relying on fixture defaults — a shell-mode synthesized pane yields neither `greenIcon` nor a resolvable repo cwd. If the suite's fixture builder has no `paneStatus` option, extend it: it constructs `TerminalPaneContent` leaves — add `status: opts.paneStatus?.[paneId] ?? 'running'`. Add a small local `withTabAttentionStyle(state, style)` helper that returns a state copy with `settings.settings.panes.tabAttentionStyle` overridden (structured clone + assignment is fine for a test). @@ -423,7 +426,10 @@ describe('getTabRepoIcons', () => { repoIcons: { '/repos/alpha': { status: 'ready', repoRoot: '/repos/alpha', repoName: 'alpha', hasIcon: true } }, }) const state = store.getState() - const tab = { ...state.tabs.tabs[0], initialCwd: '/repos/alpha' } + // Fixture tabs are mode: 'shell', and resolvePaneRepoCwd resolves terminal panes only + // when their mode is non-shell (isNonShellMode); the synthesized pane inherits tab.mode. + // Override mode alongside initialCwd or the icon can never appear. + const tab = { ...state.tabs.tabs[0], mode: 'claude' as const, initialCwd: '/repos/alpha' } const noLayout = { ...state, panes: { ...state.panes, layouts: {} } } as typeof state expect(getTabRepoIcons(noLayout, tab)).toEqual([ { url: buildRepoIconUrl('/repos/alpha'), letter: 'A', hue: hueFromString('alpha') }, @@ -582,7 +588,7 @@ describe('selectDeckModel (sorted, tile fields)', () => { }) ``` -The fixture builders' documented options are `tabs`, `busy`, `attention`, `freshAgentTab`, `pendingPermissions`, `freshAgentRunning` — they default the active tab to `t1`. Add an `activeTab?: string` option (sets `preloadedState.tabs.activeTabId`) wherever these tests (and Tasks 10–11) pass it. +The fixture APIs differ per suite (verified — adapt each snippet to the helper actually present): `deck-selectors.test.ts` has `makeState(overrides)` (options `claudeBusy`/`attention`/`pendingPermissions`/`freshAgentRunning`; returns a plain state object, not a store); `deck-controller.test.ts` has `makeStore(opts)` with `tabCount`/`claudeBusy`/`attention`/`freshAgentTab`/`pendingPermissions`/`freshAgentRunning`; only the e2e suite's `makeDeckStore` takes `tabs`. All default the active tab to `t1`. Add an `activeTab?: string` option (sets `preloadedState.tabs.activeTabId` / the built state's `activeTabId`) to whichever builder a test passes it to — only needed where a test wants a non-`t1` active tab. Also update any existing `selectDeckModel` tests in this file that assert the old `{ id, title, active, status }` shape — extend their expected objects with the new fields (or switch them to `toMatchObject`). @@ -802,21 +808,28 @@ import { IconImageCache, getIconImageCache, resetIconImageCacheForTests, hasDraw const fakeBitmap = { width: 16, height: 16 } as unknown as CanvasImageSource function deferredLoader() { + // NOTE: `pending` is a Map keyed by url, so a duplicate load for the same url would + // overwrite the same key and `pending.size` could never detect it. `calls()` counts + // actual loader invocations - that is the ONLY signal that can catch duplicate loads + // or a retry-after-failure implementation. const pending = new Map void; reject: (e: Error) => void }>() - const loader = (url: string) => - new Promise((resolve, reject) => pending.set(url, { resolve, reject })) - return { loader, pending } + let loads = 0 + const loader = (url: string) => { + loads++ + return new Promise((resolve, reject) => pending.set(url, { resolve, reject })) + } + return { loader, pending, calls: () => loads } } describe('IconImageCache', () => { it('returns null while loading, kicks off exactly one load per url, notifies on completion', async () => { - const { loader, pending } = deferredLoader() + const { loader, pending, calls } = deferredLoader() const cache = new IconImageCache(loader) const listener = vi.fn() cache.subscribe(listener) expect(cache.bitmapFor('/i/a')).toBe(null) expect(cache.bitmapFor('/i/a')).toBe(null) // second call: no second load - expect(pending.size).toBe(1) + expect(calls()).toBe(1) // loader invoked exactly once (pending.size can't see dupes) pending.get('/i/a')!.resolve(fakeBitmap) await Promise.resolve() // flush microtasks await Promise.resolve() @@ -825,7 +838,7 @@ describe('IconImageCache', () => { }) it('caches failures permanently (null forever, no retry) and still notifies', async () => { - const { loader, pending } = deferredLoader() + const { loader, pending, calls } = deferredLoader() const cache = new IconImageCache(loader) const listener = vi.fn() cache.subscribe(listener) @@ -835,11 +848,15 @@ describe('IconImageCache', () => { await Promise.resolve() expect(listener).toHaveBeenCalledTimes(1) expect(cache.bitmapFor('/i/broken')).toBe(null) - expect(pending.size).toBe(1) // no second load attempt + expect(cache.bitmapFor('/i/broken')).toBe(null) + // The load-bearing no-retry assertion: post-failure reads never re-invoke the loader. + // (A retrying implementation would re-kick the load on every bitmapFor -> fetch/repaint + // loop in production; pending.size stays 1 either way, so it proves nothing.) + expect(calls()).toBe(1) }) it('drawn-empty probe failing records the entry as FAILED (letter avatar renders), no retry', async () => { - const { loader, pending } = deferredLoader() + const { loader, pending, calls } = deferredLoader() const cache = new IconImageCache(loader, () => false) // injected probe: "drew ~0 pixels" const listener = vi.fn() cache.subscribe(listener) @@ -849,7 +866,7 @@ describe('IconImageCache', () => { await Promise.resolve() expect(listener).toHaveBeenCalledTimes(1) expect(cache.bitmapFor('/i/blank-svg')).toBe(null) // failed, like a load error - expect(pending.size).toBe(1) // permanent: no second load attempt + expect(calls()).toBe(1) // permanent: the post-failure read above did not re-invoke the loader }) it('drawn-empty probe passing keeps the bitmap', async () => { @@ -1317,10 +1334,10 @@ it('repaints keys when an icon bitmap finishes loading (cache subscription)', as const { loader, pending } = deferredLoader() const cache = new IconImageCache(loader) const { device } = setup({ - tabs: 1, + tabCount: 1, terminalMeta: { 'term-1': { cwd: '/repos/alpha' } }, repoIcons: { '/repos/alpha': { status: 'ready', repoRoot: '/repos/alpha', repoName: 'alpha', hasIcon: true } }, - }, undefined, defaultSettings, { iconCache: cache }) + }, undefined, { iconCache: cache }) const before = decodeKey(device, 0)! expect(before.kind === 'tab' && before.icons[0].ready).toBe(false) pending.get(before.kind === 'tab' ? before.icons[0].url! : '')!.resolve({} as CanvasImageSource) @@ -1341,7 +1358,7 @@ it('no periodic preview repaint: 3s of ticks paints nothing even when terminal t // Task 9's grep gate on PREVIEW_REFRESH_TICKS/registerTerminalTextReader.) let n = 0 const unregister = registerTerminalTextReader('term-1', () => [`line ${n++}`]) - const { device } = setup({ tabs: 1 }) + const { device } = setup({ tabCount: 1 }) device.keyImages.clear() vi.advanceTimersByTime(3_000) expect(device.keyImages.size).toBe(0) @@ -1351,15 +1368,25 @@ it('no periodic preview repaint: 3s of ticks paints nothing even when terminal t it('dispatches fetchRepoIconMeta for tab cwds even when settings.panes.repoIconsOnTabs is false (deck owns the probe)', () => { // No repoIcons seeded: the controller itself must probe /repos/alpha. TabBar cannot be // relied on (its probe is gated on repoIconsOnTabs and TabBar is conditionally mounted). - const settings = { ...defaultSettings, panes: { ...defaultSettings.panes, repoIconsOnTabs: false } } - const { store } = setup({ tabs: 1, terminalMeta: { 'term-1': { cwd: '/repos/alpha' } } }, undefined, settings) + // repoIconsOnTabs is an EXISTING app setting (state.settings.settings.panes, default + // true) - NOT the controller's brightness-only settings() option. It must be false + // BEFORE the controller starts (flipping it after start proves nothing: the probe + // already ran under the default), hence the fixture option below, which the builder + // applies via store.dispatch(updateSettingsLocal({ panes: { repoIconsOnTabs: false } })) + // before setup() constructs the controller (precedent: deck-manager.test.ts:128; the + // suite already registers settingsReducer). + const { store } = setup({ + tabCount: 1, + terminalMeta: { 'term-1': { cwd: '/repos/alpha' } }, + repoIconsOnTabs: false, + }) // The thunk's pending case records { status: 'loading' } synchronously on dispatch. expect(store.getState().repoIcons.byCwd['/repos/alpha']).toMatchObject({ status: 'loading' }) }) it('does not re-probe a cwd already present in state.repoIcons.byCwd', () => { const { store } = setup({ - tabs: 1, + tabCount: 1, terminalMeta: { 'term-1': { cwd: '/repos/alpha' } }, repoIcons: { '/repos/alpha': { status: 'ready', repoRoot: '/repos/alpha', repoName: 'alpha', hasIcon: true } }, }) @@ -1372,7 +1399,7 @@ it('probes a cwd that only becomes resolvable AFTER start (late terminalMeta, mo // model JSON (icons stay [] until meta AND repoIcons both exist), so this test // proves the probe runs BEFORE onStoreChange's model-JSON bail-out - the exact // TabBar-less leader scenario the deck-owned probe exists for. - const { store } = setup({ tabs: 1 }) // no terminalMeta seeded + const { store } = setup({ tabCount: 1 }) // no terminalMeta seeded expect(store.getState().repoIcons.byCwd['/repos/alpha']).toBeUndefined() store.dispatch(upsertTerminalMeta([{ terminalId: 'term-1', cwd: '/repos/alpha', updatedAt: Date.now() }])) expect(store.getState().repoIcons.byCwd['/repos/alpha']).toMatchObject({ status: 'loading' }) @@ -1381,7 +1408,7 @@ it('probes a cwd that only becomes resolvable AFTER start (late terminalMeta, mo (If the suite's real `api` layer throws synchronously in jsdom, `vi.mock('@/lib/api', ...)` it with a never-resolving `get` — the probe assertions only need the thunk's synchronous `pending` entry.) -Extend the suite's `setup()` helper to accept a settings override (3rd arg) and extra `DeckController` options (4th arg), and extend the fixture builder to seed `terminalMeta`/`repoIcons` via `preloadedState` — this works only because Task 4 already registered the real `terminalMeta`/`repoIcons` reducers in this suite's reducer map (`configureStore` silently drops preloadedState keys with no matching reducer). Note this suite has NO existing preview or `PREVIEW_REFRESH_TICKS` tests to update or remove — the only preview coverage lives in `test/e2e/stream-deck-flow.test.tsx` (exact `toEqual` assertions on full KeySpecs including `previewLines`); those e2e expectations are updated in Step 4. +This suite's REAL fixture API (verified): `makeStore(opts: StoreOpts)` with options `tabCount`/`claudeBusy`/`attention`/`freshAgentTab`/`pendingPermissions`/`freshAgentRunning`, and `setup(opts, caps)` with TWO params. There is NO `defaultSettings` identifier in this file (it exists only in the e2e suite, where it is a function returning deck-brightness `DeckSettings` — unrelated to app settings); the controller's `settings()` option here is `const settings` at `deck-controller.test.ts:105` and stays untouched. Extend the machinery as follows: (a) `setup()` gains a 3rd arg of extra `DeckController` constructor options (spread last — used above for `iconCache`); (b) `StoreOpts` gains `terminalMeta?` / `repoIcons?`, seeded via `preloadedState` — this works only because Task 4 already registered the real `terminalMeta`/`repoIcons` reducers in this suite's reducer map (`configureStore` silently drops preloadedState keys with no matching reducer); (c) `StoreOpts` gains `repoIconsOnTabs?: boolean` — when set, `makeStore` dispatches `updateSettingsLocal({ panes: { repoIconsOnTabs } })` (import from `@/store/settingsSlice`; `settingsReducer` is already registered in this suite) on the store before returning it, so the value is in place before `setup()` constructs and starts the controller (precedent: `deck-manager.test.ts:128`). Note this suite has NO existing preview or `PREVIEW_REFRESH_TICKS` tests to update or remove — the only preview coverage lives in `test/e2e/stream-deck-flow.test.tsx` (exact `toEqual` assertions on full KeySpecs including `previewLines`); those e2e expectations are updated in Step 4. - [ ] **Step 2: Run tests to verify they fail** @@ -1532,8 +1559,8 @@ Expected: FAIL — produced KeySpecs still carry `previewLines`/`ring`, `status` Run: `npm run test:vitest -- run test/unit/client/deck/ test/e2e/stream-deck-flow.test.tsx test/unit/client/components/VirtualDeckPanel.test.tsx --config config/vitest/vitest.config.ts` Expected: PASS. -Run: `grep -rn "terminal-text-registry\|registerTerminalTextReader\|getTerminalTextSnapshot\|useTerminalTextRegistration\|readXtermTail\|previewLines\|previewGeometry\|cropPreviewLines\|ringColor\|RingColor\|RING_COLORS\|getTabRingStatus\|TabRingStatus\|PREVIEW_REFRESH_TICKS" src/ test/ shared/` -Expected: **no matches** (confirms zero dead references; `/api/panes/:id/capture` in `server/` is untouched by design). +Run: `grep -rn "terminal-text-registry\|registerTerminalTextReader\|getTerminalTextSnapshot\|useTerminalTextRegistration\|readXtermTail\|previewLines\|previewGeometry\|cropPreviewLines\|ringColor\|RingColor\|RING_COLORS\|getTabRingStatus\|TabRingStatus\|PREVIEW_REFRESH_TICKS" src/ test/ shared/ --exclude=SettingsView.core.test.tsx` +Expected: **no matches** (confirms zero dead references). The `--exclude` is required: `test/unit/client/components/SettingsView.core.test.tsx:68-69` has an unrelated local `previewLines` variable (settings terminal-preview UI, not the deck) that pre-dates this work and stays. If any OTHER match appears, it is a real dead reference — fix it. `/api/panes/:id/capture` in `server/` is untouched by design. Run: `npm run typecheck:client` — clean. Run: `npm run lint` — clean. @@ -1562,8 +1589,9 @@ In `test/unit/client/deck/deck-controller.test.ts` (fake timers; store from the ```ts it('acts on the tab displayed at press-down even if the sort changes mid-press', () => { - // t1 greenIcon (key 0), t2 greenIcon (key 1) - const { store, device } = setup({ tabs: 2, activeTab: 't1' }) + // t1 greenIcon (key 0), t2 greenIcon (key 1). This suite's builder is + // makeStore({ tabCount }) and it already defaults the active tab to t1. + const { store, device } = setup({ tabCount: 2 }) device.emit({ type: 'keyDown', keyIndex: 1 }) // user is pressing "t2" // Mid-press: t2 gains attention -> re-sort moves t2 to key 0; key 1 now shows t1. // NOTE the object payload: markTabAttention takes { tabId } (the suite already @@ -1578,16 +1606,16 @@ it('acts on the tab displayed at press-down even if the sort changes mid-press', }) it('press on a tab that was closed mid-press is a no-op', () => { - const { store, device } = setup({ tabs: 2, activeTab: 't1' }) + const { store, device } = setup({ tabCount: 2 }) device.emit({ type: 'keyDown', keyIndex: 1 }) - store.dispatch(closeTab('t2')) // suite's existing tab-close action + store.dispatch(closeTab('t2')) // async thunk from @/store/tabsSlice - import it; dispatches fine on the fixture store vi.advanceTimersByTime(100) device.emit({ type: 'keyUp', keyIndex: 1 }) expect(store.getState().tabs.activeTabId).toBe('t1') }) it('long-press opens the action layer for the press-down tab despite a mid-press re-sort', () => { - const { store, device } = setup({ tabs: 2, activeTab: 't1' }) + const { store, device } = setup({ tabCount: 2 }) device.emit({ type: 'keyDown', keyIndex: 1 }) store.dispatch(markTabAttention({ tabId: 't2' })) vi.advanceTimersByTime(600) From 14ec9719296a0314d26b27079d728be877db4ac8 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 03:48:43 -0700 Subject: [PATCH 05/30] docs(deck): fix plan gaps - deck-manager fixture reducers for probe, e2e setup 4th param + deferredLoader port Fresh-eyes iteration 3 blocking fixes: - Task 8 step 3.8 (new): register terminalMeta/repoIcons reducers in deck-manager.test.ts makeStore - probeRepoIcons unconditionally reads both slices and that suite starts real controllers, so without them every manager test dies in start(); Step 4 Expected updated to match. - Task 11: Interfaces now mandate the two missing harness extensions - extend the e2e suite's 3-param setup() with a 4th extra-controller- options parameter (spread last into the controller constructor) and port deferredLoader from icon-image-cache.test.ts; Step 1 heading and the repo-icon scenario annotated so harness failures aren't misdiagnosed as integration bugs. --- docs/plans/2026-07-29-deck-tile-redesign.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/plans/2026-07-29-deck-tile-redesign.md b/docs/plans/2026-07-29-deck-tile-redesign.md index 3c5ba659f..f9c361125 100644 --- a/docs/plans/2026-07-29-deck-tile-redesign.md +++ b/docs/plans/2026-07-29-deck-tile-redesign.md @@ -1493,10 +1493,12 @@ private probeRepoIcons(): void { Call sites: once in `start()` (after the initial repaint), and in `onStoreChange` on EVERY store change, BEFORE the `modelJson === this.lastModelJson` bail-out. This placement is load-bearing: the store events that first make a cwd resolvable — `upsertTerminalMeta`/`setTerminalMetaSnapshot` enriching `terminalMeta.byTerminalId` — do NOT change the model JSON (with no `repoIcons.byCwd` entry yet, `icons` is `[]` both before and after), so a probe placed after the bail-out would never fire in exactly the TabBar-less leader scenario this probe exists for. Pre-bail-out probing is cheap (a Set build over tabs/panes per store change; it dispatches only for unprobed cwds) and cannot loop: the thunk's synchronous `pending` entry lands in `repoIcons.byCwd`, so the `!state.repoIcons.byCwd[cwd]` guard skips that cwd on the re-entrant store change, and when the meta arrives the model JSON changes and the normal repaint path takes over. If the controller's `store` field is typed too narrowly to dispatch thunks, type it with the app store's `AppDispatch` (the same store type `focusTabFromDeck` already dispatches through) rather than casting at the call site. +8. Fixture prerequisite for Step 4's suite-wide run: `test/unit/client/deck/deck-manager.test.ts` starts REAL controllers through `DeckManager` (`deck-manager.ts:95-103`; seven of its tests assert `status === 'connected'`, e.g. `:123`/`:131`), and `probeRepoIcons()` unconditionally dereferences `state.terminalMeta.byTerminalId` and `state.repoIcons.byCwd` even with zero tabs — unlike Task 4's per-tab reads, which never fire on that suite's empty tab list. That suite's `makeStore()` (`deck-manager.test.ts:81-97` — no options, no `preloadedState`) registers 11 reducers but NOT `terminalMeta`/`repoIcons` (Task 4 Step 4 point 2 covered only the deck-controller/e2e/VirtualDeckPanel suites). Register both real reducers in its reducer map too — `terminalMeta` (default export of `@/store/terminalMetaSlice`) and `repoIcons` (default export of `@/store/repoIconsSlice`) — otherwise every deck-manager test that reaches `start()` throws a TypeError that `runLeaderConnect`'s catch swallows into `handleOpenError`, `status` never reaches `'connected'`, and the store-subscriber path throws on every subsequent dispatch. No seeding is needed; the slices' initial `{ byTerminalId: {} }` / `{ byCwd: {} }` states are exactly what these tests want (no cwds resolvable, probe dispatches nothing). + - [ ] **Step 4: Run tests to verify they pass** Run: `npm run test:vitest -- run test/unit/client/deck/ test/e2e/stream-deck-flow.test.tsx --config config/vitest/vitest.config.ts` -Expected: controller tests PASS. The e2e suite's preview expectations now decode `previewLines: []` — update those expectations (full preview deletion lands in Task 9). Then `npm run typecheck:client` — clean. +Expected: controller tests PASS; `deck-manager.test.ts` PASSES because of step 3.8's reducer registration (without it, every manager test that starts a controller dies in `probeRepoIcons`). The e2e suite's preview expectations now decode `previewLines: []` — update those expectations (full preview deletion lands in Task 9). Then `npm run typecheck:client` — clean. - [ ] **Step 5: Commit** @@ -1714,10 +1716,11 @@ git commit -m "feat(deck): snapshot key target at press-down so re-sorts cannot **Interfaces:** - Consumes: everything above through the REAL store + REAL `DeckController` + `FakeDeckDevice` + spec-encoding renderer; `IconImageCache` with a deferred fake loader; fixture-builder extensions from Tasks 2–4 (`paneStatus`, `terminalMeta`, `repoIcons` seeding — port them into this suite's `makeDeckStore`). +- Harness extensions (REQUIRED before writing the scenarios — this suite does not have them yet): (a) this suite's `setup()` is currently `setup(opts = {}, caps?, settings = defaultSettings)` (`stream-deck-flow.test.tsx:121-134`, controller constructed at `:124-130` with `store`/`device`/`renderKey`/`renderStrip`/`settings`) — extend it with a FOURTH parameter of extra `DeckController` constructor options, spread LAST into that constructor call (mirror of the controller suite's 3rd `setup` arg from Task 8); JavaScript silently ignores extra arguments, so without this the repo-icon scenario's `{ iconCache: cache }` would be dropped, the controller would fall back to the global singleton cache with the default loader, and `pending.get(url)` would return `undefined` (TypeError on `.resolve`). (b) Port the `deferredLoader` helper from Task 6's `icon-image-cache.test.ts` into this file — it does not exist here (repo-wide it lives only where Task 6/8 add it). - Fixtures note (layout-less transient): fixture stores must seed `state.panes.layouts` entries for every created tab (the real `addTab` never does — tabsSlice.ts:296), OR expectations must explicitly account for `panesForTab`'s synthesized single-pane fallback (Task 2) — otherwise sort/icon expectations flake on the layout-less transient. - Produces: user-story coverage for the redesign. -- [ ] **Step 1: Write the new scenarios (they must fail only if the feature regresses — write them, run, expect PASS since Tasks 1–10 landed; any failure here is a real integration bug to fix before commit)** +- [ ] **Step 1: Write the new scenarios (first apply BOTH harness extensions from the Interfaces section — the `setup()` 4th param and the ported `deferredLoader` — then write the scenarios, run, expect PASS since Tasks 1–10 landed; once the harness extensions are in place, any failure here is a real integration bug to fix before commit)** Add these scenarios (full code, following the suite's existing `setup()`/`decodeKey` style): @@ -1750,6 +1753,9 @@ it('busy and idle-running tabs expose blue/green dots', () => { }) it('repo icons: unready at first paint, repaint to ready when the bitmap loads', async () => { + // Requires both harness extensions (Interfaces): setup()'s 4th extra-controller-options + // param (else { iconCache } is silently ignored and pending stays empty) and the + // deferredLoader helper ported from icon-image-cache.test.ts. const { loader, pending } = deferredLoader() const cache = new IconImageCache(loader) const { device } = setup({ From f5c5848fdcd94ebb47c853d4e30c423a6d88aa38 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 03:53:12 -0700 Subject: [PATCH 06/30] feat(deck): pure tile classification - fill, dot, and sort priority from tab-bar state flags --- src/deck/tile-state.ts | 38 ++++++++++++++++++++ test/unit/client/deck/tile-state.test.ts | 45 ++++++++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 src/deck/tile-state.ts create mode 100644 test/unit/client/deck/tile-state.test.ts diff --git a/src/deck/tile-state.ts b/src/deck/tile-state.ts new file mode 100644 index 000000000..983ac9718 --- /dev/null +++ b/src/deck/tile-state.ts @@ -0,0 +1,38 @@ +// Pure per-tab tile classification for the Stream Deck tiles. +// Mirrors the tab bar's visual states (src/components/TabItem.tsx): +// bar-on-top <-> active tab with attention -> fill 'barTop' +// green fill <-> inactive tab with attention -> fill 'green' +// green icon <-> a running, non-busy pane -> dot 'green' +// blue icon <-> any busy pane -> dot 'blue' +// Sort priority (spec): barTop, greenFill, greenIcon, blueIcon, rest. + +export type TileFill = 'barTop' | 'green' | 'none' +export type TileDot = 'green' | 'blue' | null + +export type TabStatusFlags = { + /** Any pane in the tab is busy (getBusyPaneIdsForTab). */ + busy: boolean + /** Turn-complete attention (turnCompletion.attentionByTab), gated on tabAttentionStyle !== 'none'. */ + attention: boolean + /** Any non-busy pane with effective status 'running' (TabItem.tsx:135-147). */ + greenIcon: boolean +} + +export function tileFill(active: boolean, flags: TabStatusFlags): TileFill { + if (flags.attention) return active ? 'barTop' : 'green' + return 'none' +} + +export function tileDot(flags: TabStatusFlags): TileDot { + if (flags.busy) return 'blue' + if (flags.greenIcon) return 'green' + return null +} + +/** 0 bar-on-top, 1 green-filled, 2 green-icon, 3 blue-icon, 4 rest. Busy dominates greenIcon. */ +export function tilePriority(active: boolean, flags: TabStatusFlags): number { + if (flags.attention) return active ? 0 : 1 + if (flags.busy) return 3 + if (flags.greenIcon) return 2 + return 4 +} diff --git a/test/unit/client/deck/tile-state.test.ts b/test/unit/client/deck/tile-state.test.ts new file mode 100644 index 000000000..fa1feff66 --- /dev/null +++ b/test/unit/client/deck/tile-state.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from 'vitest' +import { tileFill, tileDot, tilePriority, type TabStatusFlags } from '@/deck/tile-state' + +const f = (over: Partial = {}): TabStatusFlags => ({ + busy: false, attention: false, greenIcon: false, ...over, +}) + +describe('tileFill', () => { + it('bar-on-top for active tab with attention (tab bar: border-t-success + bg wash)', () => { + expect(tileFill(true, f({ attention: true }))).toBe('barTop') + }) + it('green fill for inactive tab with attention (tab bar: bg-emerald-100)', () => { + expect(tileFill(false, f({ attention: true }))).toBe('green') + }) + it('no fill without attention, regardless of busy/green-icon/active', () => { + expect(tileFill(true, f())).toBe('none') + expect(tileFill(false, f({ busy: true, greenIcon: true }))).toBe('none') + }) +}) + +describe('tileDot', () => { + it('blue when any pane is busy (tab bar: text-blue-500), even if green icons exist', () => { + expect(tileDot(f({ busy: true, greenIcon: true }))).toBe('blue') + }) + it('green for a running non-busy pane (tab bar: text-success)', () => { + expect(tileDot(f({ greenIcon: true }))).toBe('green') + }) + it('null otherwise', () => { + expect(tileDot(f())).toBe(null) + }) +}) + +describe('tilePriority', () => { + it('orders: barTop(0) < greenFill(1) < greenIcon(2) < blueIcon(3) < rest(4)', () => { + expect(tilePriority(true, f({ attention: true }))).toBe(0) + expect(tilePriority(false, f({ attention: true }))).toBe(1) + expect(tilePriority(false, f({ greenIcon: true }))).toBe(2) + expect(tilePriority(false, f({ busy: true, greenIcon: true }))).toBe(3) // busy dominates + expect(tilePriority(false, f())).toBe(4) + expect(tilePriority(true, f())).toBe(4) // active alone is not a priority bucket + }) + it('attention outranks busy/greenIcon', () => { + expect(tilePriority(false, f({ attention: true, busy: true, greenIcon: true }))).toBe(1) + }) +}) From 36eeaa8101f977d7b0e496ebd4854013f0e104ac Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 04:02:39 -0700 Subject: [PATCH 07/30] feat(deck): getTabStatusFlags - busy/attention/greenIcon from the tab bar's own conditions --- src/deck/deck-selectors.ts | 58 +++++++++++++++++++- test/unit/client/deck/deck-selectors.test.ts | 58 +++++++++++++++++++- 2 files changed, 113 insertions(+), 3 deletions(-) diff --git a/src/deck/deck-selectors.ts b/src/deck/deck-selectors.ts index 7f3a3cf03..60c639652 100644 --- a/src/deck/deck-selectors.ts +++ b/src/deck/deck-selectors.ts @@ -1,6 +1,7 @@ import type { RootState } from '@/store/store' import type { Tab } from '@/store/types' -import type { FreshAgentPaneContent, PaneNode, TerminalPaneContent } from '@/store/paneTypes' +import type { FreshAgentPaneContent, PaneContent, PaneNode, TerminalPaneContent } from '@/store/paneTypes' +import type { TabStatusFlags } from './tile-state' import { collectPaneEntries } from '@/lib/pane-utils' import { getBusyPaneIdsForTab, hasWaitingPrompt, resolvePaneActivity } from '@/lib/pane-activity' import { getFreshOpenCodeRouteCwd } from '@/lib/fresh-opencode-route' @@ -48,6 +49,61 @@ export function getTabRingStatus(state: RootState, tab: Tab): TabRingStatus { } } +/** + * Pane entries for a tab, tolerant of layout-less tabs. This transient is REAL: + * addTab (tabsSlice.ts:296) never seeds a layout — PaneLayout.tsx:30-35 initializes + * it in a post-paint useEffect, and persisted-state restore can omit layout entries — + * while the deck repaints synchronously per dispatch, so it WILL paint such tabs. + * Mirrors the tab bar's live synthesis fallback (TabBar.tsx:203-221): synthesize a + * single terminal pane from the tab's own fields. Do NOT touch TabBar; this is the + * deck-local twin of that fallback. + */ +export function panesForTab(state: RootState, tab: Tab): Array<{ paneId: string; content: PaneContent }> { + const layout = state.panes.layouts[tab.id] + if (layout) return collectPaneEntries(layout) + if (!tab.mode) return [] + return [{ + paneId: tab.id, + content: { + kind: 'terminal' as const, + mode: tab.mode, + shell: tab.shell, + createRequestId: tab.createRequestId, + status: tab.status, + sessionRef: tab.sessionRef, + initialCwd: tab.initialCwd, + }, + }] +} + +/** + * Per-tab status flags, derived from the SAME conditions the tab bar uses: + * - busy: any pane busy (getBusyPaneIdsForTab, TabBar.tsx:329-338) + * - attention: turnCompletion.attentionByTab gated on tabAttentionStyle !== 'none' + * (TabItem.tsx:158-184 renders no bar/fill when the style is 'none') + * - greenIcon: any non-busy pane whose effective status is 'running' + * (TabItem.tsx:135-147; non-terminal pane kinds count as 'running') + */ +export function getTabStatusFlags(state: RootState, tab: Tab): TabStatusFlags { + const busyIds = getBusyPaneIdsForTab({ + tab, + paneLayouts: state.panes.layouts as Record, + ...activityInputs(state), + }) + const entries = panesForTab(state, tab) // layout entries, or the synthesized single pane + const greenIcon = entries.some(({ paneId, content }) => { + if (busyIds.includes(paneId)) return false + const status = content.kind === 'terminal' ? content.status : 'running' + return status === 'running' + }) + const attentionStyle = state.settings.settings.panes.tabAttentionStyle + return { + busy: busyIds.length > 0, + attention: !!state.turnCompletion.attentionByTab[tab.id] && attentionStyle !== 'none', + greenIcon, + } +} + export function selectDeckModel(state: RootState): DeckModel { const activeTabId = state.tabs.activeTabId return { diff --git a/test/unit/client/deck/deck-selectors.test.ts b/test/unit/client/deck/deck-selectors.test.ts index 27876b98c..990e47a49 100644 --- a/test/unit/client/deck/deck-selectors.test.ts +++ b/test/unit/client/deck/deck-selectors.test.ts @@ -11,8 +11,9 @@ import opencodeActivityReducer from '@/store/opencodeActivitySlice' import paneRuntimeActivityReducer from '@/store/paneRuntimeActivitySlice' import settingsReducer from '@/store/settingsSlice' import { makeFreshAgentSessionKey } from '@shared/fresh-agent' +import type { Tab } from '@/store/types' import { - findApproveTarget, findStopTarget, getTabRingStatus, selectDeckModel, + findApproveTarget, findStopTarget, getTabRingStatus, getTabStatusFlags, selectDeckModel, } from '@/deck/deck-selectors' const reducer = { @@ -30,6 +31,7 @@ function makeState(overrides: { attention?: Record pendingPermissions?: Record freshAgentRunning?: boolean + paneStatus?: Record } = {}) { const store = configureStore({ reducer, @@ -43,7 +45,7 @@ function makeState(overrides: { }, panes: { layouts: { - t1: { type: 'leaf', id: 'p1', content: { kind: 'terminal', terminalId: 'term-1', createRequestId: 'c1', status: 'running', mode: 'claude' } }, + t1: { type: 'leaf', id: 'p1', content: { kind: 'terminal', terminalId: 'term-1', createRequestId: 'c1', status: overrides.paneStatus?.p1 ?? 'running', mode: 'claude' } }, t2: { type: 'leaf', id: 'p2', content: { kind: 'fresh-agent', sessionType: 'freshclaude', provider: 'claude', sessionId: 's1', createRequestId: 'c2', status: 'running' } }, }, activePane: { t1: 'p1', t2: 'p2' }, @@ -164,3 +166,55 @@ describe('freshopencode targets carry cwd (server auth keys embed it — A8)', ( }) }) }) + +function tabsOf(state: never): Tab[] { + return (state as { tabs: { tabs: Tab[] } }).tabs.tabs +} + +function withTabAttentionStyle(state: never, style: 'none' | 'highlight'): never { + const clone = structuredClone(state) as { settings: { settings: { panes: { tabAttentionStyle: string } } } } + clone.settings.settings.panes.tabAttentionStyle = style + return clone as never +} + +describe('getTabStatusFlags', () => { + it('greenIcon: running non-busy pane sets greenIcon (tab bar green icon condition)', () => { + const state = makeState() // default fixture: t1 has a claude terminal pane, status running, not busy + const tab = tabsOf(state)[0] + expect(getTabStatusFlags(state, tab)).toEqual({ busy: false, attention: false, greenIcon: true }) + }) + + it('busy pane sets busy and suppresses greenIcon when it is the only pane', () => { + const state = makeState({ claudeBusy: true }) // term-1 busy; p1 is t1's only pane + expect(getTabStatusFlags(state, tabsOf(state)[0])).toEqual({ busy: true, attention: false, greenIcon: false }) + }) + + it('attention flag mirrors turnCompletion.attentionByTab', () => { + const state = makeState({ attention: { t1: true } }) + expect(getTabStatusFlags(state, tabsOf(state)[0]).attention).toBe(true) + }) + + it("attention is gated off when tabAttentionStyle is 'none' (tab bar shows no bar/fill then)", () => { + const state = withTabAttentionStyle(makeState({ attention: { t1: true } }), 'none') + expect(getTabStatusFlags(state, tabsOf(state)[0]).attention).toBe(false) + }) + + it('exited terminal pane yields no greenIcon', () => { + const state = makeState({ paneStatus: { p1: 'exited' } }) + expect(getTabStatusFlags(state, tabsOf(state)[0]).greenIcon).toBe(false) + }) + + it('tab with NO pane layout classifies from the synthesized pane (tab.mode/tab.status), matching the tab bar', () => { + // Real transient: addTab (tabsSlice.ts:296) never seeds a layout — PaneLayout.tsx:30-35 + // initializes it in a post-paint useEffect, persisted-state restore can omit layout entries, + // and the deck repaints synchronously per dispatch, so it WILL paint layout-less tabs. + const state = makeState() + // Fixture tabs carry mode: 'shell' (only pane CONTENTS are mode 'claude'). The synthesized + // pane inherits tab.mode, and a shell-mode pane never yields greenIcon — so override the + // tab under test. + const tab = { ...tabsOf(state)[0], mode: 'claude' as const, status: 'running' as const } + const base = state as { panes: Record } + const noLayout = { ...(state as object), panes: { ...base.panes, layouts: {} } } as never + expect(getTabStatusFlags(noLayout, tab)).toEqual({ busy: false, attention: false, greenIcon: true }) + }) +}) From 0ee95406ef31cd6c00e3e246f5670fedda965b97 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 04:12:09 -0700 Subject: [PATCH 08/30] feat(deck): getTabRepoIcons - tab-bar repo icon pipeline reused for tiles, cap 3 --- src/deck/deck-selectors.ts | 47 ++++++++ test/unit/client/deck/deck-selectors.test.ts | 109 ++++++++++++++++++- 2 files changed, 153 insertions(+), 3 deletions(-) diff --git a/src/deck/deck-selectors.ts b/src/deck/deck-selectors.ts index 60c639652..719977000 100644 --- a/src/deck/deck-selectors.ts +++ b/src/deck/deck-selectors.ts @@ -5,6 +5,8 @@ import type { TabStatusFlags } from './tile-state' import { collectPaneEntries } from '@/lib/pane-utils' import { getBusyPaneIdsForTab, hasWaitingPrompt, resolvePaneActivity } from '@/lib/pane-activity' import { getFreshOpenCodeRouteCwd } from '@/lib/fresh-opencode-route' +import { buildRepoIconUrl, pathBasename, resolvePaneRepoCwd } from '@/lib/repo-icon' +import { hueFromString } from '@/components/icons/RepoIcon' import { makeFreshAgentSessionKey } from '@shared/fresh-agent' export type TabRingStatus = { busy: boolean; green: boolean; amber: boolean } @@ -76,6 +78,51 @@ export function panesForTab(state: RootState, tab: Tab): Array<{ paneId: string; }] } +/** Mirrors MAX_REPO_ICONS in TabItem.tsx (locked decision: cap distinct repo icons at 3). */ +export const MAX_TILE_REPO_ICONS = 3 + +export type TileRepoIcon = { + /** /api/repo-icon URL when the repo has a detected icon, else null (letter avatar). */ + url: string | null + letter: string + hue: number +} + +/** + * Repo icons for a tab, using the SAME resolution pipeline as the tab bar + * (TabBar.tsx getPaneEntries -> repoIconInfoByCwd): resolvePaneRepoCwd per pane + * (panesForTab supplies layout entries or the TabBar.tsx:203-221-style synthesized + * pane for layout-less tabs), meta from state.repoIcons.byCwd (probed by the + * DeckController itself in Task 8; TabBar also probes when mounted), distinct + * repos in first-appearance order, capped at 3, silently truncated. + * Deliberate divergences from TabItem: considers ALL panes (not just the first + * 3 pane icons) and ignores settings.panes.repoIconsOnTabs (deck tiles always + * show their center glyph). + */ +export function getTabRepoIcons(state: RootState, tab: Tab): TileRepoIcon[] { + const terminalMetaById = state.terminalMeta.byTerminalId + const byCwd = state.repoIcons.byCwd + const seen = new Set() + const icons: TileRepoIcon[] = [] + for (const entry of panesForTab(state, tab)) { + const cwd = resolvePaneRepoCwd(entry.content, tab, terminalMetaById) + if (!cwd) continue + const meta = byCwd[cwd] + if (!meta || meta.status === 'loading') continue + const repoKey = meta.repoRoot || cwd + if (seen.has(repoKey)) continue + seen.add(repoKey) + const repoName = meta.repoName || pathBasename(repoKey) + icons.push({ + url: meta.hasIcon ? buildRepoIconUrl(cwd) : null, + letter: (repoName.trim()[0] || '?').toUpperCase(), + hue: hueFromString(repoName), + }) + if (icons.length >= MAX_TILE_REPO_ICONS) break + } + return icons +} + /** * Per-tab status flags, derived from the SAME conditions the tab bar uses: * - busy: any pane busy (getBusyPaneIdsForTab, TabBar.tsx:329-338) diff --git a/test/unit/client/deck/deck-selectors.test.ts b/test/unit/client/deck/deck-selectors.test.ts index 990e47a49..a0aebd960 100644 --- a/test/unit/client/deck/deck-selectors.test.ts +++ b/test/unit/client/deck/deck-selectors.test.ts @@ -10,10 +10,17 @@ import amplifierActivityReducer from '@/store/amplifierActivitySlice' import opencodeActivityReducer from '@/store/opencodeActivitySlice' import paneRuntimeActivityReducer from '@/store/paneRuntimeActivitySlice' import settingsReducer from '@/store/settingsSlice' +import terminalMetaReducer from '@/store/terminalMetaSlice' +import repoIconsReducer from '@/store/repoIconsSlice' import { makeFreshAgentSessionKey } from '@shared/fresh-agent' import type { Tab } from '@/store/types' +import type { PaneNode } from '@/store/paneTypes' +import type { TerminalMetaRecord } from '@/store/terminalMetaSlice' +import type { RepoIconEntry } from '@/store/repoIconsSlice' +import { buildRepoIconUrl } from '@/lib/repo-icon' +import { hueFromString } from '@/components/icons/RepoIcon' import { - findApproveTarget, findStopTarget, getTabRingStatus, getTabStatusFlags, selectDeckModel, + findApproveTarget, findStopTarget, getTabRepoIcons, getTabRingStatus, getTabStatusFlags, selectDeckModel, } from '@/deck/deck-selectors' const reducer = { @@ -21,7 +28,7 @@ const reducer = { freshAgent: freshAgentReducer, codexActivity: codexActivityReducer, claudeActivity: claudeActivityReducer, amplifierActivity: amplifierActivityReducer, opencodeActivity: opencodeActivityReducer, paneRuntimeActivity: paneRuntimeActivityReducer, - settings: settingsReducer, + settings: settingsReducer, terminalMeta: terminalMetaReducer, repoIcons: repoIconsReducer, } const s1Key = makeFreshAgentSessionKey({ sessionType: 'freshclaude', provider: 'claude', sessionId: 's1' }) @@ -32,6 +39,9 @@ function makeState(overrides: { pendingPermissions?: Record freshAgentRunning?: boolean paneStatus?: Record + terminalMeta?: Record + repoIcons?: Record + t1Layout?: PaneNode } = {}) { const store = configureStore({ reducer, @@ -45,7 +55,7 @@ function makeState(overrides: { }, panes: { layouts: { - t1: { type: 'leaf', id: 'p1', content: { kind: 'terminal', terminalId: 'term-1', createRequestId: 'c1', status: overrides.paneStatus?.p1 ?? 'running', mode: 'claude' } }, + t1: overrides.t1Layout ?? { type: 'leaf', id: 'p1', content: { kind: 'terminal', terminalId: 'term-1', createRequestId: 'c1', status: overrides.paneStatus?.p1 ?? 'running', mode: 'claude' } }, t2: { type: 'leaf', id: 'p2', content: { kind: 'fresh-agent', sessionType: 'freshclaude', provider: 'claude', sessionId: 's1', createRequestId: 'c2', status: 'running' } }, }, activePane: { t1: 'p1', t2: 'p2' }, @@ -53,6 +63,8 @@ function makeState(overrides: { zoomedPane: {}, refreshRequestsByPane: {}, restoreFallbackAttemptsByPane: {}, }, claudeActivity: { byTerminalId: overrides.claudeBusy ? { 'term-1': { phase: 'busy' } } : {} }, + terminalMeta: { byTerminalId: overrides.terminalMeta ?? {} }, + repoIcons: { byCwd: overrides.repoIcons ?? {} }, turnCompletion: { seq: 0, lastAtByTerminalId: {}, lastIdleAtByTerminalId: {}, pendingEvents: [], attentionByTab: overrides.attention ?? {}, attentionByPane: {}, @@ -218,3 +230,94 @@ describe('getTabStatusFlags', () => { expect(getTabStatusFlags(noLayout, tab)).toEqual({ busy: false, attention: false, greenIcon: true }) }) }) + +function meta(terminalId: string, cwd: string): TerminalMetaRecord { + return { terminalId, cwd, updatedAt: 1 } +} + +function claudeLeaf(id: string, terminalId: string): PaneNode { + return { type: 'leaf', id, content: { kind: 'terminal', terminalId, createRequestId: 'c1', status: 'running', mode: 'claude' } } +} + +function split(id: string, a: PaneNode, b: PaneNode): PaneNode { + return { type: 'split', id, direction: 'horizontal', children: [a, b], sizes: [50, 50] } +} + +describe('getTabRepoIcons', () => { + it('maps a resolved repo cwd with an icon to a repo-icon URL + letter + hue', () => { + const state = makeState({ + terminalMeta: { 'term-1': meta('term-1', '/repos/alpha') }, + repoIcons: { '/repos/alpha': { status: 'ready', repoRoot: '/repos/alpha', repoName: 'alpha', hasIcon: true } }, + }) + expect(getTabRepoIcons(state, tabsOf(state)[0])).toEqual([ + { url: buildRepoIconUrl('/repos/alpha'), letter: 'A', hue: hueFromString('alpha') }, + ]) + }) + + it('falls back to letter-only (url null) when the repo has no icon', () => { + const state = makeState({ + terminalMeta: { 'term-1': meta('term-1', '/repos/beta') }, + repoIcons: { '/repos/beta': { status: 'error', hasIcon: false, repoName: 'beta' } }, + }) + expect(getTabRepoIcons(state, tabsOf(state)[0])).toEqual([ + { url: null, letter: 'B', hue: hueFromString('beta') }, + ]) + }) + + it('skips cwds still loading, dedupes by repoKey, caps at 3 distinct repos', () => { + // 6 panes in one tab across cwds: loading, r1, r1-worktree (same repoRoot), r2, r3, r4. + // Expect exactly r1, r2, r3 in first-appearance order (r4 truncated by the cap). + const state = makeState({ + t1Layout: split('s1', + claudeLeaf('p1', 'term-loading'), + split('s2', + claudeLeaf('p2', 'term-r1a'), + split('s3', + claudeLeaf('p3', 'term-r1b'), + split('s4', + claudeLeaf('p4', 'term-r2'), + split('s5', claudeLeaf('p5', 'term-r3'), claudeLeaf('p6', 'term-r4')))))), + terminalMeta: { + 'term-loading': meta('term-loading', '/repos/loading'), + 'term-r1a': meta('term-r1a', '/repos/r1'), + 'term-r1b': meta('term-r1b', '/repos/r1-wt'), + 'term-r2': meta('term-r2', '/repos/r2'), + 'term-r3': meta('term-r3', '/repos/r3'), + 'term-r4': meta('term-r4', '/repos/r4'), + }, + repoIcons: { + '/repos/loading': { status: 'loading' }, + '/repos/r1': { status: 'ready', repoRoot: '/repos/r1', repoName: 'r1', hasIcon: true }, + '/repos/r1-wt': { status: 'ready', repoRoot: '/repos/r1', repoName: 'r1', hasIcon: true }, + '/repos/r2': { status: 'error', hasIcon: false, repoName: 'r2' }, + '/repos/r3': { status: 'ready', repoRoot: '/repos/r3', repoName: 'r3', hasIcon: true }, + '/repos/r4': { status: 'ready', repoRoot: '/repos/r4', repoName: 'r4', hasIcon: true }, + }, + }) + expect(getTabRepoIcons(state, tabsOf(state)[0])).toEqual([ + { url: buildRepoIconUrl('/repos/r1'), letter: 'R', hue: hueFromString('r1') }, + { url: null, letter: 'R', hue: hueFromString('r2') }, + { url: buildRepoIconUrl('/repos/r3'), letter: 'R', hue: hueFromString('r3') }, + ]) + }) + + it('returns [] for a tab with no repo-resolvable panes', () => { + const state = makeState() // no terminalMeta seeded, no initialCwd anywhere + expect(getTabRepoIcons(state, tabsOf(state)[0])).toEqual([]) + }) + + it('tab with NO pane layout derives its icon from the synthesized pane (tab.initialCwd), matching the tab bar', () => { + const state = makeState({ + repoIcons: { '/repos/alpha': { status: 'ready', repoRoot: '/repos/alpha', repoName: 'alpha', hasIcon: true } }, + }) + // Fixture tabs are mode: 'shell', and resolvePaneRepoCwd resolves terminal panes only + // when their mode is non-shell (isNonShellMode); the synthesized pane inherits tab.mode. + // Override mode alongside initialCwd or the icon can never appear. + const tab = { ...tabsOf(state)[0], mode: 'claude' as const, initialCwd: '/repos/alpha' } + const base = state as { panes: Record } + const noLayout = { ...(state as object), panes: { ...base.panes, layouts: {} } } as never + expect(getTabRepoIcons(noLayout, tab)).toEqual([ + { url: buildRepoIconUrl('/repos/alpha'), letter: 'A', hue: hueFromString('alpha') }, + ]) + }) +}) From 1b12d83ec88a27785072b10afdf11849153026d9 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 04:23:58 -0700 Subject: [PATCH 09/30] feat(deck): status-priority sorted DeckModel with fill/dot/repoIcons per tab --- src/deck/deck-selectors.ts | 40 ++++++-- test/e2e/stream-deck-flow.test.tsx | 42 +++++--- .../components/VirtualDeckPanel.test.tsx | 5 +- test/unit/client/deck/deck-controller.test.ts | 14 ++- test/unit/client/deck/deck-selectors.test.ts | 98 ++++++++++++++++--- test/unit/client/deck/frame.test.ts | 13 ++- 6 files changed, 166 insertions(+), 46 deletions(-) diff --git a/src/deck/deck-selectors.ts b/src/deck/deck-selectors.ts index 719977000..fbbdf0331 100644 --- a/src/deck/deck-selectors.ts +++ b/src/deck/deck-selectors.ts @@ -2,6 +2,7 @@ import type { RootState } from '@/store/store' import type { Tab } from '@/store/types' import type { FreshAgentPaneContent, PaneContent, PaneNode, TerminalPaneContent } from '@/store/paneTypes' import type { TabStatusFlags } from './tile-state' +import { tileFill, tileDot, tilePriority, type TileFill, type TileDot } from './tile-state' import { collectPaneEntries } from '@/lib/pane-utils' import { getBusyPaneIdsForTab, hasWaitingPrompt, resolvePaneActivity } from '@/lib/pane-activity' import { getFreshOpenCodeRouteCwd } from '@/lib/fresh-opencode-route' @@ -10,7 +11,20 @@ import { hueFromString } from '@/components/icons/RepoIcon' import { makeFreshAgentSessionKey } from '@shared/fresh-agent' export type TabRingStatus = { busy: boolean; green: boolean; amber: boolean } -export type DeckTab = { id: string; title: string; status: TabRingStatus; active: boolean } + +export type DeckTab = { + id: string + title: string + active: boolean + busy: boolean + attention: boolean + fill: TileFill + dot: TileDot + priority: number + repoIcons: TileRepoIcon[] + /** TRANSITIONAL: consumed by frame.ts ringColor/stripText until Task 9 removes rings. */ + status: TabRingStatus +} export type DeckModel = { tabs: DeckTab[]; activeTabId: string | null } function activityInputs(state: RootState) { @@ -153,15 +167,27 @@ export function getTabStatusFlags(state: RootState, tab: Tab): TabStatusFlags { export function selectDeckModel(state: RootState): DeckModel { const activeTabId = state.tabs.activeTabId - return { - activeTabId, - tabs: state.tabs.tabs.map((tab) => ({ + const tabs = state.tabs.tabs.map((tab) => { + const active = tab.id === activeTabId + const flags = getTabStatusFlags(state, tab) + return { id: tab.id, title: tab.title, - active: tab.id === activeTabId, + active, + busy: flags.busy, + attention: flags.attention, + fill: tileFill(active, flags), + dot: tileDot(flags), + priority: tilePriority(active, flags), + repoIcons: getTabRepoIcons(state, tab), status: getTabRingStatus(state, tab), - })), - } + } + }) + // Status-priority sort; Array.prototype.sort is stable, so tab-bar order + // is preserved within each priority group. Paging slices this sorted list + // (visibleTabs), so the pager pages over the sorted order automatically. + tabs.sort((a, b) => a.priority - b.priority) + return { activeTabId, tabs } } export type ApproveTarget = { diff --git a/test/e2e/stream-deck-flow.test.tsx b/test/e2e/stream-deck-flow.test.tsx index bb1edb423..357ff1c23 100644 --- a/test/e2e/stream-deck-flow.test.tsx +++ b/test/e2e/stream-deck-flow.test.tsx @@ -18,6 +18,8 @@ import amplifierActivityReducer from '@/store/amplifierActivitySlice' import opencodeActivityReducer from '@/store/opencodeActivitySlice' import paneRuntimeActivityReducer from '@/store/paneRuntimeActivitySlice' import settingsReducer from '@/store/settingsSlice' +import terminalMetaReducer from '@/store/terminalMetaSlice' +import repoIconsReducer from '@/store/repoIconsSlice' import { makeFreshAgentSessionKey } from '@shared/fresh-agent' import { FakeDeckDevice, PLUS_CAPS } from '@/deck/fake-deck-device' import type { DeckCapabilities } from '@/deck/deck-device' @@ -30,7 +32,7 @@ const reducer = { freshAgent: freshAgentReducer, codexActivity: codexActivityReducer, claudeActivity: claudeActivityReducer, amplifierActivity: amplifierActivityReducer, opencodeActivity: opencodeActivityReducer, paneRuntimeActivity: paneRuntimeActivityReducer, - settings: settingsReducer, + settings: settingsReducer, terminalMeta: terminalMetaReducer, repoIcons: repoIconsReducer, } const s1Key = makeFreshAgentSessionKey({ sessionType: 'freshclaude', provider: 'claude', sessionId: 's1' }) @@ -161,21 +163,25 @@ describe('Stream Deck e2e flows (fake transport, real store)', () => { freshAgentTab: 3, pendingPermissions: { r1: { requestId: 'r1' } }, }) + // Status-priority sort: t2 attention (greenFill) < t3 waiting fresh-agent + // (greenIcon) < t1 busy (blueIcon), so busy t1 lands after the others. expect(decodeKey(device, 0)).toEqual({ - kind: 'tab', tabId: 't1', title: 'tab1', previewLines: ['$ npm test', 'PASS'], ring: 'blue', active: true, + kind: 'tab', tabId: 't2', title: 'tab2', previewLines: [], ring: 'green', active: false, }) expect(decodeKey(device, 1)).toEqual({ - kind: 'tab', tabId: 't2', title: 'tab2', previewLines: [], ring: 'green', active: false, + kind: 'tab', tabId: 't3', title: 'tab3', previewLines: [], ring: 'amber', active: false, }) expect(decodeKey(device, 2)).toEqual({ - kind: 'tab', tabId: 't3', title: 'tab3', previewLines: [], ring: 'amber', active: false, + kind: 'tab', tabId: 't1', title: 'tab1', previewLines: ['$ npm test', 'PASS'], ring: 'blue', active: true, }) }) it('press focuses the tab in this browser', () => { const { store, device } = setup({ tabs: 3, attention: { t2: true } }) expect(store.getState().tabs.activeTabId).toBe('t1') - holdKey(device, 1, 100) + // t2 has attention (greenFill) so it sorts to key 0; after focus+dismiss + // all tabs are greenIcon again and t2 repaints at key 1 in tab-bar order + holdKey(device, 0, 100) const state = store.getState() expect(state.tabs.activeTabId).toBe('t2') expect(state.turnCompletion.attentionByTab.t2).toBeFalsy() @@ -186,9 +192,11 @@ describe('Stream Deck e2e flows (fake transport, real store)', () => { const { store, device } = setup({ tabs: 3, freshAgentTab: 3 }) expect(decodeKey(device, 0)).toMatchObject({ kind: 'tab', tabId: 't1', ring: null }) store.dispatch(upsertClaudeActivity({ terminals: [{ terminalId: 'term-1', phase: 'busy', updatedAt: 1 }] })) - expect(decodeKey(device, 0)).toMatchObject({ kind: 'tab', tabId: 't1', ring: 'blue' }) + // busy t1 (blueIcon) sorts after the green-icon tabs -> key 2 + expect(decodeKey(device, 2)).toMatchObject({ kind: 'tab', tabId: 't1', ring: 'blue' }) store.dispatch(markTabAttention({ tabId: 't1' })) - // green outranks blue even while the tab is still busy + // green outranks blue even while the tab is still busy; active+attention + // (barTop) sorts t1 back to key 0 expect(decodeKey(device, 0)).toMatchObject({ kind: 'tab', tabId: 't1', ring: 'green' }) store.dispatch(addPermissionRequest({ sessionId: 's1', sessionType: 'freshclaude', provider: 'claude', requestId: 'r9', @@ -230,20 +238,21 @@ describe('Stream Deck e2e flows (fake transport, real store)', () => { it('STOP with escalation on a terminal pane; abandoned layer auto-closes', () => { const { device } = setup({ busy: ['term-1'] }) - holdKey(device, 0, 600) + // busy t1 (blueIcon) sorts after green-icon t2 -> t1 lands on key 1 + holdKey(device, 1, 600) expect(decodeKey(device, 2)).toEqual({ kind: 'action', action: 'stop', enabled: true }) device.press(2) expect(sendMock.mock.calls[0][0]).toMatchObject({ type: 'terminal.input', terminalId: 'term-1', data: '\x1b' }) expect(decodeKey(device, 0)).toMatchObject({ kind: 'tab' }) // second STOP within the 5s escalation window -> Ctrl+C - holdKey(device, 0, 600) + holdKey(device, 1, 600) device.press(2) expect(sendMock.mock.calls[1][0]).toMatchObject({ type: 'terminal.input', terminalId: 'term-1', data: '\x03' }) // a layer left open auto-closes after the 10s timeout - holdKey(device, 0, 600) + holdKey(device, 1, 600) expect(decodeKey(device, 0)).toMatchObject({ kind: 'action', action: 'back' }) vi.advanceTimersByTime(10_500) - expect(decodeKey(device, 0)).toMatchObject({ kind: 'tab', tabId: 't1' }) + expect(decodeKey(device, 1)).toMatchObject({ kind: 'tab', tabId: 't1' }) expect(sendMock).toHaveBeenCalledTimes(2) }) @@ -265,9 +274,10 @@ describe('Stream Deck e2e flows (fake transport, real store)', () => { PLUS_CAPS, () => ({ brightness: 100, idleBrightness: 10, idleTimeoutSeconds: 1 }), ) - // no pager key on the dial profile: all 8 keys are tab tiles + // no pager key on the dial profile: all 8 keys are tab tiles. + // Sorted order: busy t1 (blueIcon) lands last, so page 1 shows t2..t9. for (let k = 0; k < PLUS_CAPS.keyCount; k++) { - expect(decodeKey(device, k)).toMatchObject({ kind: 'tab', tabId: `t${k + 1}` }) + expect(decodeKey(device, k)).toMatchObject({ kind: 'tab', tabId: `t${k + 2}` }) } expect(decodeStrip(device)).toBe('tab1 | page 1/2 | 1 busy 1 waiting') // dial 0 cycles the active tab and wraps in both directions @@ -279,15 +289,15 @@ describe('Stream Deck e2e flows (fake transport, real store)', () => { expect(store.getState().tabs.activeTabId).toBe('t10') // wraps first -> last device.emit({ type: 'dialRotate', dialIndex: 0, ticks: 1 }) expect(store.getState().tabs.activeTabId).toBe('t1') // wraps last -> first - // dial 1 pages, clamped at the last page + // dial 1 pages, clamped at the last page (sorted list: [t2..t10, t1]) device.emit({ type: 'dialRotate', dialIndex: 1, ticks: 1 }) expect(decodeStrip(device)).toContain('page 2/2') - expect(decodeKey(device, 0)).toMatchObject({ kind: 'tab', tabId: 't9' }) + expect(decodeKey(device, 0)).toMatchObject({ kind: 'tab', tabId: 't10' }) device.emit({ type: 'dialRotate', dialIndex: 1, ticks: 1 }) expect(decodeStrip(device)).toContain('page 2/2') // clamped device.emit({ type: 'dialPress', dialIndex: 1 }) expect(decodeStrip(device)).toContain('page 1/2') - expect(decodeKey(device, 0)).toMatchObject({ kind: 'tab', tabId: 't1' }) + expect(decodeKey(device, 0)).toMatchObject({ kind: 'tab', tabId: 't2' }) // touchTap while dimmed restores brightness vi.advanceTimersByTime(1_500) expect(device.brightnessHistory[device.brightnessHistory.length - 1]).toBe(10) diff --git a/test/unit/client/components/VirtualDeckPanel.test.tsx b/test/unit/client/components/VirtualDeckPanel.test.tsx index 05c94ba06..6fb93a48e 100644 --- a/test/unit/client/components/VirtualDeckPanel.test.tsx +++ b/test/unit/client/components/VirtualDeckPanel.test.tsx @@ -16,6 +16,8 @@ import amplifierActivityReducer from '@/store/amplifierActivitySlice' import opencodeActivityReducer from '@/store/opencodeActivitySlice' import paneRuntimeActivityReducer from '@/store/paneRuntimeActivitySlice' import settingsReducer from '@/store/settingsSlice' +import terminalMetaReducer from '@/store/terminalMetaSlice' +import repoIconsReducer from '@/store/repoIconsSlice' import deckReducer, { setVirtualDeckOpen } from '@/store/deckSlice' import VirtualDeckPanel from '@/components/VirtualDeckPanel' @@ -24,7 +26,8 @@ const reducer = { freshAgent: freshAgentReducer, codexActivity: codexActivityReducer, claudeActivity: claudeActivityReducer, amplifierActivity: amplifierActivityReducer, opencodeActivity: opencodeActivityReducer, paneRuntimeActivity: paneRuntimeActivityReducer, - settings: settingsReducer, deck: deckReducer, + settings: settingsReducer, terminalMeta: terminalMetaReducer, repoIcons: repoIconsReducer, + deck: deckReducer, } // Mirrors the Task 3 fixture builder with two seeded tabs: tabs t1/t2, terminal diff --git a/test/unit/client/deck/deck-controller.test.ts b/test/unit/client/deck/deck-controller.test.ts index 3f0109e10..a0bdbc36f 100644 --- a/test/unit/client/deck/deck-controller.test.ts +++ b/test/unit/client/deck/deck-controller.test.ts @@ -14,6 +14,8 @@ import amplifierActivityReducer from '@/store/amplifierActivitySlice' import opencodeActivityReducer from '@/store/opencodeActivitySlice' import paneRuntimeActivityReducer from '@/store/paneRuntimeActivitySlice' import settingsReducer from '@/store/settingsSlice' +import terminalMetaReducer from '@/store/terminalMetaSlice' +import repoIconsReducer from '@/store/repoIconsSlice' import { makeFreshAgentSessionKey } from '@shared/fresh-agent' import { FakeDeckDevice, PLUS_CAPS } from '@/deck/fake-deck-device' import type { DeckCapabilities } from '@/deck/deck-device' @@ -25,7 +27,7 @@ const reducer = { freshAgent: freshAgentReducer, codexActivity: codexActivityReducer, claudeActivity: claudeActivityReducer, amplifierActivity: amplifierActivityReducer, opencodeActivity: opencodeActivityReducer, paneRuntimeActivity: paneRuntimeActivityReducer, - settings: settingsReducer, + settings: settingsReducer, terminalMeta: terminalMetaReducer, repoIcons: repoIconsReducer, } const s1Key = makeFreshAgentSessionKey({ sessionType: 'freshclaude', provider: 'claude', sessionId: 's1' }) @@ -156,8 +158,9 @@ describe('DeckController', () => { it('short press focuses the tab in the browser and dismisses green', () => { const { store, device } = setup({ attention: { t2: true } }) - expect(decodeKey(device, 1)).toMatchObject({ kind: 'tab', tabId: 't2', ring: 'green', active: false }) - shortPress(device, 1) + // t2 has attention (priority 1) so it sorts ahead of green-icon t1 -> key 0 + expect(decodeKey(device, 0)).toMatchObject({ kind: 'tab', tabId: 't2', ring: 'green', active: false }) + shortPress(device, 0) const state = store.getState() expect(state.tabs.activeTabId).toBe('t2') expect(state.turnCompletion.attentionByTab.t2).toBeFalsy() @@ -229,13 +232,14 @@ describe('DeckController', () => { it('STOP on a busy terminal sends ESC, then Ctrl+C within 5s', () => { const { device } = setup({ claudeBusy: true }) - longPress(device, 0) + // busy t1 (priority 3) sorts after green-icon t2 -> t1 lands on key 1 + longPress(device, 1) expect(decodeKey(device, 2)).toEqual({ kind: 'action', action: 'stop', enabled: true }) device.press(2) expect(sendMock.mock.calls[0][0]).toMatchObject({ type: 'terminal.input', terminalId: 'term-1', data: '\x1b' }) expect(decodeKey(device, 0)).toMatchObject({ kind: 'tab' }) // second stop within the 5s escalation window -> Ctrl+C - longPress(device, 0) + longPress(device, 1) device.press(2) expect(sendMock.mock.calls[1][0]).toMatchObject({ type: 'terminal.input', terminalId: 'term-1', data: '\x03' }) }) diff --git a/test/unit/client/deck/deck-selectors.test.ts b/test/unit/client/deck/deck-selectors.test.ts index a0aebd960..0b398b3bc 100644 --- a/test/unit/client/deck/deck-selectors.test.ts +++ b/test/unit/client/deck/deck-selectors.test.ts @@ -42,27 +42,58 @@ function makeState(overrides: { terminalMeta?: Record repoIcons?: Record t1Layout?: PaneNode + /** When set, replaces the default 2-tab fixture with tabs t1..tN, each a terminal leaf pane pN (terminalId term-N, mode 'claude'). */ + tabs?: number + activeTab?: string + /** terminalIds marked busy via claudeActivity (mirrors the e2e fixture's busy option). */ + busy?: string[] } = {}) { + let tabsList: unknown[] + let layouts: Record + let activePane: Record + if (overrides.tabs !== undefined) { + const n = overrides.tabs + tabsList = Array.from({ length: n }, (_, i) => ({ + id: `t${i + 1}`, createRequestId: `c${i + 1}`, title: `tab${i + 1}`, status: 'running', mode: 'shell', createdAt: i + 1, + })) + layouts = {} + activePane = {} + for (let i = 1; i <= n; i++) { + layouts[`t${i}`] = { + type: 'leaf', id: `p${i}`, + content: { kind: 'terminal', terminalId: `term-${i}`, createRequestId: `c${i}`, status: overrides.paneStatus?.[`p${i}`] ?? 'running', mode: 'claude' }, + } + activePane[`t${i}`] = `p${i}` + } + } else { + tabsList = [ + { id: 't1', createRequestId: 'c1', title: 'build', status: 'running', mode: 'shell', createdAt: 1 }, + { id: 't2', createRequestId: 'c2', title: 'claude', status: 'running', mode: 'shell', createdAt: 2 }, + ] + layouts = { + t1: overrides.t1Layout ?? { type: 'leaf', id: 'p1', content: { kind: 'terminal', terminalId: 'term-1', createRequestId: 'c1', status: overrides.paneStatus?.p1 ?? 'running', mode: 'claude' } }, + t2: { type: 'leaf', id: 'p2', content: { kind: 'fresh-agent', sessionType: 'freshclaude', provider: 'claude', sessionId: 's1', createRequestId: 'c2', status: 'running' } }, + } + activePane = { t1: 'p1', t2: 'p2' } + } + const busyByTerminalId: Record = Object.fromEntries( + (overrides.busy ?? []).map((terminalId) => [terminalId, { terminalId, phase: 'busy', updatedAt: 1 }]), + ) + if (overrides.claudeBusy) busyByTerminalId['term-1'] = { phase: 'busy' } const store = configureStore({ reducer, preloadedState: { tabs: { - tabs: [ - { id: 't1', createRequestId: 'c1', title: 'build', status: 'running', mode: 'shell', createdAt: 1 }, - { id: 't2', createRequestId: 'c2', title: 'claude', status: 'running', mode: 'shell', createdAt: 2 }, - ], - activeTabId: 't1', renameRequestTabId: null, tombstones: [], + tabs: tabsList, + activeTabId: overrides.activeTab ?? 't1', renameRequestTabId: null, tombstones: [], }, panes: { - layouts: { - t1: overrides.t1Layout ?? { type: 'leaf', id: 'p1', content: { kind: 'terminal', terminalId: 'term-1', createRequestId: 'c1', status: overrides.paneStatus?.p1 ?? 'running', mode: 'claude' } }, - t2: { type: 'leaf', id: 'p2', content: { kind: 'fresh-agent', sessionType: 'freshclaude', provider: 'claude', sessionId: 's1', createRequestId: 'c2', status: 'running' } }, - }, - activePane: { t1: 'p1', t2: 'p2' }, + layouts, + activePane, paneTitles: {}, paneTitleSetByUser: {}, renameRequestTabId: null, renameRequestPaneId: null, zoomedPane: {}, refreshRequestsByPane: {}, restoreFallbackAttemptsByPane: {}, }, - claudeActivity: { byTerminalId: overrides.claudeBusy ? { 'term-1': { phase: 'busy' } } : {} }, + claudeActivity: { byTerminalId: busyByTerminalId }, terminalMeta: { byTerminalId: overrides.terminalMeta ?? {} }, repoIcons: { byCwd: overrides.repoIcons ?? {} }, turnCompletion: { @@ -96,12 +127,12 @@ describe('deck-selectors', () => { const state = makeState({ claudeBusy: true }) const tab = (state as { tabs: { tabs: unknown[] } }).tabs.tabs[0] expect(getTabRingStatus(state, tab as never).busy).toBe(true) - expect(selectDeckModel(state).tabs[0].status.busy).toBe(true) + expect(selectDeckModel(state).tabs.find((t) => t.id === 't1')!.status.busy).toBe(true) }) it('attentionByTab -> green', () => { const state = makeState({ attention: { t1: true } }) - expect(selectDeckModel(state).tabs[0].status.green).toBe(true) + expect(selectDeckModel(state).tabs.find((t) => t.id === 't1')!.status.green).toBe(true) }) it('pending permission -> amber on the fresh-agent tab, and busy is suppressed', () => { @@ -321,3 +352,44 @@ describe('getTabRepoIcons', () => { ]) }) }) + +describe('selectDeckModel (sorted, tile fields)', () => { + it('sorts tabs by priority: barTop, greenFill, greenIcon, blueIcon, rest', () => { + // t1 exited pane (rest), t2 busy (blueIcon), t3 running idle (greenIcon), + // t4 attention inactive (greenFill), t5 attention + active (barTop) + const state = makeState({ + tabs: 5, + activeTab: 't5', + paneStatus: { p1: 'exited' }, + busy: ['term-2'], + attention: { t4: true, t5: true }, + }) + const model = selectDeckModel(state) + expect(model.tabs.map((t) => t.id)).toEqual(['t5', 't4', 't3', 't2', 't1']) + expect(model.tabs.map((t) => t.priority)).toEqual([0, 1, 2, 3, 4]) + }) + + it('is stable within a priority group (tab-bar order preserved)', () => { + const state = makeState({ tabs: 3 }) // all three are greenIcon + const model = selectDeckModel(state) + expect(model.tabs.map((t) => t.id)).toEqual(['t1', 't2', 't3']) + }) + + it('carries fill, dot, and repoIcons per tab', () => { + const state = makeState({ + tabs: 2, + activeTab: 't1', + attention: { t1: true }, + busy: ['term-2'], + terminalMeta: { 'term-1': meta('term-1', '/repos/alpha') }, + repoIcons: { '/repos/alpha': { status: 'ready', repoRoot: '/repos/alpha', repoName: 'alpha', hasIcon: true } }, + }) + const model = selectDeckModel(state) + const t1 = model.tabs.find((t) => t.id === 't1')! + const t2 = model.tabs.find((t) => t.id === 't2')! + expect(t1.fill).toBe('barTop') + expect(t1.repoIcons).toEqual([{ url: buildRepoIconUrl('/repos/alpha'), letter: 'A', hue: hueFromString('alpha') }]) + expect(t2.fill).toBe('none') + expect(t2.dot).toBe('blue') + }) +}) diff --git a/test/unit/client/deck/frame.test.ts b/test/unit/client/deck/frame.test.ts index 35c0cdcc6..7af76adb3 100644 --- a/test/unit/client/deck/frame.test.ts +++ b/test/unit/client/deck/frame.test.ts @@ -3,15 +3,20 @@ import { MINI_CAPS, PLUS_CAPS } from '@/deck/fake-deck-device' import { ACTION_KEYS, buildFrame, clampPage, pageCount, planLayout, ringColor, stripText, visibleTabs, } from '@/deck/frame' -import type { DeckModel } from '@/deck/deck-selectors' +import type { DeckModel, DeckTab } from '@/deck/deck-selectors' const quiet = { busy: false, green: false, amber: false } +function makeDeckTab(over: Partial & Pick): DeckTab { + return { + active: false, busy: false, attention: false, fill: 'none', dot: null, + priority: 4, repoIcons: [], status: { ...quiet }, ...over, + } +} function model(n: number, activeId = 'tab-0'): DeckModel { return { activeTabId: activeId, - tabs: Array.from({ length: n }, (_, i) => ({ - id: `tab-${i}`, title: `Tab ${i}`, active: `tab-${i}` === activeId, status: { ...quiet }, - })), + tabs: Array.from({ length: n }, (_, i) => + makeDeckTab({ id: `tab-${i}`, title: `Tab ${i}`, active: `tab-${i}` === activeId })), } } const noPreview = () => [] From 1f0d17ad576544786a9e1f59595a60f821f2d946 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 04:32:34 -0700 Subject: [PATCH 10/30] feat(deck): KeySpec gains fill/dot/icons; buildFrame resolves icon readiness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- src/deck/deck-controller.ts | 1 + src/deck/frame.ts | 13 +++++++++-- test/e2e/stream-deck-flow.test.tsx | 3 +++ test/unit/client/deck/frame.test.ts | 35 ++++++++++++++++++++++++----- 4 files changed, 45 insertions(+), 7 deletions(-) diff --git a/src/deck/deck-controller.ts b/src/deck/deck-controller.ts index 19c8836d0..785378cc8 100644 --- a/src/deck/deck-controller.ts +++ b/src/deck/deck-controller.ts @@ -120,6 +120,7 @@ export class DeckController { page: this.page, actionLayer: this.actionLayerInputs(state), previewFor: (tabId) => this.previewFor(state, tabId), + iconReady: () => false, // stub; Task 8 wires the real icon cache }) let painted = false frame.keys.forEach((spec, keyIndex) => { diff --git a/src/deck/frame.ts b/src/deck/frame.ts index 3bb73e545..5f06c7965 100644 --- a/src/deck/frame.ts +++ b/src/deck/frame.ts @@ -1,11 +1,14 @@ import type { DeckCapabilities } from './deck-device' import type { DeckModel } from './deck-selectors' +import type { TileFill, TileDot } from './tile-state' export type RingColor = 'amber' | 'green' | 'blue' | null export type DeckAction = 'back' | 'approve' | 'stop' +export type TileIcon = { url: string | null; letter: string; hue: number; ready: boolean } export type KeySpec = | { kind: 'empty' } - | { kind: 'tab'; tabId: string; title: string; previewLines: string[]; ring: RingColor; active: boolean } + | { kind: 'tab'; tabId: string; title: string; previewLines: string[]; ring: RingColor; + active: boolean; fill: TileFill; dot: TileDot; icons: TileIcon[] } | { kind: 'pager'; page: number; pageCount: number } | { kind: 'action'; action: DeckAction; enabled: boolean } export type StripSpec = { text: string } | null @@ -82,9 +85,10 @@ export type FrameInputs = { page: number actionLayer: { tabId: string; approveEnabled: boolean; stopEnabled: boolean } | null previewFor: (tabId: string) => string[] + iconReady: (url: string) => boolean } -export function buildFrame({ model, caps, page, actionLayer, previewFor }: FrameInputs): FrameSpec { +export function buildFrame({ model, caps, page, actionLayer, previewFor, iconReady }: FrameInputs): FrameSpec { const plan = planLayout(caps, model.tabs.length) const pages = pageCount(model.tabs.length, plan.tabsPerPage) const keys: KeySpec[] = Array.from({ length: plan.keyCount }, () => ({ kind: 'empty' as const })) @@ -107,6 +111,11 @@ export function buildFrame({ model, caps, page, actionLayer, previewFor }: Frame keys[keyIndex] = { kind: 'tab', tabId: tab.id, title: tab.title, previewLines: previewFor(tab.id), ring: ringColor(tab.status), active: tab.active, + fill: tab.fill, dot: tab.dot, + icons: tab.repoIcons.map((icon) => ({ + ...icon, + ready: icon.url !== null && iconReady(icon.url), + })), } }) if (plan.pagerKey !== null) keys[plan.pagerKey] = { kind: 'pager', page: current, pageCount: pages } diff --git a/test/e2e/stream-deck-flow.test.tsx b/test/e2e/stream-deck-flow.test.tsx index 357ff1c23..90a52c471 100644 --- a/test/e2e/stream-deck-flow.test.tsx +++ b/test/e2e/stream-deck-flow.test.tsx @@ -167,12 +167,15 @@ describe('Stream Deck e2e flows (fake transport, real store)', () => { // (greenIcon) < t1 busy (blueIcon), so busy t1 lands after the others. expect(decodeKey(device, 0)).toEqual({ kind: 'tab', tabId: 't2', title: 'tab2', previewLines: [], ring: 'green', active: false, + fill: 'green', dot: 'green', icons: [], }) expect(decodeKey(device, 1)).toEqual({ kind: 'tab', tabId: 't3', title: 'tab3', previewLines: [], ring: 'amber', active: false, + fill: 'none', dot: 'green', icons: [], }) expect(decodeKey(device, 2)).toEqual({ kind: 'tab', tabId: 't1', title: 'tab1', previewLines: ['$ npm test', 'PASS'], ring: 'blue', active: true, + fill: 'none', dot: 'blue', icons: [], }) }) diff --git a/test/unit/client/deck/frame.test.ts b/test/unit/client/deck/frame.test.ts index 7af76adb3..b075c0966 100644 --- a/test/unit/client/deck/frame.test.ts +++ b/test/unit/client/deck/frame.test.ts @@ -20,6 +20,7 @@ function model(n: number, activeId = 'tab-0'): DeckModel { } } const noPreview = () => [] +const noIcon = () => false describe('planLayout', () => { it('mini, 3 tabs: keys mode, no pager, 6 tab slots', () => { @@ -63,7 +64,7 @@ describe('ringColor priority', () => { describe('buildFrame', () => { it('tabs fit: all tab tiles, active flag set, rest empty', () => { - const frame = buildFrame({ model: model(3), caps: MINI_CAPS, page: 1, actionLayer: null, previewFor: noPreview }) + const frame = buildFrame({ model: model(3), caps: MINI_CAPS, page: 1, actionLayer: null, previewFor: noPreview, iconReady: noIcon }) expect(frame.keys).toHaveLength(6) expect(frame.keys[0]).toMatchObject({ kind: 'tab', tabId: 'tab-0', title: 'Tab 0', active: true }) expect(frame.keys[2]).toMatchObject({ kind: 'tab', tabId: 'tab-2', active: false }) @@ -71,10 +72,10 @@ describe('buildFrame', () => { expect(frame.strip).toBeNull() }) it('overflow: pager key at 5 with page/pageCount; page 2 shows the tail', () => { - const f1 = buildFrame({ model: model(8), caps: MINI_CAPS, page: 1, actionLayer: null, previewFor: noPreview }) + const f1 = buildFrame({ model: model(8), caps: MINI_CAPS, page: 1, actionLayer: null, previewFor: noPreview, iconReady: noIcon }) expect(f1.keys[5]).toEqual({ kind: 'pager', page: 1, pageCount: 2 }) expect((f1.keys[0] as { tabId: string }).tabId).toBe('tab-0') - const f2 = buildFrame({ model: model(8), caps: MINI_CAPS, page: 2, actionLayer: null, previewFor: noPreview }) + const f2 = buildFrame({ model: model(8), caps: MINI_CAPS, page: 2, actionLayer: null, previewFor: noPreview, iconReady: noIcon }) expect((f2.keys[0] as { tabId: string }).tabId).toBe('tab-5') expect(f2.keys[3]).toEqual({ kind: 'empty' }) expect(f2.keys[5]).toEqual({ kind: 'pager', page: 2, pageCount: 2 }) @@ -82,18 +83,42 @@ describe('buildFrame', () => { it('action layer replaces the frame', () => { const frame = buildFrame({ model: model(3), caps: MINI_CAPS, page: 1, - actionLayer: { tabId: 'tab-1', approveEnabled: false, stopEnabled: true }, previewFor: noPreview, + actionLayer: { tabId: 'tab-1', approveEnabled: false, stopEnabled: true }, previewFor: noPreview, iconReady: noIcon, }) expect(frame.keys[ACTION_KEYS.back]).toEqual({ kind: 'action', action: 'back', enabled: true }) expect(frame.keys[ACTION_KEYS.approve]).toEqual({ kind: 'action', action: 'approve', enabled: false }) expect(frame.keys[ACTION_KEYS.stop]).toEqual({ kind: 'action', action: 'stop', enabled: true }) expect(frame.keys[3]).toEqual({ kind: 'empty' }) }) + it('buildFrame carries fill/dot/icons onto tab keys, with iconReady resolving readiness', () => { + const model = { + activeTabId: 't1', + tabs: [makeDeckTab({ + id: 't1', title: 'alpha', active: true, fill: 'barTop', dot: 'green', + repoIcons: [ + { url: '/api/repo-icon?cwd=%2Fr%2Fa', letter: 'A', hue: 120 }, + { url: null, letter: 'B', hue: 200 }, + ], + })], + } + const frame = buildFrame({ + model, caps: MINI_CAPS, page: 1, actionLayer: null, + previewFor: () => [], + iconReady: (url) => url === '/api/repo-icon?cwd=%2Fr%2Fa', + }) + expect(frame.keys[0]).toMatchObject({ + kind: 'tab', tabId: 't1', fill: 'barTop', dot: 'green', + icons: [ + { url: '/api/repo-icon?cwd=%2Fr%2Fa', letter: 'A', hue: 120, ready: true }, + { url: null, letter: 'B', hue: 200, ready: false }, + ], + }) + }) it('full mode fills the strip and never emits a pager', () => { const m = model(10) m.tabs[1].status.busy = true m.tabs[2].status.amber = true - const frame = buildFrame({ model: m, caps: PLUS_CAPS, page: 1, actionLayer: null, previewFor: noPreview }) + const frame = buildFrame({ model: m, caps: PLUS_CAPS, page: 1, actionLayer: null, previewFor: noPreview, iconReady: noIcon }) expect(frame.keys.every((k) => k.kind !== 'pager')).toBe(true) expect(frame.strip).toEqual({ text: 'Tab 0 | page 1/2 | 1 busy 1 waiting' }) }) From 5745df20aad3cee1976d33e0149d232b7aadbb1e Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 04:38:12 -0700 Subject: [PATCH 11/30] feat(deck): IconImageCache - async repo-icon bitmaps with letter-avatar fallback and drawn-empty probe --- src/deck/icon-image-cache.ts | 111 +++++++++++++++++ .../unit/client/deck/icon-image-cache.test.ts | 116 ++++++++++++++++++ 2 files changed, 227 insertions(+) create mode 100644 src/deck/icon-image-cache.ts create mode 100644 test/unit/client/deck/icon-image-cache.test.ts diff --git a/src/deck/icon-image-cache.ts b/src/deck/icon-image-cache.ts new file mode 100644 index 000000000..3653fb1fa --- /dev/null +++ b/src/deck/icon-image-cache.ts @@ -0,0 +1,111 @@ +// Async bitmap cache for repo icons drawn on Stream Deck tiles. +// Canvas analogue of RepoIcon.tsx's + onError-fallback: while a URL is +// loading (or after it fails) bitmapFor returns null and the tile renderer +// draws the letter avatar; when a load completes, subscribers (the deck +// controller) are notified so tiles repaint with the real icon. +// Failures are cached permanently for the session (like -> +// letter avatar; the server caches negatives too). +// All error paths are SILENT - no console.error/console.warn (console.error is +// fatal in tests, and a failed icon is expected, not exceptional). + +export type IconLoader = (url: string) => Promise + +const defaultLoader: IconLoader = (url) => + new Promise((resolve, reject) => { + const img = new Image() + img.onload = () => resolve(img) + img.onerror = () => reject(new Error(`repo icon load failed: ${url}`)) + img.src = url + }) + +/** True when the decoded bitmap actually draws pixels (guards the SVG drawn-empty trap). */ +export type IconProbe = (bitmap: CanvasImageSource) => boolean + +export const DRAWN_EMPTY_PROBE_SIZE = 16 +/** Minimum fraction of non-transparent pixels for a draw to count as visible. */ +export const DRAWN_EMPTY_MIN_ALPHA_COVERAGE = 0.01 + +/** Pure threshold logic (exported for unit tests): >= 1% of pixels have alpha > 0. */ +export function hasDrawnPixels(data: Uint8ClampedArray): boolean { + const pixels = data.length / 4 + let opaque = 0 + for (let i = 3; i < data.length; i += 4) { + if (data[i] > 0) opaque++ + } + return pixels > 0 && opaque / pixels >= DRAWN_EMPTY_MIN_ALPHA_COVERAGE +} + +// Runtime-only drawn-empty probe. The server serves dimensionless SVGs first-class +// (repo_icon_detect.rs:51-52 "Unknown dimensions are acceptable"), and two servable +// shapes fire onload yet draw ~0 pixels in real Chromium (no-viewBox SVGs with +// off-viewport content; width/height=0 SVGs). Draw into a small internal canvas with +// EXPLICIT destination dims and count alpha; near-blank -> treat as failure so the +// letter avatar renders. In jsdom, getContext returns null: skip and trust the load. +const defaultProbe: IconProbe = (bitmap) => { + const canvas = document.createElement('canvas') + canvas.width = DRAWN_EMPTY_PROBE_SIZE + canvas.height = DRAWN_EMPTY_PROBE_SIZE + const ctx = canvas.getContext('2d') + if (!ctx) return true // jsdom / no 2D context: cannot probe, trust the load + ctx.clearRect(0, 0, DRAWN_EMPTY_PROBE_SIZE, DRAWN_EMPTY_PROBE_SIZE) + ctx.drawImage(bitmap, 0, 0, DRAWN_EMPTY_PROBE_SIZE, DRAWN_EMPTY_PROBE_SIZE) + return hasDrawnPixels(ctx.getImageData(0, 0, DRAWN_EMPTY_PROBE_SIZE, DRAWN_EMPTY_PROBE_SIZE).data) +} + +export class IconImageCache { + private bitmaps = new Map() + private failed = new Set() + private pending = new Set() + private listeners = new Set<() => void>() + + constructor( + private loader: IconLoader = defaultLoader, + private probe: IconProbe = defaultProbe, + ) {} + + /** Returns the decoded bitmap, or null while loading / after failure. Requests the load on first miss. */ + bitmapFor(url: string): CanvasImageSource | null { + const hit = this.bitmaps.get(url) + if (hit) return hit + if (!this.failed.has(url) && !this.pending.has(url)) { + this.pending.add(url) + void this.loader(url).then( + (bitmap) => { + this.pending.delete(url) + if (this.probe(bitmap)) { + this.bitmaps.set(url, bitmap) + } else { + this.failed.add(url) // drew ~0 pixels: record as FAILED -> letter avatar + } + this.notify() + }, + () => { + this.pending.delete(url) + this.failed.add(url) + this.notify() + }, + ) + } + return null + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + private notify(): void { + for (const listener of [...this.listeners]) listener() + } +} + +let singleton: IconImageCache | null = null + +export function getIconImageCache(): IconImageCache { + if (!singleton) singleton = new IconImageCache() + return singleton +} + +export function resetIconImageCacheForTests(cache?: IconImageCache): void { + singleton = cache ?? null +} diff --git a/test/unit/client/deck/icon-image-cache.test.ts b/test/unit/client/deck/icon-image-cache.test.ts new file mode 100644 index 000000000..56570281f --- /dev/null +++ b/test/unit/client/deck/icon-image-cache.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect, vi } from 'vitest' +import { IconImageCache, getIconImageCache, resetIconImageCacheForTests, hasDrawnPixels } from '@/deck/icon-image-cache' + +const fakeBitmap = { width: 16, height: 16 } as unknown as CanvasImageSource + +function deferredLoader() { + // NOTE: `pending` is a Map keyed by url, so a duplicate load for the same url would + // overwrite the same key and `pending.size` could never detect it. `calls()` counts + // actual loader invocations - that is the ONLY signal that can catch duplicate loads + // or a retry-after-failure implementation. + const pending = new Map void; reject: (e: Error) => void }>() + let loads = 0 + const loader = (url: string) => { + loads++ + return new Promise((resolve, reject) => pending.set(url, { resolve, reject })) + } + return { loader, pending, calls: () => loads } +} + +describe('IconImageCache', () => { + it('returns null while loading, kicks off exactly one load per url, notifies on completion', async () => { + const { loader, pending, calls } = deferredLoader() + const cache = new IconImageCache(loader) + const listener = vi.fn() + cache.subscribe(listener) + expect(cache.bitmapFor('/i/a')).toBe(null) + expect(cache.bitmapFor('/i/a')).toBe(null) // second call: no second load + expect(calls()).toBe(1) // loader invoked exactly once (pending.size can't see dupes) + pending.get('/i/a')!.resolve(fakeBitmap) + await Promise.resolve() // flush microtasks + await Promise.resolve() + expect(listener).toHaveBeenCalledTimes(1) + expect(cache.bitmapFor('/i/a')).toBe(fakeBitmap) + }) + + it('caches failures permanently (null forever, no retry) and still notifies', async () => { + const { loader, pending, calls } = deferredLoader() + const cache = new IconImageCache(loader) + const listener = vi.fn() + cache.subscribe(listener) + cache.bitmapFor('/i/broken') + pending.get('/i/broken')!.reject(new Error('404')) + await Promise.resolve() + await Promise.resolve() + expect(listener).toHaveBeenCalledTimes(1) + expect(cache.bitmapFor('/i/broken')).toBe(null) + expect(cache.bitmapFor('/i/broken')).toBe(null) + // The load-bearing no-retry assertion: post-failure reads never re-invoke the loader. + // (A retrying implementation would re-kick the load on every bitmapFor -> fetch/repaint + // loop in production; pending.size stays 1 either way, so it proves nothing.) + expect(calls()).toBe(1) + }) + + it('drawn-empty probe failing records the entry as FAILED (letter avatar renders), no retry', async () => { + const { loader, pending, calls } = deferredLoader() + const cache = new IconImageCache(loader, () => false) // injected probe: "drew ~0 pixels" + const listener = vi.fn() + cache.subscribe(listener) + cache.bitmapFor('/i/blank-svg') + pending.get('/i/blank-svg')!.resolve(fakeBitmap) + await Promise.resolve() + await Promise.resolve() + expect(listener).toHaveBeenCalledTimes(1) + expect(cache.bitmapFor('/i/blank-svg')).toBe(null) // failed, like a load error + expect(calls()).toBe(1) // permanent: the post-failure read above did not re-invoke the loader + }) + + it('drawn-empty probe passing keeps the bitmap', async () => { + const { loader, pending } = deferredLoader() + const cache = new IconImageCache(loader, () => true) + cache.bitmapFor('/i/ok') + pending.get('/i/ok')!.resolve(fakeBitmap) + await Promise.resolve() + await Promise.resolve() + expect(cache.bitmapFor('/i/ok')).toBe(fakeBitmap) + }) + + it('unsubscribe stops notifications', async () => { + const { loader, pending } = deferredLoader() + const cache = new IconImageCache(loader) + const listener = vi.fn() + cache.subscribe(listener)() + cache.bitmapFor('/i/a') + pending.get('/i/a')!.resolve(fakeBitmap) + await Promise.resolve() + await Promise.resolve() + expect(listener).not.toHaveBeenCalled() + }) + + it('singleton: getIconImageCache returns the same instance; reset swaps it for tests', () => { + resetIconImageCacheForTests() + const a = getIconImageCache() + expect(getIconImageCache()).toBe(a) + const fake = new IconImageCache(async () => fakeBitmap) + resetIconImageCacheForTests(fake) + expect(getIconImageCache()).toBe(fake) + resetIconImageCacheForTests() + }) +}) + +describe('hasDrawnPixels (drawn-empty threshold)', () => { + const px = (alphas: number[]): Uint8ClampedArray => { + const data = new Uint8ClampedArray(alphas.length * 4) + alphas.forEach((a, i) => { data[i * 4 + 3] = a }) + return data + } + it('false for a fully transparent draw', () => { + expect(hasDrawnPixels(px(new Array(100).fill(0)))).toBe(false) + }) + it('true at >= 1% alpha coverage', () => { + expect(hasDrawnPixels(px([255, ...new Array(99).fill(0)]))).toBe(true) // exactly 1% + }) + it('false just below 1% coverage', () => { + expect(hasDrawnPixels(px([255, ...new Array(199).fill(0)]))).toBe(false) // 0.5% + }) +}) From 010664d77896295989015bdb6dabfd5c5ddd0d0d Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 04:45:39 -0700 Subject: [PATCH 12/30] feat(deck): tab-bar-matching tile rendering - fills, repo icons, status dot, active ring --- src/components/VirtualDeckPanel.tsx | 4 +- src/deck/tile-renderer.ts | 100 +++++++++++----- test/unit/client/deck/tile-renderer.test.ts | 122 ++++++++++++++------ 3 files changed, 162 insertions(+), 64 deletions(-) diff --git a/src/components/VirtualDeckPanel.tsx b/src/components/VirtualDeckPanel.tsx index 93de5fac0..b337b7a76 100644 --- a/src/components/VirtualDeckPanel.tsx +++ b/src/components/VirtualDeckPanel.tsx @@ -9,6 +9,7 @@ import { setVirtualDeckOpen } from '@/store/deckSlice' import { FakeDeckDevice, MINI_CAPS, PLUS_CAPS } from '@/deck/fake-deck-device' import { DeckController } from '@/deck/deck-controller' import { renderKey, renderStrip, type Ctx2D, type CtxFactory } from '@/deck/tile-renderer' +import { getIconImageCache } from '@/deck/icon-image-cache' import { SegmentedControl } from '@/components/settings/settings-controls' type Profile = 'mini' | 'plus' @@ -25,6 +26,7 @@ function noopCtx(width: number, height: number): Ctx2D { textBaseline: 'top' as CanvasTextBaseline, fillRect: () => {}, fillText: () => {}, + drawImage: () => {}, measureText: () => ({ width: 0 }) as TextMetrics, getImageData: () => ({ data: new Uint8ClampedArray(width * height * 4) }) as ImageData, } @@ -81,7 +83,7 @@ export default function VirtualDeckPanel() { const controller = new DeckController({ store, device, - renderKey: (spec, c) => renderKey(spec, c, safeCtxFactory), + renderKey: (spec, c) => renderKey(spec, c, safeCtxFactory, (url) => getIconImageCache().bitmapFor(url)), renderStrip: (text, width, height) => renderStrip(text, width, height, safeCtxFactory), settings: () => store.getState().settings.settings.streamDeck, }) diff --git a/src/deck/tile-renderer.ts b/src/deck/tile-renderer.ts index 1057f58ac..46e246394 100644 --- a/src/deck/tile-renderer.ts +++ b/src/deck/tile-renderer.ts @@ -5,12 +5,12 @@ import type { DeckAction, KeySpec, RingColor } from './frame' // injectable 2D-context factory (jsdom returns null from getContext, so tests // always inject a fake context; defaultCtxFactory is runtime-only). -export type Ctx2D = Pick & { - fillStyle: string | CanvasGradient | CanvasPattern - font: string - textBaseline: CanvasTextBaseline -} +export type Ctx2D = Pick< + CanvasRenderingContext2D, + 'fillRect' | 'fillText' | 'measureText' | 'getImageData' | 'drawImage' +> & { fillStyle: string | CanvasGradient | CanvasPattern; font: string; textBaseline: CanvasTextBaseline } + +export type IconSource = (url: string) => CanvasImageSource | null export type CtxFactory = (width: number, height: number) => Ctx2D export type KeyRenderer = (spec: KeySpec, caps: DeckCapabilities) => Uint8ClampedArray export type StripRenderer = (text: string, width: number, height: number) => Uint8ClampedArray @@ -30,6 +30,16 @@ export const RING_COLORS: Record, string> = { blue: '#3b82f6', } export const ACTIVE_COLOR = '#ffffff' +export const TILE_BG = '#0a0a0a' +/** Light green fill - the tab bar's emerald attention fill, tuned for the LCD (emerald-200). */ +export const TILE_FILL_GREEN = '#a7f3d0' +/** The tab bar's bar-on-top green (--success, hsl(142 71% 45%)). */ +export const BAR_TOP_BORDER = '#21c45d' +/** Status dot: the tab bar's icon tint colors (text-success / text-blue-500). */ +export const DOT_GREEN = '#21c45d' +export const DOT_BLUE = '#3b82f6' +export const DOT_SIZE = 8 +export const ICON_GAP = 3 export const STOP_COLOR = '#ef4444' export const APPROVE_COLOR = '#22c55e' export const DISABLED_ACTION_COLOR = '#555555' @@ -63,6 +73,19 @@ export function fitLabel(measure: (t: string) => number, text: string, maxWidth: return `${t}…` } +/** Centered icon slots in the area below the title banner. */ +export function iconLayout(w: number, h: number, count: number): Array<{ x: number; y: number; size: number }> { + if (count <= 0) return [] + const areaTop = BANNER_HEIGHT + const areaH = h - areaTop + const scale = count === 1 ? 0.5 : 0.3 + const size = Math.round(Math.min(w, areaH) * scale) + const rowW = count * size + (count - 1) * ICON_GAP + const x0 = Math.round((w - rowW) / 2) + const y = Math.round(areaTop + (areaH - size) / 2) + return Array.from({ length: count }, (_, i) => ({ x: x0 + i * (size + ICON_GAP), y, size })) +} + export function drawRing(ctx: Ctx2D, w: number, h: number, color: string, width: number, inset = 0): void { ctx.fillStyle = color for (let i = 0; i < width; i++) { @@ -82,39 +105,53 @@ function drawCenteredText(ctx: Ctx2D, text: string, w: number, y: number): void const ACTION_LABELS: Record = { back: 'BACK', approve: 'APPROVE', stop: 'STOP' } const ACTION_RING: Record = { back: ACTIVE_COLOR, approve: APPROVE_COLOR, stop: STOP_COLOR } -function drawTab( - ctx: Ctx2D, w: number, h: number, - spec: Extract, -): void { - ctx.fillStyle = PREVIEW_BG +function drawTab(ctx: Ctx2D, w: number, h: number, spec: Extract, getIcon: IconSource): void { + // 1. Background mirrors the tab bar state: no fill / green fill / barTop (fill + border below). + ctx.fillStyle = spec.fill === 'none' ? TILE_BG : TILE_FILL_GREEN ctx.fillRect(0, 0, w, h) - const { lines, columns } = previewGeometry(w, h) - const body = cropPreviewLines(spec.previewLines, lines, columns) - ctx.font = `${PREVIEW_FONT_SIZE}px monospace` - ctx.textBaseline = 'top' - ctx.fillStyle = PREVIEW_TEXT_COLOR - const baseY = h - body.length * PREVIEW_LINE_HEIGHT - 2 - body.forEach((line, i) => { - if (line.trim() === '') return - ctx.fillText(line, PREVIEW_LEFT_MARGIN, baseY + i * PREVIEW_LINE_HEIGHT) + // 2. Centered repo icons; letter avatar while loading, on failure, or when the repo has no icon. + const slots = iconLayout(w, h, spec.icons.length) + spec.icons.forEach((icon, i) => { + const { x, y, size } = slots[i] + const bitmap = icon.url && icon.ready ? getIcon(icon.url) : null + if (bitmap) { + // ALWAYS pass explicit destination width AND height: dimensionless (viewBox-only) + // SVGs draw blank without them (verified headless Chromium 145; the server serves + // dimensionless SVGs first-class - repo_icon_detect.rs:51-52). Never call the + // 3-arg drawImage(image, dx, dy) form anywhere in this module. + ctx.drawImage(bitmap, x, y, size, size) + return + } + // Letter avatar (canvas analogue of RepoIcon's SVG circle): hue swatch + white letter. + ctx.fillStyle = `hsl(${icon.hue}, 60%, 42%)` + ctx.fillRect(x, y, size, size) + ctx.font = `600 ${Math.round(size * 0.6)}px sans-serif` + ctx.textBaseline = 'top' + ctx.fillStyle = '#ffffff' + const letterWidth = ctx.measureText(icon.letter).width + ctx.fillText(icon.letter, Math.round(x + (size - letterWidth) / 2), Math.round(y + size * 0.2)) }) + // 3. Status dot: the tab bar's green/blue icon-tint states, visible on the deck. + if (spec.dot) { + ctx.fillStyle = spec.dot === 'green' ? DOT_GREEN : DOT_BLUE + ctx.fillRect(Math.round((w - DOT_SIZE) / 2), h - DOT_SIZE - 5, DOT_SIZE, DOT_SIZE) + } + + // 4. Title banner across the top (unchanged treatment). ctx.fillStyle = BANNER_FILL ctx.fillRect(0, 0, w, BANNER_HEIGHT) - ctx.font = `${TITLE_FONT_SIZE}px sans-serif` ctx.textBaseline = 'top' ctx.fillStyle = ACTIVE_COLOR const label = fitLabel((t) => ctx.measureText(t).width, truncateTitle(spec.title), w - 4) drawCenteredText(ctx, label, w, 2) - const ringColor = spec.ring ? RING_COLORS[spec.ring] : null - if (ringColor && spec.active) { - drawRing(ctx, w, h, ringColor, 3, 0) - drawRing(ctx, w, h, ACTIVE_COLOR, 2, 3) - } else if (ringColor) { - drawRing(ctx, w, h, ringColor, 4, 0) + // 5. Borders/rings: barTop green border; white ring marks the active tab. + if (spec.fill === 'barTop') { + drawRing(ctx, w, h, BAR_TOP_BORDER, 3, 0) + if (spec.active) drawRing(ctx, w, h, ACTIVE_COLOR, 2, 3) } else if (spec.active) { drawRing(ctx, w, h, ACTIVE_COLOR, 3, 0) } @@ -156,7 +193,12 @@ function drawAction( drawRing(ctx, w, h, spec.enabled ? ACTION_RING[spec.action] : DISABLED_ACTION_COLOR, 3, 0) } -export function renderKey(spec: KeySpec, caps: DeckCapabilities, createCtx: CtxFactory): Uint8ClampedArray { +export function renderKey( + spec: KeySpec, + caps: DeckCapabilities, + createCtx: CtxFactory, + getIcon: IconSource = () => null, +): Uint8ClampedArray { const w = caps.keyPixelWidth const h = caps.keyPixelHeight const ctx = createCtx(w, h) @@ -166,7 +208,7 @@ export function renderKey(spec: KeySpec, caps: DeckCapabilities, createCtx: CtxF ctx.fillRect(0, 0, w, h) break case 'tab': - drawTab(ctx, w, h, spec) + drawTab(ctx, w, h, spec, getIcon) break case 'pager': drawPager(ctx, w, h, spec) diff --git a/test/unit/client/deck/tile-renderer.test.ts b/test/unit/client/deck/tile-renderer.test.ts index 831dbb348..68b6c4b70 100644 --- a/test/unit/client/deck/tile-renderer.test.ts +++ b/test/unit/client/deck/tile-renderer.test.ts @@ -1,17 +1,21 @@ import { describe, expect, it } from 'vitest' import { MINI_CAPS } from '@/deck/fake-deck-device' import { - cropPreviewLines, drawRing, fitLabel, previewGeometry, renderKey, truncateTitle, + cropPreviewLines, drawRing, fitLabel, iconLayout, previewGeometry, renderKey, truncateTitle, RING_COLORS, ACTIVE_COLOR, DISABLED_ACTION_COLOR, + TILE_BG, TILE_FILL_GREEN, BAR_TOP_BORDER, DOT_GREEN, DOT_BLUE, DOT_SIZE, } from '@/deck/tile-renderer' -import type { Ctx2D } from '@/deck/tile-renderer' +import type { Ctx2D, IconSource } from '@/deck/tile-renderer' +import type { KeySpec } from '@/deck/frame' type Rect = { x: number; y: number; w: number; h: number; style: string } type Text = { text: string; x: number; y: number; style: string; font: string } +type Img = { x: number; y: number; w: number; h: number } function recordingCtx(width: number, height: number) { const rects: Rect[] = [] const texts: Text[] = [] + const images: Img[] = [] const ctx = { fillStyle: '#000000' as string, font: '', @@ -22,10 +26,13 @@ function recordingCtx(width: number, height: number) { fillText(text: string, x: number, y: number) { texts.push({ text, x, y, style: String(this.fillStyle), font: this.font }) }, + drawImage(_src: CanvasImageSource, x: number, y: number, w: number, h: number) { + images.push({ x, y, w, h }) + }, measureText(t: string) { return { width: t.length * 6 } as TextMetrics }, getImageData() { return { data: new Uint8ClampedArray(width * height * 4) } as ImageData }, } as unknown as Ctx2D - return { ctx, rects, texts } + return { ctx, rects, texts, images } } describe('previewGeometry', () => { @@ -68,42 +75,89 @@ describe('drawRing', () => { }) }) +const tabSpec = (over: Partial> = {}): KeySpec => ({ + kind: 'tab', tabId: 't1', title: 'build', previewLines: [], ring: null, + active: false, fill: 'none', dot: null, icons: [], ...over, +}) + +function renderTab(spec: KeySpec, getIcon?: IconSource) { + let captured: ReturnType | null = null + const factory = (w: number, h: number) => { + captured = recordingCtx(w, h) + return captured.ctx + } + const out = renderKey(spec, MINI_CAPS, factory, getIcon) + const { rects, texts, images } = captured! + return { out, rects, texts, images } +} + describe('renderKey', () => { - it('tab tile: bg, preview text, banner, title, rings (status+active widths)', () => { - let captured: ReturnType | null = null - const factory = (w: number, h: number) => { - captured = recordingCtx(w, h) - return captured.ctx - } - const out = renderKey( - { kind: 'tab', tabId: 't1', title: 'build', previewLines: ['$ npm test', 'PASS'], ring: 'blue', active: true }, - MINI_CAPS, factory, - ) + it('no-fill tile: near-black bg, banner, white title, no rings, no dot, no preview text', () => { + const { out, rects, texts } = renderTab(tabSpec()) expect(out).toBeInstanceOf(Uint8ClampedArray) - const { rects, texts } = captured! - expect(rects[0]).toMatchObject({ x: 0, y: 0, w: 80, h: 80, style: '#0a0a0a' }) // bg - expect(texts.some((t) => t.text === '$ npm test' && t.style === '#a8a8a8')).toBe(true) // preview + expect(rects[0]).toMatchObject({ x: 0, y: 0, w: 80, h: 80, style: TILE_BG }) expect(rects.some((r) => r.y === 0 && r.h === 20 && r.style.startsWith('rgba'))).toBe(true) // banner - expect(texts.some((t) => t.text === 'build' && t.style === '#ffffff')).toBe(true) // title - const blue = rects.filter((r) => r.style === RING_COLORS.blue) - const white = rects.filter((r) => r.style === ACTIVE_COLOR && r.h <= 1) - expect(blue).toHaveLength(3 * 4) // 3px status ring: 3 frames x 4 rects each - // The h <= 1 filter matches ONLY the top+bottom strips of each 1px frame (2 per - // frame); drawRing paints verticals as single TALL rects (h = h - 2*o), which the - // filter deliberately excludes to avoid counting anything else white on the tile. - expect(white).toHaveLength(2 * 2) // 2px active ring at inset 3: 2 frames x 2 horizontal strips + expect(texts.some((t) => t.text === 'build' && t.style === '#ffffff')).toBe(true) // title + expect(rects.filter((r) => r.style === ACTIVE_COLOR)).toHaveLength(0) + expect(texts.filter((t) => t.style === '#a8a8a8')).toHaveLength(0) // preview text gone from drawTab (literal: the constant dies in Task 9) }) - it('status-only tile paints a 4px ring; active-only a 3px white ring', () => { - const make = (ring: 'green' | null, active: boolean) => { - let cap: ReturnType | null = null - renderKey({ kind: 'tab', tabId: 't', title: 't', previewLines: [], ring, active }, - MINI_CAPS, (w, h) => (cap = recordingCtx(w, h)).ctx) - return cap!.rects - } - expect(make('green', false).filter((r) => r.style === RING_COLORS.green)).toHaveLength(4 * 4) - // Same h <= 1 caveat as above: 3 frames x 2 horizontal strips each (verticals are tall rects). - expect(make(null, true).filter((r) => r.style === ACTIVE_COLOR && r.h <= 1)).toHaveLength(3 * 2) + it('green fill state paints the light-green background', () => { + const { rects } = renderTab(tabSpec({ fill: 'green' })) + expect(rects[0]).toMatchObject({ x: 0, y: 0, w: 80, h: 80, style: TILE_FILL_GREEN }) + }) + + it('barTop state paints light-green background + 3px green border ring', () => { + const { rects } = renderTab(tabSpec({ fill: 'barTop', active: true })) + expect(rects[0].style).toBe(TILE_FILL_GREEN) + expect(rects.filter((r) => r.style === BAR_TOP_BORDER).length).toBeGreaterThan(0) + // active tab keeps its white ring nested inside the border + expect(rects.filter((r) => r.style === ACTIVE_COLOR && r.h <= 1).length).toBeGreaterThan(0) + }) + + it('active tab without fill gets the plain white ring', () => { + const { rects } = renderTab(tabSpec({ active: true })) + expect(rects.filter((r) => r.style === ACTIVE_COLOR).length).toBeGreaterThan(0) + }) + + it('ready icon draws via drawImage at the centered layout slot', () => { + const bitmap = {} as CanvasImageSource + const { images } = renderTab( + tabSpec({ icons: [{ url: '/i/a', letter: 'A', hue: 120, ready: true }] }), + (url) => (url === '/i/a' ? bitmap : null), + ) + const [slot] = iconLayout(80, 80, 1) + expect(images).toEqual([{ x: slot.x, y: slot.y, w: slot.size, h: slot.size }]) + }) + + it('unready or letter-only icon draws the hue swatch + white letter fallback', () => { + const { rects, texts, images } = renderTab( + tabSpec({ icons: [{ url: null, letter: 'B', hue: 200, ready: false }] }), + ) + expect(images).toHaveLength(0) + expect(rects.some((r) => r.style === 'hsl(200, 60%, 42%)')).toBe(true) + expect(texts.some((t) => t.text === 'B' && t.style === '#ffffff')).toBe(true) + }) + + it('status dot: green and blue variants at bottom-center; absent when null', () => { + const green = renderTab(tabSpec({ dot: 'green' })) + expect(green.rects.some((r) => r.style === DOT_GREEN && r.w === DOT_SIZE && r.h === DOT_SIZE)).toBe(true) + const blue = renderTab(tabSpec({ dot: 'blue' })) + expect(blue.rects.some((r) => r.style === DOT_BLUE && r.w === DOT_SIZE && r.h === DOT_SIZE)).toBe(true) + const none = renderTab(tabSpec()) + expect(none.rects.some((r) => r.w === DOT_SIZE && r.h === DOT_SIZE)).toBe(false) + }) + + it('iconLayout: 1 icon centered large; 3 icons in a centered row below the banner', () => { + const one = iconLayout(80, 80, 1) + expect(one).toHaveLength(1) + expect(one[0].size).toBe(30) // round(min(80, 60) * 0.5) + expect(one[0].x).toBe(Math.round((80 - 30) / 2)) + expect(one[0].y).toBe(Math.round(20 + (60 - 30) / 2)) + const three = iconLayout(80, 80, 3) + expect(three).toHaveLength(3) + expect(three.every((s) => s.size === 18)).toBe(true) // round(60 * 0.3) + expect(three[1].x - three[0].x).toBe(18 + 3) // size + gap }) it('pager key renders PAGE / n/m / NEXT > on the control background', () => { From fb09f7e36a1c76824918de0097b62a390891c191 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 04:58:50 -0700 Subject: [PATCH 13/30] feat(deck): controller loads repo icons via IconImageCache, owns the meta probe, drops the preview repaint --- src/deck/deck-controller.ts | 64 ++++++---- src/deck/frame.ts | 6 +- test/e2e/stream-deck-flow.test.tsx | 4 +- test/unit/client/deck/deck-controller.test.ts | 114 +++++++++++++++++- test/unit/client/deck/deck-manager.test.ts | 7 ++ test/unit/client/deck/frame.test.ts | 12 +- 6 files changed, 169 insertions(+), 38 deletions(-) diff --git a/src/deck/deck-controller.ts b/src/deck/deck-controller.ts index 785378cc8..16d2a4b23 100644 --- a/src/deck/deck-controller.ts +++ b/src/deck/deck-controller.ts @@ -17,11 +17,12 @@ import type { KeySpec } from './frame' import type { DeckModel } from './deck-selectors' import type { RootState } from '@/store/store' import { ACTION_KEYS, buildFrame, clampPage, pageCount, planLayout, visibleTabs } from './frame' -import { findApproveTarget, findStopTarget, selectDeckModel } from './deck-selectors' +import { findApproveTarget, findStopTarget, panesForTab, selectDeckModel } from './deck-selectors' import { executeDeckStop, focusTabFromDeck, sendDeckApproval } from './deck-actions' import { dismissTabGreen } from '@/store/turnCompletionAttention' -import { findPaneContent } from '@/lib/pane-utils' -import { getTerminalTextSnapshot } from './terminal-text-registry' +import { fetchRepoIconMeta } from '@/store/repoIconsSlice' +import { resolvePaneRepoCwd } from '@/lib/repo-icon' +import { IconImageCache, getIconImageCache } from './icon-image-cache' import { defaultCtxFactory, renderKey as canvasRenderKey, renderStrip as canvasRenderStrip } from './tile-renderer' export type DeckControllerOptions = { @@ -31,13 +32,13 @@ export type DeckControllerOptions = { renderStrip?: (text: string, width: number, height: number) => Uint8ClampedArray settings: () => { brightness: number; idleBrightness: number; idleTimeoutSeconds: number } now?: () => number + iconCache?: IconImageCache } export const LONG_PRESS_MS = 500 export const ACTION_LAYER_TIMEOUT_MS = 10_000 export const STOP_ESCALATE_MS = 5_000 export const TICK_MS = 500 -export const PREVIEW_REFRESH_TICKS = 6 // previews re-checked every 3s export class DeckController { private readonly store: DeckControllerOptions['store'] @@ -46,6 +47,7 @@ export class DeckController { private readonly renderStripFn: (text: string, width: number, height: number) => Uint8ClampedArray private readonly settings: DeckControllerOptions['settings'] private readonly now: () => number + private readonly iconCache: IconImageCache private page = 1 private actionLayer: { tabId: string; openedAt: number } | null = null @@ -55,19 +57,21 @@ export class DeckController { private dimmed = false private lastPaintedSpecs: string[] = [] private lastStripText: string | null = null - private tickCount = 0 private lastModelJson: string | null = null private lastTabsPerPage: number | null = null private unsubscribeStore: (() => void) | null = null private unsubscribeInput: (() => void) | null = null + private unsubscribeIcons: (() => void) | null = null private intervalId: ReturnType | null = null private onVisibilityChange: (() => void) | null = null constructor(options: DeckControllerOptions) { this.store = options.store this.device = options.device - this.renderKeyFn = options.renderKey ?? ((spec, caps) => canvasRenderKey(spec, caps, defaultCtxFactory)) + this.iconCache = options.iconCache ?? getIconImageCache() + this.renderKeyFn = options.renderKey ?? + ((spec, caps) => canvasRenderKey(spec, caps, defaultCtxFactory, (url) => this.iconCache.bitmapFor(url))) this.renderStripFn = options.renderStrip ?? ((text, width, height) => canvasRenderStrip(text, width, height, defaultCtxFactory)) this.settings = options.settings this.now = options.now ?? (() => Date.now()) @@ -77,6 +81,8 @@ export class DeckController { this.lastActivityAt = this.now() void this.device.setBrightness(this.settings().brightness) this.repaint() + this.probeRepoIcons() + this.unsubscribeIcons = this.iconCache.subscribe(() => this.repaint()) this.unsubscribeStore = this.store.subscribe(() => this.onStoreChange()) this.unsubscribeInput = this.device.onInput((event) => this.handleInput(event)) this.intervalId = setInterval(() => this.tick(), TICK_MS) @@ -92,6 +98,8 @@ export class DeckController { this.unsubscribeStore = null this.unsubscribeInput?.() this.unsubscribeInput = null + this.unsubscribeIcons?.() + this.unsubscribeIcons = null if (this.intervalId !== null) { clearInterval(this.intervalId) this.intervalId = null @@ -119,8 +127,9 @@ export class DeckController { caps, page: this.page, actionLayer: this.actionLayerInputs(state), - previewFor: (tabId) => this.previewFor(state, tabId), - iconReady: () => false, // stub; Task 8 wires the real icon cache + // bitmapFor both reports readiness and requests the load - first paint + // of a tile with an unloaded icon starts the fetch. + iconReady: (url) => this.iconCache.bitmapFor(url) !== null, }) let painted = false frame.keys.forEach((spec, keyIndex) => { @@ -149,24 +158,39 @@ export class DeckController { } } - private previewFor(state: RootState, tabId: string): string[] { - const paneId = state.panes.activePane[tabId] - const layout = state.panes.layouts[tabId] - if (!paneId || !layout) return [] - const content = findPaneContent(layout, paneId) - if (content && content.kind === 'terminal' && content.terminalId) { - return getTerminalTextSnapshot(content.terminalId) ?? [] + /** + * Probe repo-icon meta for every distinct resolved cwd of the tabs we render. + * Deliberately UN-gated by settings.panes.repoIconsOnTabs (Design decision 7: + * deck tiles always show their center glyph). Double-probing alongside a + * mounted TabBar is harmless - the thunk self-dedupes (repoIconsSlice.ts:36-40). + */ + private probeRepoIcons(): void { + const state = this.store.getState() + const terminalMetaById = state.terminalMeta.byTerminalId + const cwds = new Set() + for (const tab of state.tabs.tabs) { + for (const entry of panesForTab(state, tab)) { + const cwd = resolvePaneRepoCwd(entry.content, tab, terminalMetaById) + if (cwd) cwds.add(cwd) + } + } + for (const cwd of cwds) { + if (!state.repoIcons.byCwd[cwd]) this.store.dispatch(fetchRepoIconMeta(cwd)) } - return [] } // --- store subscription --- private onStoreChange(): void { const state = this.store.getState() - // ORDERING (load-bearing): compare the model JSON BEFORE any xterm buffer - // reads - previewFor is only invoked by repaint, which we skip entirely - // when the model is unchanged. + // Probe BEFORE the model-JSON bail-out: the store events that first make a + // cwd resolvable (upsertTerminalMeta/setTerminalMetaSnapshot) do NOT change + // the model JSON (icons stay [] until meta AND repoIcons both exist), so a + // probe placed after the bail-out would never fire in the TabBar-less + // leader scenario this probe exists for. It cannot loop: the thunk's + // synchronous pending entry makes the byCwd guard skip that cwd on the + // re-entrant store change. + this.probeRepoIcons() const model = selectDeckModel(state) const modelJson = JSON.stringify(model) if (modelJson === this.lastModelJson) return @@ -327,8 +351,6 @@ export class DeckController { private tick(): void { this.dutyChecks() - this.tickCount++ - if (this.tickCount % PREVIEW_REFRESH_TICKS === 0) this.repaint() // picks up xterm buffer changes } private noteActivity(): void { diff --git a/src/deck/frame.ts b/src/deck/frame.ts index 5f06c7965..8dcc4b926 100644 --- a/src/deck/frame.ts +++ b/src/deck/frame.ts @@ -84,11 +84,10 @@ export type FrameInputs = { caps: DeckCapabilities page: number actionLayer: { tabId: string; approveEnabled: boolean; stopEnabled: boolean } | null - previewFor: (tabId: string) => string[] iconReady: (url: string) => boolean } -export function buildFrame({ model, caps, page, actionLayer, previewFor, iconReady }: FrameInputs): FrameSpec { +export function buildFrame({ model, caps, page, actionLayer, iconReady }: FrameInputs): FrameSpec { const plan = planLayout(caps, model.tabs.length) const pages = pageCount(model.tabs.length, plan.tabsPerPage) const keys: KeySpec[] = Array.from({ length: plan.keyCount }, () => ({ kind: 'empty' as const })) @@ -110,7 +109,8 @@ export function buildFrame({ model, caps, page, actionLayer, previewFor, iconRea if (!tab) return keys[keyIndex] = { kind: 'tab', tabId: tab.id, title: tab.title, - previewLines: previewFor(tab.id), ring: ringColor(tab.status), active: tab.active, + previewLines: [], // field dies in Task 9 + ring: ringColor(tab.status), active: tab.active, fill: tab.fill, dot: tab.dot, icons: tab.repoIcons.map((icon) => ({ ...icon, diff --git a/test/e2e/stream-deck-flow.test.tsx b/test/e2e/stream-deck-flow.test.tsx index 90a52c471..f7924f2df 100644 --- a/test/e2e/stream-deck-flow.test.tsx +++ b/test/e2e/stream-deck-flow.test.tsx @@ -174,7 +174,9 @@ describe('Stream Deck e2e flows (fake transport, real store)', () => { fill: 'none', dot: 'green', icons: [], }) expect(decodeKey(device, 2)).toEqual({ - kind: 'tab', tabId: 't1', title: 'tab1', previewLines: ['$ npm test', 'PASS'], ring: 'blue', active: true, + // previewLines is always [] since Task 8 (field dies in Task 9); the + // registered term-1 reader above is deliberately ignored. + kind: 'tab', tabId: 't1', title: 'tab1', previewLines: [], ring: 'blue', active: true, fill: 'none', dot: 'blue', icons: [], }) }) diff --git a/test/unit/client/deck/deck-controller.test.ts b/test/unit/client/deck/deck-controller.test.ts index a0bdbc36f..e1764d7b9 100644 --- a/test/unit/client/deck/deck-controller.test.ts +++ b/test/unit/client/deck/deck-controller.test.ts @@ -13,13 +13,15 @@ import claudeActivityReducer from '@/store/claudeActivitySlice' import amplifierActivityReducer from '@/store/amplifierActivitySlice' import opencodeActivityReducer from '@/store/opencodeActivitySlice' import paneRuntimeActivityReducer from '@/store/paneRuntimeActivitySlice' -import settingsReducer from '@/store/settingsSlice' -import terminalMetaReducer from '@/store/terminalMetaSlice' -import repoIconsReducer from '@/store/repoIconsSlice' +import settingsReducer, { updateSettingsLocal } from '@/store/settingsSlice' +import terminalMetaReducer, { upsertTerminalMeta } from '@/store/terminalMetaSlice' +import repoIconsReducer, { type RepoIconEntry } from '@/store/repoIconsSlice' import { makeFreshAgentSessionKey } from '@shared/fresh-agent' import { FakeDeckDevice, PLUS_CAPS } from '@/deck/fake-deck-device' import type { DeckCapabilities } from '@/deck/deck-device' -import { DeckController } from '@/deck/deck-controller' +import { DeckController, type DeckControllerOptions } from '@/deck/deck-controller' +import { IconImageCache } from '@/deck/icon-image-cache' +import { registerTerminalTextReader } from '@/deck/terminal-text-registry' import type { KeySpec } from '@/deck/frame' const reducer = { @@ -39,6 +41,12 @@ type StoreOpts = { freshAgentTab?: boolean // makes t2 a fresh-agent pane bound to session s1 pendingPermissions?: Record freshAgentRunning?: boolean + /** Seed state.terminalMeta.byTerminalId (terminalId/updatedAt filled in). */ + terminalMeta?: Record + /** Seed state.repoIcons.byCwd. */ + repoIcons?: Record + /** Dispatch updateSettingsLocal({ panes: { repoIconsOnTabs } }) BEFORE the controller starts. */ + repoIconsOnTabs?: boolean } // Mirrors the Task 3 fixture builder, parameterized by tab count: tabs t1..tN, @@ -61,9 +69,19 @@ function makeStore(opts: StoreOpts = {}) { } activePane[`t${i}`] = `p${i}` } - return configureStore({ + const store = configureStore({ reducer, preloadedState: { + ...(opts.terminalMeta + ? { + terminalMeta: { + byTerminalId: Object.fromEntries(Object.entries(opts.terminalMeta).map( + ([terminalId, meta]) => [terminalId, { terminalId, updatedAt: 0, ...meta }], + )), + }, + } + : {}), + ...(opts.repoIcons ? { repoIcons: { byCwd: opts.repoIcons } } : {}), tabs: { tabs, activeTabId: 't1', renameRequestTabId: null, tombstones: [] }, panes: { layouts, activePane, @@ -89,6 +107,12 @@ function makeStore(opts: StoreOpts = {}) { }, } as never, }) + if (opts.repoIconsOnTabs !== undefined) { + // Precedent: deck-manager.test.ts:128 — the value must be in place BEFORE + // setup() constructs and starts the controller. + store.dispatch(updateSettingsLocal({ panes: { repoIconsOnTabs: opts.repoIconsOnTabs } })) + } + return store } // Spec-recording renderer: encodes the KeySpec JSON into the pixel buffer so @@ -104,11 +128,19 @@ function decodeStrip(device: FakeDeckDevice): string | null { return device.stripImage ? new TextDecoder().decode(device.stripImage.rgba as unknown as Uint8Array) : null } +// Deferred loader as in icon-image-cache.test.ts: resolve/reject each url by hand. +function deferredLoader() { + const pending = new Map void; reject: (e: Error) => void }>() + const loader = (url: string) => + new Promise((resolve, reject) => pending.set(url, { resolve, reject })) + return { loader, pending } +} + const settings = () => ({ brightness: 100, idleBrightness: 10, idleTimeoutSeconds: 300 }) let activeController: DeckController | null = null -function setup(opts: StoreOpts = {}, caps?: DeckCapabilities) { +function setup(opts: StoreOpts = {}, caps?: DeckCapabilities, extra?: Partial) { const store = makeStore(opts) const device = new FakeDeckDevice(caps) const controller = new DeckController({ @@ -117,6 +149,7 @@ function setup(opts: StoreOpts = {}, caps?: DeckCapabilities) { renderKey: (spec) => encodeSpec(spec), renderStrip: (text) => new TextEncoder().encode(text) as unknown as Uint8ClampedArray, settings, + ...extra, }) controller.start() activeController = controller @@ -289,4 +322,73 @@ describe('DeckController', () => { device.emit({ type: 'dialPress', dialIndex: 0 }) expect(store.getState().tabs.activeTabId).toBe('t10') // re-focus current active tab }) + + it('repaints keys when an icon bitmap finishes loading (cache subscription)', async () => { + // Deferred loader as in icon-image-cache.test.ts + const { loader, pending } = deferredLoader() + const cache = new IconImageCache(loader) + const { device } = setup({ + tabCount: 1, + terminalMeta: { 'term-1': { cwd: '/repos/alpha' } }, + repoIcons: { '/repos/alpha': { status: 'ready', repoRoot: '/repos/alpha', repoName: 'alpha', hasIcon: true } }, + }, undefined, { iconCache: cache }) + const before = decodeKey(device, 0)! + expect(before.kind === 'tab' && before.icons[0].ready).toBe(false) + pending.get(before.kind === 'tab' ? before.icons[0].url! : '')!.resolve({} as CanvasImageSource) + await vi.advanceTimersByTimeAsync(0) // flush the load microtask under fake timers + const after = decodeKey(device, 0)! + expect(after.kind === 'tab' && after.icons[0].ready).toBe(true) + }) + + it('no periodic preview repaint: 3s of ticks paints nothing even when terminal text changes', () => { + // A reader with a CHANGING snapshot is what makes this test able to go RED: with + // no reader registered, previewFor already yields [] and the per-key spec-JSON + // diff suppresses every paint, so the assertion would pass against unmodified + // code (vacuous). With the reader, current code's PREVIEW_REFRESH_TICKS branch + // repaints key 0 at the ~3s tick (new previewLines -> spec differs) and the test + // fails; it goes green only when previewFor and the tick branch are deleted. + // (Task 9 deletes the registry module itself; when it does, rework this test to + // drop the reader registration - the no-repaint guarantee becomes structural via + // Task 9's grep gate on PREVIEW_REFRESH_TICKS/registerTerminalTextReader.) + let n = 0 + const unregister = registerTerminalTextReader('term-1', () => [`line ${n++}`]) + const { device } = setup({ tabCount: 1 }) + device.keyImages.clear() + vi.advanceTimersByTime(3_000) + expect(device.keyImages.size).toBe(0) + unregister() + }) + + it('dispatches fetchRepoIconMeta for tab cwds even when settings.panes.repoIconsOnTabs is false (deck owns the probe)', () => { + // No repoIcons seeded: the controller itself must probe /repos/alpha. TabBar cannot be + // relied on (its probe is gated on repoIconsOnTabs and TabBar is conditionally mounted). + const { store } = setup({ + tabCount: 1, + terminalMeta: { 'term-1': { cwd: '/repos/alpha' } }, + repoIconsOnTabs: false, + }) + // The thunk's pending case records { status: 'loading' } synchronously on dispatch. + expect(store.getState().repoIcons.byCwd['/repos/alpha']).toMatchObject({ status: 'loading' }) + }) + + it('does not re-probe a cwd already present in state.repoIcons.byCwd', () => { + const { store } = setup({ + tabCount: 1, + terminalMeta: { 'term-1': { cwd: '/repos/alpha' } }, + repoIcons: { '/repos/alpha': { status: 'ready', repoRoot: '/repos/alpha', repoName: 'alpha', hasIcon: true } }, + }) + expect(store.getState().repoIcons.byCwd['/repos/alpha'].status).toBe('ready') // untouched, no 'loading' overwrite + }) + + it('probes a cwd that only becomes resolvable AFTER start (late terminalMeta, model JSON unchanged)', () => { + // Fixture panes have no initialCwd, so nothing is resolvable at start(). A later + // upsertTerminalMeta makes term-1's cwd resolvable but does NOT change the deck + // model JSON (icons stay [] until meta AND repoIcons both exist), so this test + // proves the probe runs BEFORE onStoreChange's model-JSON bail-out - the exact + // TabBar-less leader scenario the deck-owned probe exists for. + const { store } = setup({ tabCount: 1 }) // no terminalMeta seeded + expect(store.getState().repoIcons.byCwd['/repos/alpha']).toBeUndefined() + store.dispatch(upsertTerminalMeta([{ terminalId: 'term-1', cwd: '/repos/alpha', updatedAt: Date.now() }])) + expect(store.getState().repoIcons.byCwd['/repos/alpha']).toMatchObject({ status: 'loading' }) + }) }) diff --git a/test/unit/client/deck/deck-manager.test.ts b/test/unit/client/deck/deck-manager.test.ts index ef2b88233..afb4fbd22 100644 --- a/test/unit/client/deck/deck-manager.test.ts +++ b/test/unit/client/deck/deck-manager.test.ts @@ -11,6 +11,8 @@ import claudeActivityReducer from '@/store/claudeActivitySlice' import amplifierActivityReducer from '@/store/amplifierActivitySlice' import opencodeActivityReducer from '@/store/opencodeActivitySlice' import paneRuntimeActivityReducer from '@/store/paneRuntimeActivitySlice' +import terminalMetaReducer from '@/store/terminalMetaSlice' +import repoIconsReducer from '@/store/repoIconsSlice' import { FakeDeckDevice } from '@/deck/fake-deck-device' import { DeckOpenError } from '@/deck/webhid-transport' import { @@ -92,6 +94,11 @@ function makeStore() { amplifierActivity: amplifierActivityReducer, opencodeActivity: opencodeActivityReducer, paneRuntimeActivity: paneRuntimeActivityReducer, + // The real controller's probeRepoIcons() dereferences these slices even + // with zero tabs; without the reducers every test that reaches start() + // would die in a swallowed TypeError and never report 'connected'. + terminalMeta: terminalMetaReducer, + repoIcons: repoIconsReducer, }, }) } diff --git a/test/unit/client/deck/frame.test.ts b/test/unit/client/deck/frame.test.ts index b075c0966..839f30877 100644 --- a/test/unit/client/deck/frame.test.ts +++ b/test/unit/client/deck/frame.test.ts @@ -19,7 +19,6 @@ function model(n: number, activeId = 'tab-0'): DeckModel { makeDeckTab({ id: `tab-${i}`, title: `Tab ${i}`, active: `tab-${i}` === activeId })), } } -const noPreview = () => [] const noIcon = () => false describe('planLayout', () => { @@ -64,7 +63,7 @@ describe('ringColor priority', () => { describe('buildFrame', () => { it('tabs fit: all tab tiles, active flag set, rest empty', () => { - const frame = buildFrame({ model: model(3), caps: MINI_CAPS, page: 1, actionLayer: null, previewFor: noPreview, iconReady: noIcon }) + const frame = buildFrame({ model: model(3), caps: MINI_CAPS, page: 1, actionLayer: null, iconReady: noIcon }) expect(frame.keys).toHaveLength(6) expect(frame.keys[0]).toMatchObject({ kind: 'tab', tabId: 'tab-0', title: 'Tab 0', active: true }) expect(frame.keys[2]).toMatchObject({ kind: 'tab', tabId: 'tab-2', active: false }) @@ -72,10 +71,10 @@ describe('buildFrame', () => { expect(frame.strip).toBeNull() }) it('overflow: pager key at 5 with page/pageCount; page 2 shows the tail', () => { - const f1 = buildFrame({ model: model(8), caps: MINI_CAPS, page: 1, actionLayer: null, previewFor: noPreview, iconReady: noIcon }) + const f1 = buildFrame({ model: model(8), caps: MINI_CAPS, page: 1, actionLayer: null, iconReady: noIcon }) expect(f1.keys[5]).toEqual({ kind: 'pager', page: 1, pageCount: 2 }) expect((f1.keys[0] as { tabId: string }).tabId).toBe('tab-0') - const f2 = buildFrame({ model: model(8), caps: MINI_CAPS, page: 2, actionLayer: null, previewFor: noPreview, iconReady: noIcon }) + const f2 = buildFrame({ model: model(8), caps: MINI_CAPS, page: 2, actionLayer: null, iconReady: noIcon }) expect((f2.keys[0] as { tabId: string }).tabId).toBe('tab-5') expect(f2.keys[3]).toEqual({ kind: 'empty' }) expect(f2.keys[5]).toEqual({ kind: 'pager', page: 2, pageCount: 2 }) @@ -83,7 +82,7 @@ describe('buildFrame', () => { it('action layer replaces the frame', () => { const frame = buildFrame({ model: model(3), caps: MINI_CAPS, page: 1, - actionLayer: { tabId: 'tab-1', approveEnabled: false, stopEnabled: true }, previewFor: noPreview, iconReady: noIcon, + actionLayer: { tabId: 'tab-1', approveEnabled: false, stopEnabled: true }, iconReady: noIcon, }) expect(frame.keys[ACTION_KEYS.back]).toEqual({ kind: 'action', action: 'back', enabled: true }) expect(frame.keys[ACTION_KEYS.approve]).toEqual({ kind: 'action', action: 'approve', enabled: false }) @@ -103,7 +102,6 @@ describe('buildFrame', () => { } const frame = buildFrame({ model, caps: MINI_CAPS, page: 1, actionLayer: null, - previewFor: () => [], iconReady: (url) => url === '/api/repo-icon?cwd=%2Fr%2Fa', }) expect(frame.keys[0]).toMatchObject({ @@ -118,7 +116,7 @@ describe('buildFrame', () => { const m = model(10) m.tabs[1].status.busy = true m.tabs[2].status.amber = true - const frame = buildFrame({ model: m, caps: PLUS_CAPS, page: 1, actionLayer: null, previewFor: noPreview, iconReady: noIcon }) + const frame = buildFrame({ model: m, caps: PLUS_CAPS, page: 1, actionLayer: null, iconReady: noIcon }) expect(frame.keys.every((k) => k.kind !== 'pager')).toBe(true) expect(frame.strip).toEqual({ text: 'Tab 0 | page 1/2 | 1 busy 1 waiting' }) }) From ef52f33407023539dbcd9756dcbaa029a2fc878d Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 05:14:28 -0700 Subject: [PATCH 14/30] refactor(deck): remove terminal preview machinery and status rings --- src/components/TerminalView.tsx | 4 -- src/deck/deck-selectors.ts | 18 ------ src/deck/frame.ts | 23 ++----- src/deck/terminal-text-registry.ts | 53 ---------------- src/deck/tile-renderer.ts | 26 +------- test/e2e/stream-deck-flow.test.tsx | 35 +++++------ test/unit/client/deck/deck-controller.test.ts | 26 +++----- test/unit/client/deck/deck-selectors.test.ts | 23 +++---- test/unit/client/deck/frame.test.ts | 31 ++++----- .../deck/terminal-text-registry.test.tsx | 63 ------------------- test/unit/client/deck/tile-renderer.test.ts | 25 ++------ 11 files changed, 62 insertions(+), 265 deletions(-) delete mode 100644 src/deck/terminal-text-registry.ts delete mode 100644 test/unit/client/deck/terminal-text-registry.test.tsx diff --git a/src/components/TerminalView.tsx b/src/components/TerminalView.tsx index 87f4eaee8..272bb1107 100644 --- a/src/components/TerminalView.tsx +++ b/src/components/TerminalView.tsx @@ -100,7 +100,6 @@ import { import { useMobile } from '@/hooks/useMobile' import { useKeyboardInset } from '@/hooks/useKeyboardInset' import { useEnsureExtensionsRegistry } from '@/hooks/useEnsureExtensionsRegistry' -import { useTerminalTextRegistration } from '@/deck/terminal-text-registry' import { findLocalFilePaths } from '@/lib/path-utils' import { findUrls } from '@/lib/url-utils' import { openExternalUrl, shouldOpenLinkExternally } from '@/lib/open-url' @@ -673,9 +672,6 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps) const isTerminal = paneContent.kind === 'terminal' const terminalContent = isTerminal ? paneContent : null - // Register live terminal text reader for Stream Deck previews - useTerminalTextRegistration(terminalContent?.terminalId, termRef) - const extensions = useAppSelector((s) => s.extensions?.entries ?? [], shallowEqual) const shouldResolveProviderBehavior = isTerminal && providerUsesExtensionTerminalBehavior(terminalContent?.mode) const extensionRegistryReady = useEnsureExtensionsRegistry(shouldResolveProviderBehavior) diff --git a/src/deck/deck-selectors.ts b/src/deck/deck-selectors.ts index fbbdf0331..badbfbc87 100644 --- a/src/deck/deck-selectors.ts +++ b/src/deck/deck-selectors.ts @@ -10,8 +10,6 @@ import { buildRepoIconUrl, pathBasename, resolvePaneRepoCwd } from '@/lib/repo-i import { hueFromString } from '@/components/icons/RepoIcon' import { makeFreshAgentSessionKey } from '@shared/fresh-agent' -export type TabRingStatus = { busy: boolean; green: boolean; amber: boolean } - export type DeckTab = { id: string title: string @@ -22,8 +20,6 @@ export type DeckTab = { dot: TileDot priority: number repoIcons: TileRepoIcon[] - /** TRANSITIONAL: consumed by frame.ts ringColor/stripText until Task 9 removes rings. */ - status: TabRingStatus } export type DeckModel = { tabs: DeckTab[]; activeTabId: string | null } @@ -52,19 +48,6 @@ export function tabHasPendingApproval(state: RootState, tabId: string): boolean entry.content.kind === 'fresh-agent' && hasWaitingPrompt(freshAgentSessionFor(state, entry.content))) } -export function getTabRingStatus(state: RootState, tab: Tab): TabRingStatus { - const busy = getBusyPaneIdsForTab({ - tab, - paneLayouts: state.panes.layouts as Record, - ...activityInputs(state), - }).length > 0 - return { - busy, - green: !!state.turnCompletion.attentionByTab[tab.id], - amber: tabHasPendingApproval(state, tab.id), - } -} - /** * Pane entries for a tab, tolerant of layout-less tabs. This transient is REAL: * addTab (tabsSlice.ts:296) never seeds a layout — PaneLayout.tsx:30-35 initializes @@ -180,7 +163,6 @@ export function selectDeckModel(state: RootState): DeckModel { dot: tileDot(flags), priority: tilePriority(active, flags), repoIcons: getTabRepoIcons(state, tab), - status: getTabRingStatus(state, tab), } }) // Status-priority sort; Array.prototype.sort is stable, so tab-bar order diff --git a/src/deck/frame.ts b/src/deck/frame.ts index 8dcc4b926..bbda5899a 100644 --- a/src/deck/frame.ts +++ b/src/deck/frame.ts @@ -2,13 +2,11 @@ import type { DeckCapabilities } from './deck-device' import type { DeckModel } from './deck-selectors' import type { TileFill, TileDot } from './tile-state' -export type RingColor = 'amber' | 'green' | 'blue' | null export type DeckAction = 'back' | 'approve' | 'stop' export type TileIcon = { url: string | null; letter: string; hue: number; ready: boolean } export type KeySpec = | { kind: 'empty' } - | { kind: 'tab'; tabId: string; title: string; previewLines: string[]; ring: RingColor; - active: boolean; fill: TileFill; dot: TileDot; icons: TileIcon[] } + | { kind: 'tab'; tabId: string; title: string; active: boolean; fill: TileFill; dot: TileDot; icons: TileIcon[] } | { kind: 'pager'; page: number; pageCount: number } | { kind: 'action'; action: DeckAction; enabled: boolean } export type StripSpec = { text: string } | null @@ -58,25 +56,18 @@ export function visibleTabs(tabs: T[], page: number, tabsPerPage: number): T[ return tabs.slice(start, start + tabsPerPage) } -export function ringColor(status: { busy: boolean; green: boolean; amber: boolean }): RingColor { - if (status.amber) return 'amber' - if (status.green) return 'green' - if (status.busy) return 'blue' - return null -} - function toAscii(text: string): string { return text.replace(/[^\x20-\x7e]/g, '?') } export function stripText( - model: { tabs: Array<{ title: string; active: boolean; status: { busy: boolean; amber: boolean } }> }, + model: { tabs: Array<{ title: string; active: boolean; busy: boolean; attention: boolean }> }, page: number, pages: number, ): string { const active = model.tabs.find((t) => t.active) - const busy = model.tabs.filter((t) => t.status.busy).length - const amber = model.tabs.filter((t) => t.status.amber).length - return toAscii(`${active?.title ?? '-'} | page ${page}/${pages} | ${busy} busy ${amber} waiting`) + const busyCount = model.tabs.filter((t) => t.busy).length + const waitingCount = model.tabs.filter((t) => t.attention).length + return toAscii(`${active?.title ?? '-'} | page ${page}/${pages} | ${busyCount} busy ${waitingCount} waiting`) } export type FrameInputs = { @@ -108,9 +99,7 @@ export function buildFrame({ model, caps, page, actionLayer, iconReady }: FrameI const tab = visible[slot] if (!tab) return keys[keyIndex] = { - kind: 'tab', tabId: tab.id, title: tab.title, - previewLines: [], // field dies in Task 9 - ring: ringColor(tab.status), active: tab.active, + kind: 'tab', tabId: tab.id, title: tab.title, active: tab.active, fill: tab.fill, dot: tab.dot, icons: tab.repoIcons.map((icon) => ({ ...icon, diff --git a/src/deck/terminal-text-registry.ts b/src/deck/terminal-text-registry.ts deleted file mode 100644 index 88a03c895..000000000 --- a/src/deck/terminal-text-registry.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { useEffect } from 'react' -import type { MutableRefObject } from 'react' - -export type TerminalTextReader = () => string[] -const readers = new Map() - -export function registerTerminalTextReader(terminalId: string, reader: TerminalTextReader): () => void { - readers.set(terminalId, reader) - return () => { - if (readers.get(terminalId) === reader) readers.delete(terminalId) - } -} -export function getTerminalTextSnapshot(terminalId: string): string[] | null { - const reader = readers.get(terminalId) - return reader ? reader() : null -} -export function resetTerminalTextRegistryForTests(): void { - readers.clear() -} - -export type XtermLike = { - buffer: { - active: { - length: number - viewportY: number - getLine(y: number): { translateToString(trimRight?: boolean): string } | undefined - } - } -} - -export function readXtermTail(term: XtermLike, maxLines: number): string[] { - const buf = term.buffer.active - const start = Math.max(0, buf.length - maxLines) - const out: string[] = [] - for (let y = start; y < buf.length; y++) { - out.push(buf.getLine(y)?.translateToString(true) ?? '') - } - return out -} - -export function useTerminalTextRegistration( - terminalId: string | undefined, - termRef: MutableRefObject, - maxLines = 12, -): void { - useEffect(() => { - if (!terminalId) return - return registerTerminalTextReader(terminalId, () => { - const term = termRef.current - return term ? readXtermTail(term, maxLines) : [] - }) - }, [terminalId, termRef, maxLines]) -} diff --git a/src/deck/tile-renderer.ts b/src/deck/tile-renderer.ts index 46e246394..70cec5cd1 100644 --- a/src/deck/tile-renderer.ts +++ b/src/deck/tile-renderer.ts @@ -1,5 +1,5 @@ import type { DeckCapabilities } from './deck-device' -import type { DeckAction, KeySpec, RingColor } from './frame' +import type { DeckAction, KeySpec } from './frame' // Canvas draw layer: converts a KeySpec into an RGBA pixel buffer via an // injectable 2D-context factory (jsdom returns null from getContext, so tests @@ -15,20 +15,9 @@ export type CtxFactory = (width: number, height: number) => Ctx2D export type KeyRenderer = (spec: KeySpec, caps: DeckCapabilities) => Uint8ClampedArray export type StripRenderer = (text: string, width: number, height: number) => Uint8ClampedArray -export const PREVIEW_BG = '#0a0a0a' -export const PREVIEW_TEXT_COLOR = '#a8a8a8' -export const PREVIEW_FONT_SIZE = 11 -export const PREVIEW_LINE_HEIGHT = 13 -export const PREVIEW_CHAR_WIDTH = 5.5 -export const PREVIEW_LEFT_MARGIN = 3 export const BANNER_HEIGHT = 20 export const BANNER_FILL = 'rgba(0,0,0,0.667)' export const TITLE_FONT_SIZE = 16 -export const RING_COLORS: Record, string> = { - amber: '#f59e0b', - green: '#22c55e', - blue: '#3b82f6', -} export const ACTIVE_COLOR = '#ffffff' export const TILE_BG = '#0a0a0a' /** Light green fill - the tab bar's emerald attention fill, tuned for the LCD (emerald-200). */ @@ -49,19 +38,6 @@ export const EMPTY_BG = '#000000' export const STRIP_FONT_SIZE = 22 export const MAX_TITLE_CHARS = 10 -export function previewGeometry(width: number, height: number): { lines: number; columns: number } { - return { - lines: Math.max(1, Math.floor((height - BANNER_HEIGHT - 2) / PREVIEW_LINE_HEIGHT) + 1), - columns: Math.max(1, Math.floor((width - PREVIEW_LEFT_MARGIN) / PREVIEW_CHAR_WIDTH)), - } -} - -export function cropPreviewLines(lines: string[], maxLines: number, maxColumns: number): string[] { - const out = [...lines] - while (out.length > 0 && out[out.length - 1].trim() === '') out.pop() - return out.slice(-maxLines).map((l) => l.slice(0, maxColumns)) -} - export function truncateTitle(title: string): string { return title.length > MAX_TITLE_CHARS ? `${title.slice(0, MAX_TITLE_CHARS - 1)}…` : title } diff --git a/test/e2e/stream-deck-flow.test.tsx b/test/e2e/stream-deck-flow.test.tsx index f7924f2df..74e819d69 100644 --- a/test/e2e/stream-deck-flow.test.tsx +++ b/test/e2e/stream-deck-flow.test.tsx @@ -25,7 +25,6 @@ import { FakeDeckDevice, PLUS_CAPS } from '@/deck/fake-deck-device' import type { DeckCapabilities } from '@/deck/deck-device' import { DeckController } from '@/deck/deck-controller' import type { KeySpec } from '@/deck/frame' -import { registerTerminalTextReader, resetTerminalTextRegistryForTests } from '@/deck/terminal-text-registry' const reducer = { tabs: tabsReducer, panes: panesReducer, turnCompletion: turnCompletionReducer, @@ -149,13 +148,11 @@ beforeEach(() => { afterEach(() => { activeController?.stop() activeController = null - resetTerminalTextRegistryForTests() vi.useRealTimers() }) describe('Stream Deck e2e flows (fake transport, real store)', () => { - it('tabs appear on keys with titles, previews, and rings', () => { - registerTerminalTextReader('term-1', () => ['$ npm test', 'PASS']) + it('tabs appear on keys with titles, fills, dots, and icons', () => { const { device } = setup({ tabs: 3, busy: ['term-1'], @@ -166,17 +163,15 @@ describe('Stream Deck e2e flows (fake transport, real store)', () => { // Status-priority sort: t2 attention (greenFill) < t3 waiting fresh-agent // (greenIcon) < t1 busy (blueIcon), so busy t1 lands after the others. expect(decodeKey(device, 0)).toEqual({ - kind: 'tab', tabId: 't2', title: 'tab2', previewLines: [], ring: 'green', active: false, + kind: 'tab', tabId: 't2', title: 'tab2', active: false, fill: 'green', dot: 'green', icons: [], }) expect(decodeKey(device, 1)).toEqual({ - kind: 'tab', tabId: 't3', title: 'tab3', previewLines: [], ring: 'amber', active: false, + kind: 'tab', tabId: 't3', title: 'tab3', active: false, fill: 'none', dot: 'green', icons: [], }) expect(decodeKey(device, 2)).toEqual({ - // previewLines is always [] since Task 8 (field dies in Task 9); the - // registered term-1 reader above is deliberately ignored. - kind: 'tab', tabId: 't1', title: 'tab1', previewLines: [], ring: 'blue', active: true, + kind: 'tab', tabId: 't1', title: 'tab1', active: true, fill: 'none', dot: 'blue', icons: [], }) }) @@ -190,23 +185,23 @@ describe('Stream Deck e2e flows (fake transport, real store)', () => { const state = store.getState() expect(state.tabs.activeTabId).toBe('t2') expect(state.turnCompletion.attentionByTab.t2).toBeFalsy() - expect(decodeKey(device, 1)).toMatchObject({ kind: 'tab', tabId: 't2', active: true, ring: null }) + expect(decodeKey(device, 1)).toMatchObject({ kind: 'tab', tabId: 't2', active: true, fill: 'none' }) }) - it('ring colors track state changes', () => { + it('tile fill and dot track state changes', () => { const { store, device } = setup({ tabs: 3, freshAgentTab: 3 }) - expect(decodeKey(device, 0)).toMatchObject({ kind: 'tab', tabId: 't1', ring: null }) + expect(decodeKey(device, 0)).toMatchObject({ kind: 'tab', tabId: 't1', fill: 'none', dot: 'green' }) store.dispatch(upsertClaudeActivity({ terminals: [{ terminalId: 'term-1', phase: 'busy', updatedAt: 1 }] })) // busy t1 (blueIcon) sorts after the green-icon tabs -> key 2 - expect(decodeKey(device, 2)).toMatchObject({ kind: 'tab', tabId: 't1', ring: 'blue' }) + expect(decodeKey(device, 2)).toMatchObject({ kind: 'tab', tabId: 't1', dot: 'blue' }) store.dispatch(markTabAttention({ tabId: 't1' })) - // green outranks blue even while the tab is still busy; active+attention - // (barTop) sorts t1 back to key 0 - expect(decodeKey(device, 0)).toMatchObject({ kind: 'tab', tabId: 't1', ring: 'green' }) + // attention outranks busy; active+attention (barTop) sorts t1 back to key 0 + expect(decodeKey(device, 0)).toMatchObject({ kind: 'tab', tabId: 't1', fill: 'barTop' }) store.dispatch(addPermissionRequest({ sessionId: 's1', sessionType: 'freshclaude', provider: 'claude', requestId: 'r9', })) - expect(decodeKey(device, 2)).toMatchObject({ kind: 'tab', tabId: 't3', ring: 'amber' }) + // a pending approval suppresses busy on the fresh-agent tab: still a green-dot tile + expect(decodeKey(device, 2)).toMatchObject({ kind: 'tab', tabId: 't3', fill: 'none', dot: 'green' }) }) it('overflow paging with wrap on the 6-key profile', () => { @@ -275,12 +270,14 @@ describe('Stream Deck e2e flows (fake transport, real store)', () => { it('Deck+ dials and strip: cycle, page clamp, strip text, touch wake', () => { const { store, device } = setup( - { tabs: 10, busy: ['term-1'], freshAgentTab: 2, pendingPermissions: { r1: { requestId: 'r1' } } }, + { tabs: 10, busy: ['term-1'], attention: { t2: true }, freshAgentTab: 2, pendingPermissions: { r1: { requestId: 'r1' } } }, PLUS_CAPS, () => ({ brightness: 100, idleBrightness: 10, idleTimeoutSeconds: 1 }), ) // no pager key on the dial profile: all 8 keys are tab tiles. - // Sorted order: busy t1 (blueIcon) lands last, so page 1 shows t2..t9. + // Sorted order: t2 attention (greenFill) stays first, busy t1 (blueIcon) + // lands last, so page 1 shows t2..t9. Strip counts busy=1 (t1) and + // waiting=1 (t2 attention). for (let k = 0; k < PLUS_CAPS.keyCount; k++) { expect(decodeKey(device, k)).toMatchObject({ kind: 'tab', tabId: `t${k + 2}` }) } diff --git a/test/unit/client/deck/deck-controller.test.ts b/test/unit/client/deck/deck-controller.test.ts index e1764d7b9..6cf152976 100644 --- a/test/unit/client/deck/deck-controller.test.ts +++ b/test/unit/client/deck/deck-controller.test.ts @@ -21,7 +21,6 @@ import { FakeDeckDevice, PLUS_CAPS } from '@/deck/fake-deck-device' import type { DeckCapabilities } from '@/deck/deck-device' import { DeckController, type DeckControllerOptions } from '@/deck/deck-controller' import { IconImageCache } from '@/deck/icon-image-cache' -import { registerTerminalTextReader } from '@/deck/terminal-text-registry' import type { KeySpec } from '@/deck/frame' const reducer = { @@ -183,7 +182,7 @@ describe('DeckController', () => { it('paints tab tiles in tab order with active ring and asserts brightness on start', () => { const { device } = setup() expect(device.brightnessHistory[0]).toBe(100) - expect(decodeKey(device, 0)).toMatchObject({ kind: 'tab', tabId: 't1', title: 'tab1', active: true, ring: null }) + expect(decodeKey(device, 0)).toMatchObject({ kind: 'tab', tabId: 't1', title: 'tab1', active: true }) expect(decodeKey(device, 1)).toMatchObject({ kind: 'tab', tabId: 't2', title: 'tab2', active: false }) expect(decodeKey(device, 2)).toEqual({ kind: 'empty' }) expect(decodeKey(device, 5)).toEqual({ kind: 'empty' }) @@ -192,19 +191,19 @@ describe('DeckController', () => { it('short press focuses the tab in the browser and dismisses green', () => { const { store, device } = setup({ attention: { t2: true } }) // t2 has attention (priority 1) so it sorts ahead of green-icon t1 -> key 0 - expect(decodeKey(device, 0)).toMatchObject({ kind: 'tab', tabId: 't2', ring: 'green', active: false }) + expect(decodeKey(device, 0)).toMatchObject({ kind: 'tab', tabId: 't2', fill: 'green', active: false }) shortPress(device, 0) const state = store.getState() expect(state.tabs.activeTabId).toBe('t2') expect(state.turnCompletion.attentionByTab.t2).toBeFalsy() - expect(decodeKey(device, 1)).toMatchObject({ kind: 'tab', tabId: 't2', active: true, ring: null }) + expect(decodeKey(device, 1)).toMatchObject({ kind: 'tab', tabId: 't2', active: true, fill: 'none' }) }) it('store changes repaint only changed keys', () => { const { store, device } = setup() device.keyImages.clear() store.dispatch(markTabAttention({ tabId: 't1' })) - expect(decodeKey(device, 0)).toMatchObject({ kind: 'tab', tabId: 't1', ring: 'green' }) + expect(decodeKey(device, 0)).toMatchObject({ kind: 'tab', tabId: 't1', fill: 'barTop' }) expect(device.keyImages.has(1)).toBe(false) expect(device.keyImages.has(2)).toBe(false) }) @@ -340,23 +339,14 @@ describe('DeckController', () => { expect(after.kind === 'tab' && after.icons[0].ready).toBe(true) }) - it('no periodic preview repaint: 3s of ticks paints nothing even when terminal text changes', () => { - // A reader with a CHANGING snapshot is what makes this test able to go RED: with - // no reader registered, previewFor already yields [] and the per-key spec-JSON - // diff suppresses every paint, so the assertion would pass against unmodified - // code (vacuous). With the reader, current code's PREVIEW_REFRESH_TICKS branch - // repaints key 0 at the ~3s tick (new previewLines -> spec differs) and the test - // fails; it goes green only when previewFor and the tick branch are deleted. - // (Task 9 deletes the registry module itself; when it does, rework this test to - // drop the reader registration - the no-repaint guarantee becomes structural via - // Task 9's grep gate on PREVIEW_REFRESH_TICKS/registerTerminalTextReader.) - let n = 0 - const unregister = registerTerminalTextReader('term-1', () => [`line ${n++}`]) + it('no periodic repaint: 3s of ticks paints nothing while the store is unchanged', () => { + // The terminal-preview machinery is deleted (Task 9); repaints are store-driven + // only. The structural guarantee is Task 9's dead-reference grep gate; this + // asserts the observable behavior. const { device } = setup({ tabCount: 1 }) device.keyImages.clear() vi.advanceTimersByTime(3_000) expect(device.keyImages.size).toBe(0) - unregister() }) it('dispatches fetchRepoIconMeta for tab cwds even when settings.panes.repoIconsOnTabs is false (deck owns the probe)', () => { diff --git a/test/unit/client/deck/deck-selectors.test.ts b/test/unit/client/deck/deck-selectors.test.ts index 0b398b3bc..3e6405b23 100644 --- a/test/unit/client/deck/deck-selectors.test.ts +++ b/test/unit/client/deck/deck-selectors.test.ts @@ -20,7 +20,7 @@ import type { RepoIconEntry } from '@/store/repoIconsSlice' import { buildRepoIconUrl } from '@/lib/repo-icon' import { hueFromString } from '@/components/icons/RepoIcon' import { - findApproveTarget, findStopTarget, getTabRepoIcons, getTabRingStatus, getTabStatusFlags, selectDeckModel, + findApproveTarget, findStopTarget, getTabRepoIcons, getTabStatusFlags, selectDeckModel, } from '@/deck/deck-selectors' const reducer = { @@ -116,30 +116,27 @@ function makeState(overrides: { } describe('deck-selectors', () => { - it('quiet tabs have no ring', () => { + it('quiet tabs are neither busy nor attention', () => { const state = makeState() const model = selectDeckModel(state) expect(model.tabs).toHaveLength(2) - expect(model.tabs[0]).toMatchObject({ id: 't1', title: 'build', active: true, status: { busy: false, green: false, amber: false } }) + expect(model.tabs[0]).toMatchObject({ id: 't1', title: 'build', active: true, busy: false, attention: false }) }) - it('busy terminal pane -> busy tab (blue)', () => { + it('busy terminal pane -> busy tab', () => { const state = makeState({ claudeBusy: true }) - const tab = (state as { tabs: { tabs: unknown[] } }).tabs.tabs[0] - expect(getTabRingStatus(state, tab as never).busy).toBe(true) - expect(selectDeckModel(state).tabs.find((t) => t.id === 't1')!.status.busy).toBe(true) + expect(selectDeckModel(state).tabs.find((t) => t.id === 't1')!.busy).toBe(true) }) - it('attentionByTab -> green', () => { + it('attentionByTab -> attention flag', () => { const state = makeState({ attention: { t1: true } }) - expect(selectDeckModel(state).tabs.find((t) => t.id === 't1')!.status.green).toBe(true) + expect(selectDeckModel(state).tabs.find((t) => t.id === 't1')!.attention).toBe(true) }) - it('pending permission -> amber on the fresh-agent tab, and busy is suppressed', () => { + it('pending permission suppresses busy on the fresh-agent tab', () => { const state = makeState({ pendingPermissions: { r1: { requestId: 'r1' } }, freshAgentRunning: true }) - const t2 = selectDeckModel(state).tabs[1] - expect(t2.status.amber).toBe(true) - expect(t2.status.busy).toBe(false) // isFreshAgentBusy yields false while waiting + const t2 = selectDeckModel(state).tabs.find((t) => t.id === 't2')! + expect(t2.busy).toBe(false) // isFreshAgentBusy yields false while waiting }) it('findApproveTarget returns the pending permission for the tab', () => { diff --git a/test/unit/client/deck/frame.test.ts b/test/unit/client/deck/frame.test.ts index 839f30877..535ba4b83 100644 --- a/test/unit/client/deck/frame.test.ts +++ b/test/unit/client/deck/frame.test.ts @@ -1,15 +1,14 @@ import { describe, expect, it } from 'vitest' import { MINI_CAPS, PLUS_CAPS } from '@/deck/fake-deck-device' import { - ACTION_KEYS, buildFrame, clampPage, pageCount, planLayout, ringColor, stripText, visibleTabs, + ACTION_KEYS, buildFrame, clampPage, pageCount, planLayout, stripText, visibleTabs, } from '@/deck/frame' import type { DeckModel, DeckTab } from '@/deck/deck-selectors' -const quiet = { busy: false, green: false, amber: false } function makeDeckTab(over: Partial & Pick): DeckTab { return { active: false, busy: false, attention: false, fill: 'none', dot: null, - priority: 4, repoIcons: [], status: { ...quiet }, ...over, + priority: 4, repoIcons: [], ...over, } } function model(n: number, activeId = 'tab-0'): DeckModel { @@ -52,15 +51,6 @@ describe('page math', () => { }) }) -describe('ringColor priority', () => { - it('amber > green > blue > none', () => { - expect(ringColor({ busy: true, green: true, amber: true })).toBe('amber') - expect(ringColor({ busy: true, green: true, amber: false })).toBe('green') - expect(ringColor({ busy: true, green: false, amber: false })).toBe('blue') - expect(ringColor(quiet)).toBeNull() - }) -}) - describe('buildFrame', () => { it('tabs fit: all tab tiles, active flag set, rest empty', () => { const frame = buildFrame({ model: model(3), caps: MINI_CAPS, page: 1, actionLayer: null, iconReady: noIcon }) @@ -114,8 +104,8 @@ describe('buildFrame', () => { }) it('full mode fills the strip and never emits a pager', () => { const m = model(10) - m.tabs[1].status.busy = true - m.tabs[2].status.amber = true + m.tabs[1].busy = true + m.tabs[2].attention = true const frame = buildFrame({ model: m, caps: PLUS_CAPS, page: 1, actionLayer: null, iconReady: noIcon }) expect(frame.keys.every((k) => k.kind !== 'pager')).toBe(true) expect(frame.strip).toEqual({ text: 'Tab 0 | page 1/2 | 1 busy 1 waiting' }) @@ -125,7 +115,18 @@ describe('buildFrame', () => { describe('stripText', () => { it('uses - for no active tab and forces ASCII', () => { expect(stripText({ tabs: [] }, 1, 1)).toBe('- | page 1/1 | 0 busy 0 waiting') - expect(stripText({ tabs: [{ title: 'café', active: true, status: { busy: false, amber: false } }] }, 1, 1)) + expect(stripText({ tabs: [{ title: 'café', active: true, busy: false, attention: false }] }, 1, 1)) .toBe('caf? | page 1/1 | 0 busy 0 waiting') }) + it('stripText counts busy and waiting from tab flags', () => { + const model = { + activeTabId: 't1', + tabs: [ + makeDeckTab({ id: 't1', title: 'alpha', active: true, busy: true }), + makeDeckTab({ id: 't2', title: 'beta', attention: true }), + makeDeckTab({ id: 't3', title: 'gamma' }), + ], + } + expect(stripText(model, 1, 1)).toContain('1 busy 1 waiting') + }) }) diff --git a/test/unit/client/deck/terminal-text-registry.test.tsx b/test/unit/client/deck/terminal-text-registry.test.tsx deleted file mode 100644 index 92f2b7c6c..000000000 --- a/test/unit/client/deck/terminal-text-registry.test.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest' -import { render } from '@testing-library/react' -import { createRef } from 'react' -import { - getTerminalTextSnapshot, readXtermTail, registerTerminalTextReader, - resetTerminalTextRegistryForTests, useTerminalTextRegistration, -} from '@/deck/terminal-text-registry' -import type { XtermLike } from '@/deck/terminal-text-registry' - -afterEach(() => resetTerminalTextRegistryForTests()) - -function fakeXterm(lines: string[]): XtermLike { - return { - buffer: { - active: { - length: lines.length, - viewportY: 0, - getLine: (y: number) => (lines[y] === undefined ? undefined : { translateToString: () => lines[y] }), - }, - }, - } -} - -describe('registry', () => { - it('registers, reads, and unregisters readers', () => { - const off = registerTerminalTextReader('term-1', () => ['hello']) - expect(getTerminalTextSnapshot('term-1')).toEqual(['hello']) - expect(getTerminalTextSnapshot('nope')).toBeNull() - off() - expect(getTerminalTextSnapshot('term-1')).toBeNull() - }) -}) - -describe('readXtermTail', () => { - it('returns the last N buffer lines in order', () => { - const term = fakeXterm(['a', 'b', 'c', 'd', 'e']) - expect(readXtermTail(term, 3)).toEqual(['c', 'd', 'e']) - expect(readXtermTail(term, 10)).toEqual(['a', 'b', 'c', 'd', 'e']) - }) -}) - -describe('useTerminalTextRegistration', () => { - function Probe({ terminalId, term }: { terminalId?: string; term: XtermLike | null }) { - const ref = createRef() as { current: XtermLike | null } - ref.current = term - useTerminalTextRegistration(terminalId, ref, 3) - return null - } - it('registers while mounted and cleans up on unmount', () => { - const { unmount } = render() - expect(getTerminalTextSnapshot('term-9')).toEqual(['x', 'y']) - unmount() - expect(getTerminalTextSnapshot('term-9')).toBeNull() - }) - it('no-ops without a terminalId and tolerates a null term', () => { - render() - expect(getTerminalTextSnapshot('undefined')).toBeNull() - const { rerender } = render() - expect(getTerminalTextSnapshot('term-8')).toEqual([]) - rerender() - expect(getTerminalTextSnapshot('term-8')).toEqual(['z']) - }) -}) diff --git a/test/unit/client/deck/tile-renderer.test.ts b/test/unit/client/deck/tile-renderer.test.ts index 68b6c4b70..3e16e2193 100644 --- a/test/unit/client/deck/tile-renderer.test.ts +++ b/test/unit/client/deck/tile-renderer.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'vitest' import { MINI_CAPS } from '@/deck/fake-deck-device' import { - cropPreviewLines, drawRing, fitLabel, iconLayout, previewGeometry, renderKey, truncateTitle, - RING_COLORS, ACTIVE_COLOR, DISABLED_ACTION_COLOR, + drawRing, fitLabel, iconLayout, renderKey, truncateTitle, + APPROVE_COLOR, ACTIVE_COLOR, DISABLED_ACTION_COLOR, TILE_BG, TILE_FILL_GREEN, BAR_TOP_BORDER, DOT_GREEN, DOT_BLUE, DOT_SIZE, } from '@/deck/tile-renderer' import type { Ctx2D, IconSource } from '@/deck/tile-renderer' @@ -35,21 +35,6 @@ function recordingCtx(width: number, height: number) { return { ctx, rects, texts, images } } -describe('previewGeometry', () => { - it('matches the hardware-anchored values', () => { - expect(previewGeometry(120, 120)).toEqual({ lines: 8, columns: 21 }) - expect(previewGeometry(80, 80)).toEqual({ lines: 5, columns: 14 }) - expect(previewGeometry(72, 72)).toEqual({ lines: 4, columns: 12 }) - }) -}) - -describe('cropPreviewLines', () => { - it('drops trailing blanks, keeps last N lines and first M columns', () => { - const lines = ['one', 'two-is-longer-than-five', 'three', '', ' '] - expect(cropPreviewLines(lines, 2, 5)).toEqual(['two-i', 'three']) - }) -}) - describe('title fitting', () => { it('truncateTitle caps at 10 chars with ellipsis', () => { expect(truncateTitle('short')).toBe('short') @@ -76,7 +61,7 @@ describe('drawRing', () => { }) const tabSpec = (over: Partial> = {}): KeySpec => ({ - kind: 'tab', tabId: 't1', title: 'build', previewLines: [], ring: null, + kind: 'tab', tabId: 't1', title: 'build', active: false, fill: 'none', dot: null, icons: [], ...over, }) @@ -99,7 +84,7 @@ describe('renderKey', () => { expect(rects.some((r) => r.y === 0 && r.h === 20 && r.style.startsWith('rgba'))).toBe(true) // banner expect(texts.some((t) => t.text === 'build' && t.style === '#ffffff')).toBe(true) // title expect(rects.filter((r) => r.style === ACTIVE_COLOR)).toHaveLength(0) - expect(texts.filter((t) => t.style === '#a8a8a8')).toHaveLength(0) // preview text gone from drawTab (literal: the constant dies in Task 9) + expect(texts.filter((t) => t.style === '#a8a8a8')).toHaveLength(0) // no preview text anywhere on the tile }) it('green fill state paints the light-green background', () => { @@ -175,6 +160,6 @@ describe('renderKey', () => { return cap!.rects } expect(rectsFor(false).some((r) => r.style === DISABLED_ACTION_COLOR)).toBe(true) - expect(rectsFor(true).some((r) => r.style === RING_COLORS.green)).toBe(true) + expect(rectsFor(true).some((r) => r.style === APPROVE_COLOR)).toBe(true) }) }) From 97a7c732d2ff4ad807529e3bbde7a72797ea6c28 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 05:23:16 -0700 Subject: [PATCH 15/30] feat(deck): snapshot key target at press-down so re-sorts cannot retarget a press MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 4 made selectDeckModel sorted by status priority, so a mid-press re-sort (e.g. a tab gaining attention) could move tabs between keys and the release would act on whatever the slot showed at keyUp, not what the user saw when they pressed. keyDown now snapshots the key's displayed target (pager / tab / none); keyUp acts on that snapshot. A tab closed mid-press is a no-op; the action-layer keyUp branch is unchanged (action keys are fixed indices, unaffected by sorting). 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- src/deck/deck-controller.ts | 46 ++++++++++++------- test/unit/client/deck/deck-controller.test.ts | 44 +++++++++++++++++- 2 files changed, 73 insertions(+), 17 deletions(-) diff --git a/src/deck/deck-controller.ts b/src/deck/deck-controller.ts index 16d2a4b23..84579845f 100644 --- a/src/deck/deck-controller.ts +++ b/src/deck/deck-controller.ts @@ -35,6 +35,9 @@ export type DeckControllerOptions = { iconCache?: IconImageCache } +/** What a key displayed at press-down - snapshotted so re-sorts can't retarget a press. */ +type PressTarget = { kind: 'pager' } | { kind: 'tab'; tabId: string } | { kind: 'none' } + export const LONG_PRESS_MS = 500 export const ACTION_LAYER_TIMEOUT_MS = 10_000 export const STOP_ESCALATE_MS = 5_000 @@ -51,7 +54,7 @@ export class DeckController { private page = 1 private actionLayer: { tabId: string; openedAt: number } | null = null - private pressedAt = new Map() + private pressedAt = new Map() private lastStopAt = new Map() // per paneId private lastActivityAt = 0 private dimmed = false @@ -208,7 +211,7 @@ export class DeckController { this.dutyChecks() switch (event.type) { case 'keyDown': - this.pressedAt.set(event.keyIndex, this.now()) + this.pressedAt.set(event.keyIndex, { at: this.now(), target: this.resolveKeyTarget(event.keyIndex) }) this.noteActivity() break case 'keyUp': @@ -226,34 +229,45 @@ export class DeckController { } } + /** What this key DISPLAYS right now - captured at press-down so re-sorts can't retarget a press. */ + private resolveKeyTarget(keyIndex: number): PressTarget { + const model = selectDeckModel(this.store.getState()) + const plan = planLayout(this.device.capabilities, model.tabs.length) + if (plan.pagerKey !== null && keyIndex === plan.pagerKey) return { kind: 'pager' } + const slot = plan.tabSlots.indexOf(keyIndex) + if (slot === -1) return { kind: 'none' } + const pages = pageCount(model.tabs.length, plan.tabsPerPage) + const tab = visibleTabs(model.tabs, clampPage(this.page, pages), plan.tabsPerPage)[slot] + return tab ? { kind: 'tab', tabId: tab.id } : { kind: 'none' } + } + private handleKeyUp(keyIndex: number): void { - const downAt = this.pressedAt.get(keyIndex) + const press = this.pressedAt.get(keyIndex) this.pressedAt.delete(keyIndex) this.noteActivity() - if (downAt === undefined) return // unmatched release + if (press === undefined) return // unmatched release if (this.actionLayer) { this.handleActionKey(keyIndex) return } - const duration = this.now() - downAt - const state = this.store.getState() - const model = selectDeckModel(state) - const plan = planLayout(this.device.capabilities, model.tabs.length) - const pages = pageCount(model.tabs.length, plan.tabsPerPage) - if (plan.pagerKey !== null && keyIndex === plan.pagerKey) { + const duration = this.now() - press.at + if (press.target.kind === 'pager') { + const model = selectDeckModel(this.store.getState()) + const plan = planLayout(this.device.capabilities, model.tabs.length) + const pages = pageCount(model.tabs.length, plan.tabsPerPage) this.page = this.page >= pages ? 1 : this.page + 1 this.repaint() return } - const slot = plan.tabSlots.indexOf(keyIndex) - if (slot === -1) return - const tab = visibleTabs(model.tabs, clampPage(this.page, pages), plan.tabsPerPage)[slot] - if (!tab) return // empty slot + if (press.target.kind !== 'tab') return + const tabId = press.target.tabId + const model = selectDeckModel(this.store.getState()) + if (!model.tabs.some((tab) => tab.id === tabId)) return // tab closed mid-press if (duration >= LONG_PRESS_MS) { - this.actionLayer = { tabId: tab.id, openedAt: this.now() } + this.actionLayer = { tabId, openedAt: this.now() } this.repaint() } else { - focusTabFromDeck(this.store, tab.id) + focusTabFromDeck(this.store, tabId) this.repaint() // optimistic immediacy; store subscription repaints too } } diff --git a/test/unit/client/deck/deck-controller.test.ts b/test/unit/client/deck/deck-controller.test.ts index 6cf152976..83c1873d1 100644 --- a/test/unit/client/deck/deck-controller.test.ts +++ b/test/unit/client/deck/deck-controller.test.ts @@ -4,7 +4,7 @@ const sendMock = vi.fn() vi.mock('@/lib/ws-client', () => ({ getWsClient: () => ({ send: sendMock }) })) import { configureStore } from '@reduxjs/toolkit' -import tabsReducer from '@/store/tabsSlice' +import tabsReducer, { closeTab } from '@/store/tabsSlice' import panesReducer from '@/store/panesSlice' import turnCompletionReducer, { markTabAttention } from '@/store/turnCompletionSlice' import freshAgentReducer from '@/store/freshAgentSlice' @@ -199,6 +199,48 @@ describe('DeckController', () => { expect(decodeKey(device, 1)).toMatchObject({ kind: 'tab', tabId: 't2', active: true, fill: 'none' }) }) + it('acts on the tab displayed at press-down even if the sort changes mid-press', () => { + // t1 greenIcon (key 0), t2 greenIcon (key 1); active tab defaults to t1. + const { store, device } = setup({ tabCount: 2 }) + device.emit({ type: 'keyDown', keyIndex: 1 }) // user is pressing "t2" + // Mid-press: t2 gains attention -> re-sort moves t2 to key 0; key 1 now shows t1. + store.dispatch(markTabAttention({ tabId: 't2' })) + // Sanity: the RED gate is armed - attention actually set, re-sort actually happened. + expect(store.getState().turnCompletion.attentionByTab.t2).toBe(true) + expect(decodeKey(device, 0)).toMatchObject({ kind: 'tab', tabId: 't2' }) + vi.advanceTimersByTime(100) + device.emit({ type: 'keyUp', keyIndex: 1 }) + // Snapshot guard: the press focuses t2 (what the user saw), not t1 (what the slot shows now) + expect(store.getState().tabs.activeTabId).toBe('t2') + }) + + it('press on a tab that was closed mid-press is a no-op', () => { + const { store, device } = setup({ tabCount: 2 }) + device.emit({ type: 'keyDown', keyIndex: 1 }) + store.dispatch(closeTab('t2')) + expect(store.getState().tabs.tabs.map((t) => t.id)).toEqual(['t1']) // t2 really gone mid-press + vi.advanceTimersByTime(100) + device.emit({ type: 'keyUp', keyIndex: 1 }) + expect(store.getState().tabs.activeTabId).toBe('t1') + }) + + it('long-press opens the action layer for the press-down tab despite a mid-press re-sort', () => { + // t2 is a fresh-agent pane with pending permission r1 -> APPROVE is enabled only + // if the action layer targets t2; t1 is a plain terminal (approve target null). + const { store, device } = setup({ tabCount: 2, freshAgentTab: true, pendingPermissions: { r1: { requestId: 'r1' } } }) + expect(decodeKey(device, 1)).toMatchObject({ kind: 'tab', tabId: 't2' }) // pre-press: t2 on key 1 + device.emit({ type: 'keyDown', keyIndex: 1 }) + store.dispatch(markTabAttention({ tabId: 't2' })) + // Sanity: the RED gate is armed - attention set, mid-press re-sort moved t2 to key 0. + expect(store.getState().turnCompletion.attentionByTab.t2).toBe(true) + expect(decodeKey(device, 0)).toMatchObject({ kind: 'tab', tabId: 't2' }) + vi.advanceTimersByTime(600) + device.emit({ type: 'keyUp', keyIndex: 1 }) + // Action layer opened, targeting the press-down tab t2 (approve enabled via r1) + expect(decodeKey(device, 0)).toMatchObject({ kind: 'action', action: 'back' }) + expect(decodeKey(device, 1)).toMatchObject({ kind: 'action', action: 'approve', enabled: true }) + }) + it('store changes repaint only changed keys', () => { const { store, device } = setup() device.keyImages.clear() From 14ba0f958d0cfbfb1554c9710dc67d83d6a5ff30 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 05:31:57 -0700 Subject: [PATCH 16/30] test(deck): e2e coverage for sorted keys, background states, repo icons, press snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 11: seven new e2e scenarios through the real store + real DeckController + FakeDeckDevice covering status-priority sorting (stable within groups), the three background treatments + active ring, blue/green dots, repo-icon unready->ready repaint via an injected IconImageCache with a deferred loader, pager paging over the sorted order, the press-down target snapshot under a mid-press re-sort, and short/long-press on the sorted layout. Harness: setup() gains a 4th extra-controller-options param spread last into the constructor; makeDeckStore gains activeTab, paneStatus, terminalMeta, and repoIcons seeding; deferredLoader ported from icon-image-cache.test.ts. All 9 pre-existing scenarios pass unmodified (already sorted-order-aware). 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- test/e2e/stream-deck-flow.test.tsx | 115 +++++++++++++++++++++++++++-- 1 file changed, 110 insertions(+), 5 deletions(-) diff --git a/test/e2e/stream-deck-flow.test.tsx b/test/e2e/stream-deck-flow.test.tsx index 74e819d69..6649fd1c1 100644 --- a/test/e2e/stream-deck-flow.test.tsx +++ b/test/e2e/stream-deck-flow.test.tsx @@ -19,11 +19,13 @@ import opencodeActivityReducer from '@/store/opencodeActivitySlice' import paneRuntimeActivityReducer from '@/store/paneRuntimeActivitySlice' import settingsReducer from '@/store/settingsSlice' import terminalMetaReducer from '@/store/terminalMetaSlice' -import repoIconsReducer from '@/store/repoIconsSlice' +import repoIconsReducer, { type RepoIconEntry } from '@/store/repoIconsSlice' +import type { Tab } from '@/store/types' import { makeFreshAgentSessionKey } from '@shared/fresh-agent' import { FakeDeckDevice, PLUS_CAPS } from '@/deck/fake-deck-device' import type { DeckCapabilities } from '@/deck/deck-device' -import { DeckController } from '@/deck/deck-controller' +import { DeckController, type DeckControllerOptions } from '@/deck/deck-controller' +import { IconImageCache } from '@/deck/icon-image-cache' import type { KeySpec } from '@/deck/frame' const reducer = { @@ -38,11 +40,17 @@ const s1Key = makeFreshAgentSessionKey({ sessionType: 'freshclaude', provider: ' type DeckStoreOpts = { tabs?: number // tab count (t1..tN), default 2 + activeTab?: string // tabs.activeTabId seed, default 't1' busy?: string[] // terminalIds marked busy via claudeActivity attention?: Record // attentionByTab seed freshAgentTab?: number // 1-based tab index hosting the fresh-agent pane (session s1) pendingPermissions?: Record freshAgentRunning?: boolean + paneStatus?: Record // per-pane content status override (p1..pN) + /** Seed state.terminalMeta.byTerminalId (terminalId/updatedAt filled in). */ + terminalMeta?: Record + /** Seed state.repoIcons.byCwd. */ + repoIcons?: Record } // Local extraction of the deck-controller unit-suite fixture builder: tabs @@ -61,7 +69,7 @@ function makeDeckStore(opts: DeckStoreOpts = {}) { type: 'leaf', id: `p${i}`, content: isAgent ? { kind: 'fresh-agent', sessionType: 'freshclaude', provider: 'claude', sessionId: 's1', createRequestId: `c${i}`, status: 'running' } - : { kind: 'terminal', terminalId: `term-${i}`, createRequestId: `c${i}`, status: 'running', mode: 'claude' }, + : { kind: 'terminal', terminalId: `term-${i}`, createRequestId: `c${i}`, status: opts.paneStatus?.[`p${i}`] ?? 'running', mode: 'claude' }, } activePane[`t${i}`] = `p${i}` } @@ -71,7 +79,17 @@ function makeDeckStore(opts: DeckStoreOpts = {}) { return configureStore({ reducer, preloadedState: { - tabs: { tabs, activeTabId: 't1', renameRequestTabId: null, tombstones: [] }, + ...(opts.terminalMeta + ? { + terminalMeta: { + byTerminalId: Object.fromEntries(Object.entries(opts.terminalMeta).map( + ([terminalId, meta]) => [terminalId, { terminalId, updatedAt: 0, ...meta }], + )), + }, + } + : {}), + ...(opts.repoIcons ? { repoIcons: { byCwd: opts.repoIcons } } : {}), + tabs: { tabs, activeTabId: opts.activeTab ?? 't1', renameRequestTabId: null, tombstones: [] }, panes: { layouts, activePane, paneTitles: {}, paneTitleSetByUser: {}, renameRequestTabId: null, renameRequestPaneId: null, @@ -119,7 +137,12 @@ const defaultSettings = (): DeckSettings => ({ brightness: 100, idleBrightness: let activeController: DeckController | null = null -function setup(opts: DeckStoreOpts = {}, caps?: DeckCapabilities, settings: () => DeckSettings = defaultSettings) { +function setup( + opts: DeckStoreOpts = {}, + caps?: DeckCapabilities, + settings: () => DeckSettings = defaultSettings, + extra?: Partial, +) { const store = makeDeckStore(opts) const device = new FakeDeckDevice(caps) const controller = new DeckController({ @@ -128,12 +151,22 @@ function setup(opts: DeckStoreOpts = {}, caps?: DeckCapabilities, settings: () = renderKey: (spec) => encodeSpec(spec), renderStrip: (text) => new TextEncoder().encode(text) as unknown as Uint8ClampedArray, settings, + ...extra, }) controller.start() activeController = controller return { store, device, controller } } +// Deferred icon loader, ported from icon-image-cache.test.ts: resolve/reject each +// url by hand. jsdom never loads images, so post-load assertions REQUIRE this. +function deferredLoader() { + const pending = new Map void; reject: (e: Error) => void }>() + const loader = (url: string) => + new Promise((resolve, reject) => pending.set(url, { resolve, reject })) + return { loader, pending } +} + function holdKey(device: FakeDeckDevice, keyIndex: number, ms: number) { device.emit({ type: 'keyDown', keyIndex }) vi.advanceTimersByTime(ms) @@ -320,4 +353,76 @@ describe('Stream Deck e2e flows (fake transport, real store)', () => { expect(device.stripImage).toBeNull() expect(device.brightnessHistory.length).toBe(brightnessCalls) }) + + it('keys are sorted by status priority and stable within groups', () => { + // 5 tabs: t1 exited(rest), t2 busy(blue), t3 idle-running(green icon), + // t4 attention(green fill), t5 active+attention(barTop) + const { device } = setup({ + tabs: 5, activeTab: 't5', + paneStatus: { p1: 'exited' }, busy: ['term-2'], attention: { t4: true, t5: true }, + }) + const ids = [0, 1, 2, 3, 4].map((k) => { + const spec = decodeKey(device, k) + return spec?.kind === 'tab' ? spec.tabId : null + }) + expect(ids).toEqual(['t5', 't4', 't3', 't2', 't1']) + }) + + it('tiles carry the three background treatments and the active ring flag', () => { + const { device } = setup({ tabs: 3, activeTab: 't1', attention: { t1: true, t2: true } }) + expect(decodeKey(device, 0)).toMatchObject({ tabId: 't1', fill: 'barTop', active: true }) + expect(decodeKey(device, 1)).toMatchObject({ tabId: 't2', fill: 'green', active: false }) + expect(decodeKey(device, 2)).toMatchObject({ tabId: 't3', fill: 'none', active: false }) + }) + + it('busy and idle-running tabs expose blue/green dots', () => { + const { device } = setup({ tabs: 2, busy: ['term-2'] }) + expect(decodeKey(device, 0)).toMatchObject({ tabId: 't1', dot: 'green' }) // idle running + expect(decodeKey(device, 1)).toMatchObject({ tabId: 't2', dot: 'blue' }) // busy sorts after green + }) + + it('repo icons: unready at first paint, repaint to ready when the bitmap loads', async () => { + // Requires both harness extensions (Interfaces): setup()'s 4th extra-controller-options + // param (else { iconCache } is silently ignored and pending stays empty) and the + // deferredLoader helper ported from icon-image-cache.test.ts. + const { loader, pending } = deferredLoader() + const cache = new IconImageCache(loader) + const { device } = setup({ + tabs: 1, + terminalMeta: { 'term-1': { cwd: '/repos/alpha' } }, + repoIcons: { '/repos/alpha': { status: 'ready', repoRoot: '/repos/alpha', repoName: 'alpha', hasIcon: true } }, + }, undefined, defaultSettings, { iconCache: cache }) + const before = decodeKey(device, 0) + expect(before).toMatchObject({ icons: [{ letter: 'A', ready: false }] }) + pending.get((before as Extract).icons[0].url!)!.resolve({} as CanvasImageSource) + await vi.advanceTimersByTimeAsync(0) + expect(decodeKey(device, 0)).toMatchObject({ icons: [{ letter: 'A', ready: true }] }) + }) + + it('pager pages over the SORTED order', () => { + // 8 tabs on a 6-key Mini -> 5 tab slots + pager. Make t8 attention: it must appear on page 1 key 0. + const { device } = setup({ tabs: 8, attention: { t8: true } }) + expect(decodeKey(device, 0)).toMatchObject({ kind: 'tab', tabId: 't8' }) + expect(decodeKey(device, 5)).toMatchObject({ kind: 'pager', page: 1, pageCount: 2 }) + device.press(5) // next page + // Sorted order: t8,t1,t2,t3,t4 on page 1 (5 tab slots); t5,t6,t7 on page 2. + expect(decodeKey(device, 0)).toMatchObject({ kind: 'tab', tabId: 't5' }) + }) + + it('a mid-press re-sort does not retarget the press (e2e)', () => { + const { store, device } = setup({ tabs: 2, activeTab: 't1' }) + device.emit({ type: 'keyDown', keyIndex: 1 }) + store.dispatch(markTabAttention({ tabId: 't2' })) // from '@/store/turnCompletionSlice' - object payload + vi.advanceTimersByTime(100) + device.emit({ type: 'keyUp', keyIndex: 1 }) + expect(store.getState().tabs.activeTabId).toBe('t2') + }) + + it('short-press focuses, long-press opens the action layer - on the sorted layout', () => { + const { store, device } = setup({ tabs: 3, attention: { t3: true } }) // t3 sorts to key 0 + device.press(0) + expect(store.getState().tabs.activeTabId).toBe('t3') + holdKey(device, 1, 600) // long-press whatever now occupies key 1 + expect(decodeKey(device, 0)).toMatchObject({ kind: 'action', action: 'back' }) + }) }) From 39b9eff8599217d7687cc482aae3794b5c44c48c Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 05:52:09 -0700 Subject: [PATCH 17/30] refactor(deck): drop dead tabHasPendingApproval export left by ring removal --- src/deck/deck-selectors.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/deck/deck-selectors.ts b/src/deck/deck-selectors.ts index badbfbc87..f6f0f09cf 100644 --- a/src/deck/deck-selectors.ts +++ b/src/deck/deck-selectors.ts @@ -4,7 +4,7 @@ import type { FreshAgentPaneContent, PaneContent, PaneNode, TerminalPaneContent import type { TabStatusFlags } from './tile-state' import { tileFill, tileDot, tilePriority, type TileFill, type TileDot } from './tile-state' import { collectPaneEntries } from '@/lib/pane-utils' -import { getBusyPaneIdsForTab, hasWaitingPrompt, resolvePaneActivity } from '@/lib/pane-activity' +import { getBusyPaneIdsForTab, resolvePaneActivity } from '@/lib/pane-activity' import { getFreshOpenCodeRouteCwd } from '@/lib/fresh-opencode-route' import { buildRepoIconUrl, pathBasename, resolvePaneRepoCwd } from '@/lib/repo-icon' import { hueFromString } from '@/components/icons/RepoIcon' @@ -41,13 +41,6 @@ function freshAgentSessionFor(state: RootState, content: FreshAgentPaneContent) })] } -export function tabHasPendingApproval(state: RootState, tabId: string): boolean { - const layout = state.panes.layouts[tabId] - if (!layout) return false - return collectPaneEntries(layout).some((entry) => - entry.content.kind === 'fresh-agent' && hasWaitingPrompt(freshAgentSessionFor(state, entry.content))) -} - /** * Pane entries for a tab, tolerant of layout-less tabs. This transient is REAL: * addTab (tabsSlice.ts:296) never seeds a layout — PaneLayout.tsx:30-35 initializes From 8da1e22d7ba2c4c77773242ee31af033e75c1e53 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:02:01 -0700 Subject: [PATCH 18/30] docs: add implementation plan for deck-tile-modes --- docs/plans/2026-07-29-deck-tile-modes.md | 1262 ++++++++++++++++++++++ 1 file changed, 1262 insertions(+) create mode 100644 docs/plans/2026-07-29-deck-tile-modes.md diff --git a/docs/plans/2026-07-29-deck-tile-modes.md b/docs/plans/2026-07-29-deck-tile-modes.md new file mode 100644 index 000000000..6798ef8a1 --- /dev/null +++ b/docs/plans/2026-07-29-deck-tile-modes.md @@ -0,0 +1,1262 @@ +# Stream Deck Tile-Style Setting + Strip Waiting-Count Union Implementation Plan + +> **For agentic workers:** This plan is executed task-by-task by the +> workflow's execute stage: a fresh implementer per task, with a spec + +> quality review after each task. Steps use checkbox (`- [ ]`) syntax +> for tracking. + +**Goal:** Add a persisted user setting choosing between the new "Status icons" deck tile style (default; what `feat/deck-tile-redesign` built) and the classic "Terminal previews" style (restored from git history and gated behind the setting), and make the Stream Deck+ touch-strip "waiting" count the union of needs-attention and waiting-for-approval tabs in both styles. + +**Architecture:** Client-only. The new `streamDeck.tileStyle` setting lives in `LocalSettings` (localStorage persist, sparse-diff pattern) and flows into `selectDeckModel`, which carries it on `DeckModel` — so the controller's model-JSON bail-out and per-key spec-JSON paint cache invalidate automatically on a style switch (live repaint + re-sort, no reload). `KeySpec`'s tab variant becomes a two-member union discriminated on a `style` field (`'icons'` | `'preview'`); `buildFrame` constructs one or the other per the model's `tileStyle`, and the renderer dispatches on it. The classic machinery (terminal-text registry, preview polling, ring rendering) is restored verbatim from this branch's own git history and only ever executes when `tileStyle === 'terminal-previews'`. A new always-computed `DeckTab.pendingApproval` flag (restored `tabHasPendingApproval`) feeds both the classic amber ring and the strip's new union waiting count. + +**Tech Stack:** TypeScript, React, Redux Toolkit, Vitest (jsdom, fake-transport "e2e"), Tailwind, Zod (settings validation), Canvas 2D via injectable `CtxFactory`. + +## Global Constraints + +- Work in the worktree `/home/dan/code/freshell/.worktrees/deck-tile-modes` on branch `feat/deck-tile-modes` (based on `feat/deck-tile-redesign`, NOT origin/main). The eventual single PR contains the redesign plus this work. +- Do NOT create or open a PR without explicit user approval. Committing and pushing the branch is fine; stop before `gh pr create`. +- NEVER restart the live Rust/freshell server on port 3002. No broad kill patterns (`pkill -f vite`, `pkill node`, etc.). +- Client-only: no `server/` changes. Do NOT touch `/api/panes/:id/capture` or `server/agent-api/capture.ts`. +- The tab bar (`TabBar.tsx`, `TabItem.tsx`) must remain visually and behaviorally unchanged. +- Red-Green-Refactor TDD for every task. `console.error` is FATAL under test (`test/setup/dom.ts` throws) — no code path may log errors in tests. +- Focused test runs: `npm run test:vitest -- run --config config/vitest/vitest.config.ts` (`--config` is mandatory; there is no root vitest config). Broad runs (`npm test`, `npm run check`) go through the shared coordinator — check `npm run test:status` first and never kill a foreign holder. +- Lint: `npm run lint` (includes eslint-plugin-jsx-a11y; CI requirement). Typecheck: `npm run typecheck:client`. +- jsdom has no canvas and never loads images: renderer tests inject fake `Ctx2D`/loaders; controller/e2e tests use spec-encoding renderers (`encodeSpec`/`decodeKey`). Never add `environmentOptions.jsdom.resources` to the vitest config. +- Commits: Conventional Commits with `(deck)` scope where applicable, lowercase imperative subject, one focused commit per task. +- Path aliases: `@/` → `src/`, `@test/` → `test/`. +- Setting values are exactly `'status-icons'` (label **Status icons**, the default) and `'terminal-previews'` (label **Terminal previews**). +- README.md is the only end-user markdown doc to touch (plus this plan). `docs/index.html` is NOT updated: the default experience is unchanged (default style = the redesign), and a settings enum is not a "major change" per AGENTS.md's bar. + +## Design Decisions (settled — do not re-litigate) + +1. **`tileStyle` rides on `DeckModel` and `KeySpec`.** The controller's repaint bail-out is `JSON.stringify(selectDeckModel(state))` (`deck-controller.ts:196-199`) and the per-key paint cache is `JSON.stringify(spec)` (`deck-controller.ts:137-143`). Putting the style in the model and giving the tab KeySpec a `style` discriminant makes a settings flip repaint every key with zero extra invalidation machinery. +2. **Sort is gated in `selectDeckModel`:** `'status-icons'` sorts by `tilePriority`; `'terminal-previews'` keeps raw tab-bar order (the pre-redesign behavior). Paging, dials, and press targeting are order-agnostic downstream. +3. **Capture polling is gated in the controller's `tick()`** on `this.settings().tileStyle === 'terminal-previews'`. `buildFrame` only calls `previewFor` when building preview-style specs, so the icons style performs zero xterm buffer reads and zero preview repaints. +4. **The registry write side (`useTerminalTextRegistration` in `TerminalView`) is restored un-gated.** Registration is pull-based — a registered closure costs nothing until the controller reads it — and leaving it always-on keeps the module-level registry coherent when the setting flips mid-session. (This mirrors the pre-redesign wiring exactly.) +5. **`DeckTab` gains `pendingApproval: boolean`** (restored `tabHasPendingApproval`), computed in both styles because the strip union needs it everywhere. The classic amber ring derives from it via restored `ringColor({ busy, green: attention, amber: pendingApproval })`. Note: the classic ring's green input is the model's existing `attention` flag (gated on `tabAttentionStyle !== 'none'`, like everything else on the branch) rather than the pre-redesign ungated `attentionByTab` read — a deliberate, tiny unification so both styles honor the user's attention-style preference consistently. +6. **Strip waiting count = `attention || pendingApproval`, counted once per tab, in both styles** (the strip is shared; `stripText` does not branch on style). +7. **`SegmentedControl` gets a one-time a11y upgrade** (optional `aria-label` group name, `role="group"`, `type="button"`, `aria-pressed`) rather than per-callsite hacks — AGENTS.md requires "complex widgets: aria-pressed where applicable", and the control has 3+ existing call sites that inherit the fix for free. + +## Restore Points (git archaeology — recover, don't rewrite) + +All removed classic machinery exists in this branch's own history. Fork point `62fa0ff1` has the full pre-redesign implementation; `ef52f334` ("remove terminal preview machinery and status rings") and `fb09f7e3` (controller preview-repaint removal) are the deleting commits; `39b9eff8` removed `tabHasPendingApproval`. + +```bash +git show ef52f334^:src/deck/terminal-text-registry.ts # whole deleted file +git show ef52f334^:test/unit/client/deck/terminal-text-registry.test.tsx # whole deleted test file +git show 62fa0ff1:src/deck/tile-renderer.ts # old drawTab (preview + banner + rings), preview consts, previewGeometry, cropPreviewLines, RING_COLORS +git show ef52f334^:src/deck/frame.ts # RingColor, ringColor(), previewLines+ring on KeySpec, FrameInputs.previewFor +git show fb09f7e3^:src/deck/deck-controller.ts # PREVIEW_REFRESH_TICKS, tickCount, previewFor(), tick() repaint branch +git show 39b9eff8^:src/deck/deck-selectors.ts # tabHasPendingApproval verbatim +git show ef52f334^:src/components/TerminalView.tsx # useTerminalTextRegistration import + call site +git show 62fa0ff1:test/e2e/stream-deck-flow.test.tsx # old 'titles, previews, and rings' e2e +``` + +Do NOT `git apply -R` the removal commits: `fb09f7e3` bundles the IconImageCache/repo-icon-probe work you must keep. Hand-merge the quoted pieces. + +## File Structure + +| File | Change | Responsibility | +|---|---|---| +| `shared/settings.ts` | Modify | `DeckTileStyle` type + values + Zod schema; `streamDeck.tileStyle` in type, defaults, patch normalizer, and every resolve/compose/seed site that handles the other streamDeck fields | +| `src/store/browserPreferencesPersistence.ts` | Modify (~:145) | persist `tileStyle` via `assignChangedScalar` | +| `src/components/settings/settings-controls.tsx` | Modify (~:56-83) | `SegmentedControl` a11y upgrade (`aria-label`, `role="group"`, `type="button"`, `aria-pressed`) | +| `src/components/settings/StreamDeckSettings.tsx` | Modify | new "Tile style" `SettingsRow` + `SegmentedControl` | +| `src/deck/terminal-text-registry.ts` | **Restore** (deleted file) | preview registry: `registerTerminalTextReader`, `getTerminalTextSnapshot`, `readXtermTail`, `useTerminalTextRegistration` | +| `src/components/TerminalView.tsx` | Modify | restore `useTerminalTextRegistration(terminalContent?.terminalId, termRef)` call + import | +| `src/deck/deck-selectors.ts` | Modify | restore `tabHasPendingApproval`; `DeckTab.pendingApproval`; `DeckModel.tileStyle`; conditional sort | +| `src/deck/frame.ts` | Modify | `RingColor` + `ringColor()` restored; tab `KeySpec` split into `style: 'icons'`/`'preview'` variants; `FrameInputs.previewFor`; `buildFrame` branches; `stripText` union count | +| `src/deck/tile-renderer.ts` | Modify | restore preview constants, `previewGeometry`, `cropPreviewLines`, `RING_COLORS`, classic draw path (`drawPreviewTab`); dispatch on `spec.style` | +| `src/deck/deck-controller.ts` | Modify | settings thunk type gains `tileStyle`; restore `PREVIEW_REFRESH_TICKS`, `tickCount`, `previewFor()`, gated `tick()` repaint; pass `previewFor` to `buildFrame` | +| `README.md` | Modify (line 35, section lines 68-93) | fix stale copy; document both tile styles + the setting | +| `test/unit/shared/settings.stream-deck.test.ts` | Modify | tileStyle defaults/round-trip/invalid-drop | +| `test/unit/client/components/settings/StreamDeckSettings.test.tsx` | Modify | Tile style control behavior + a11y | +| `test/unit/client/deck/terminal-text-registry.test.tsx` | **Restore** (deleted file) | registry unit coverage | +| `test/unit/client/deck/deck-selectors.test.ts` | Modify | `pendingApproval` flag (restores the lost amber coverage), `tileStyle` on model, sort gating | +| `test/unit/client/deck/frame.test.ts` | Modify | `ringColor` priority (restored), dual-variant `buildFrame`, `previewFor` laziness, strip union | +| `test/unit/client/deck/tile-renderer.test.ts` | Modify | restored `previewGeometry`/`cropPreviewLines` describes, classic tab draw (preview text + ring geometry), icons path regression | +| `test/unit/client/deck/deck-controller.test.ts` | Modify | polling gated by style (both directions), settings fixtures gain `tileStyle` | +| `test/e2e/stream-deck-flow.test.tsx` | Modify | classic journey (previews + rings + tab-bar order), live style switch, no-polling proof, strip union, mid-press style flip | + +**Interfaces produced (used across tasks — exact names):** + +```ts +// shared/settings.ts +export const DECK_TILE_STYLE_VALUES = ['status-icons', 'terminal-previews'] as const +export type DeckTileStyle = (typeof DECK_TILE_STYLE_VALUES)[number] +// LocalSettings['streamDeck'] gains: tileStyle: DeckTileStyle (default 'status-icons') + +// src/deck/deck-selectors.ts +export type DeckTab = { + id: string; title: string; active: boolean + busy: boolean; attention: boolean + pendingApproval: boolean // NEW + fill: TileFill; dot: TileDot; priority: number + repoIcons: TileRepoIcon[] +} +export type DeckModel = { tabs: DeckTab[]; activeTabId: string | null; tileStyle: DeckTileStyle } // tileStyle NEW +export function tabHasPendingApproval(state: RootState, tabId: string): boolean // RESTORED + +// src/deck/frame.ts +export type RingColor = 'amber' | 'green' | 'blue' | null // RESTORED +export function ringColor(status: { busy: boolean; green: boolean; amber: boolean }): RingColor // RESTORED +export type KeySpec = + | { kind: 'empty' } + | { kind: 'tab'; style: 'icons'; tabId: string; title: string; active: boolean; fill: TileFill; dot: TileDot; icons: TileIcon[] } + | { kind: 'tab'; style: 'preview'; tabId: string; title: string; active: boolean; previewLines: string[]; ring: RingColor } + | { kind: 'pager'; page: number; pageCount: number } + | { kind: 'action'; action: DeckAction; enabled: boolean } +// FrameInputs gains: previewFor: (tabId: string) => string[] + +// src/deck/terminal-text-registry.ts (RESTORED verbatim) +export function registerTerminalTextReader(terminalId: string, reader: () => string[]): () => void +export function getTerminalTextSnapshot(terminalId: string): string[] | null +export function resetTerminalTextRegistryForTests(): void +export function readXtermTail(term: XtermLike, maxLines: number): string[] +export function useTerminalTextRegistration(terminalId: string | undefined, termRef: MutableRefObject, maxLines?: number): void + +// src/deck/deck-controller.ts +export const PREVIEW_REFRESH_TICKS = 6 // RESTORED (previews re-checked every 3s of TICK_MS=500 ticks) +// DeckControllerOptions.settings: () => { brightness: number; idleBrightness: number; idleTimeoutSeconds: number; tileStyle: DeckTileStyle } + +// src/deck/tile-renderer.ts (RESTORED exports) +export const RING_COLORS: Record, string> +export function previewGeometry(width: number, height: number): { lines: number; columns: number } +export function cropPreviewLines(lines: string[], maxLines: number, maxColumns: number): string[] + +// src/components/settings/settings-controls.tsx +// SegmentedControl props gain: 'aria-label'?: string +``` + +--- + +### Task 1: `tileStyle` setting — shared schema + localStorage persistence + +**Files:** +- Modify: `shared/settings.ts` (type ~:223, defaults ~:895, patch normalizer ~:631-648, plus every other site that handles streamDeck fields — find them all with the grep in Step 1) +- Modify: `src/store/browserPreferencesPersistence.ts:145-155` +- Test: `test/unit/shared/settings.stream-deck.test.ts` + +**Interfaces:** +- Consumes: existing `LocalSettings`, `defaultLocalSettings`, `LocalSettingsPatch`, `assignChangedScalar`, the Zod-enum idiom at `shared/settings.ts:546-548` (`TabAttentionStyleSchema`). +- Produces: `DECK_TILE_STYLE_VALUES`, `DeckTileStyle`, `LocalSettings['streamDeck'].tileStyle` (default `'status-icons'`), persisted sparse-diff key. Every later task reads `state.settings.settings.streamDeck.tileStyle`. + +- [ ] **Step 1: Map every streamDeck touch point** + +Run: `grep -n "idleTimeoutSeconds" shared/settings.ts src/store/browserPreferencesPersistence.ts` + +Every line that mentions `idleTimeoutSeconds` is a site the new `tileStyle` field must also be added to (type, defaults, patch normalizer, resolve/compose/seed helpers around `shared/settings.ts:1298/:1389/:1451`, persistence builder). Keep the list; the round-trip test in Step 2 fails until all are covered. + +- [ ] **Step 2: Write the failing tests** + +Add to `test/unit/shared/settings.stream-deck.test.ts` (follow the file's existing imports/fixtures; it already tests defaults, resolve→`buildLocalSettingsPatch` round-trips, and the no-patch-at-defaults rule): + +```ts +describe('streamDeck.tileStyle', () => { + it('defaults to status-icons', () => { + expect(defaultLocalSettings.streamDeck.tileStyle).toBe('status-icons') + }) + + it('round-trips terminal-previews through patch normalization and persistence', () => { + const normalized = normalizeLocalSettingsPatch({ streamDeck: { tileStyle: 'terminal-previews' } }) + expect(normalized.streamDeck?.tileStyle).toBe('terminal-previews') + const local = resolveLocalSettings(normalized) + expect(buildLocalSettingsPatch(local).streamDeck?.tileStyle).toBe('terminal-previews') + }) + + it('drops invalid tileStyle values', () => { + const normalized = normalizeLocalSettingsPatch({ streamDeck: { tileStyle: 'sparkly' } } as never) + expect(normalized.streamDeck?.tileStyle).toBeUndefined() + }) + + it('produces no persisted entry at the default value', () => { + const local = resolveLocalSettings({}) + expect(buildLocalSettingsPatch(local).streamDeck?.tileStyle).toBeUndefined() + }) +}) +``` + +Adapt the exact helper names (`normalizeLocalSettingsPatch`, `resolveLocalSettings`, `buildLocalSettingsPatch`) to what the file already imports — it exercises exactly this normalize→resolve→patch pipeline today; mirror its existing round-trip test's calls verbatim. + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `npm run test:vitest -- run test/unit/shared/settings.stream-deck.test.ts --config config/vitest/vitest.config.ts` +Expected: FAIL — `tileStyle` is `undefined` on defaults / dropped by the normalizer. + +- [ ] **Step 4: Implement** + +In `shared/settings.ts`, next to `TAB_ATTENTION_STYLE_VALUES` (~:262): + +```ts +export const DECK_TILE_STYLE_VALUES = ['status-icons', 'terminal-previews'] as const +export type DeckTileStyle = (typeof DECK_TILE_STYLE_VALUES)[number] +const DeckTileStyleSchema = z.enum(DECK_TILE_STYLE_VALUES) +``` + +Type (~:223): add `tileStyle: DeckTileStyle` to the `streamDeck` object. Defaults (~:895): add `tileStyle: 'status-icons',`. Patch normalizer, inside the `isRecord(patch.streamDeck)` block (~:631-648): + +```ts + if (DeckTileStyleSchema.safeParse(patch.streamDeck.tileStyle).success) { + streamDeck.tileStyle = patch.streamDeck.tileStyle as DeckTileStyle + } +``` + +Add `tileStyle` at every remaining site from Step 1's grep, mirroring `idleTimeoutSeconds` handling exactly (for non-scalar helpers copy the adjacent field's line and change the key). In `src/store/browserPreferencesPersistence.ts` after the `idleTimeoutSeconds` line (~:149): + +```ts + assignChangedScalar(streamDeck, localSettings.streamDeck, defaultLocalSettings.streamDeck, 'tileStyle') +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `npm run test:vitest -- run test/unit/shared/settings.stream-deck.test.ts --config config/vitest/vitest.config.ts` +Expected: PASS (whole file, including pre-existing tests). + +- [ ] **Step 6: Typecheck, then commit** + +Run: `npm run typecheck:client` — expected clean (nothing consumes the field yet; adding it is additive). + +```bash +git add shared/settings.ts src/store/browserPreferencesPersistence.ts test/unit/shared/settings.stream-deck.test.ts +git commit -m "feat(deck): streamDeck.tileStyle local setting - status-icons default, terminal-previews opt-in" +``` + +--- + +### Task 2: Settings UI — Tile style control (+ SegmentedControl a11y) + +**Files:** +- Modify: `src/components/settings/settings-controls.tsx:56-83` (`SegmentedControl`) +- Modify: `src/components/settings/StreamDeckSettings.tsx` +- Test: `test/unit/client/components/settings/StreamDeckSettings.test.tsx` + +**Interfaces:** +- Consumes: `DeckTileStyle` from Task 1; `applyLocalSetting: (updates: LocalSettingsPatch) => void` from `SettingsSectionProps`; `SettingsRow`/`SegmentedControl` from `settings-controls.tsx`. +- Produces: a "Tile style" control dispatching `applyLocalSetting({ streamDeck: { tileStyle } })`; `SegmentedControl` accepts optional `'aria-label'`. + +- [ ] **Step 1: Write the failing tests** + +In `test/unit/client/components/settings/StreamDeckSettings.test.tsx`, first extend the `renderSection` default fixture (~:23) with the new field: + +```tsx +function renderSection( + streamDeck = { enabled: true, brightness: 100, idleBrightness: 10, idleTimeoutSeconds: 300, tileStyle: 'status-icons' as const }, +) { +``` + +Then add: + +```tsx + it('offers the tile style choice with Status icons selected by default', () => { + renderSection() + const group = screen.getByRole('group', { name: /tile style/i }) + const statusIcons = within(group).getByRole('button', { name: /status icons/i }) + expect(statusIcons).toHaveAttribute('aria-pressed', 'true') + expect(within(group).getByRole('button', { name: /terminal previews/i })).toHaveAttribute('aria-pressed', 'false') + }) + + it('selecting Terminal previews patches streamDeck.tileStyle', () => { + const { applyLocalSetting } = renderSection() + fireEvent.click(screen.getByRole('button', { name: /terminal previews/i })) + expect(applyLocalSetting).toHaveBeenCalledWith({ streamDeck: { tileStyle: 'terminal-previews' } }) + }) +``` + +Add `within` to the existing `@testing-library/react` import. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm run test:vitest -- run test/unit/client/components/settings/StreamDeckSettings.test.tsx --config config/vitest/vitest.config.ts` +Expected: FAIL — no `group` role, no such buttons. + +- [ ] **Step 3: Implement** + +`settings-controls.tsx` — upgrade `SegmentedControl` (all existing call sites keep working; the new props are optional/behavior-preserving): + +```tsx +export function SegmentedControl({ + value, + options, + onChange, + 'aria-label': ariaLabel, +}: { + value: string + options: { value: string; label: string }[] + onChange: (value: string) => void + 'aria-label'?: string +}) { + return ( +
+ {options.map((opt) => ( + + ))} +
+ ) +} +``` + +`StreamDeckSettings.tsx` — add a row after the "Enable Stream Deck" row (~:75), following the file's existing `streamDeck` accessor: + +```tsx + + { + const tileStyle = v as DeckTileStyle + applyLocalSetting({ streamDeck: { tileStyle } }) + }} + /> + +``` + +Import `SegmentedControl` from `./settings-controls` and `type { DeckTileStyle }` from `../../../shared/settings` (match the file's existing import path style for `shared/settings`). + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm run test:vitest -- run test/unit/client/components/settings/StreamDeckSettings.test.tsx test/unit/client/components/settings/ test/unit/client/components/VirtualDeckPanel.test.tsx --config config/vitest/vitest.config.ts` +Expected: PASS — including all pre-existing settings tests and the other `SegmentedControl` consumers (PanesSettings, VirtualDeckPanel). + +- [ ] **Step 5: Lint + typecheck, commit** + +Run: `npm run lint && npm run typecheck:client` — expected clean. + +```bash +git add src/components/settings/settings-controls.tsx src/components/settings/StreamDeckSettings.tsx test/unit/client/components/settings/StreamDeckSettings.test.tsx +git commit -m "feat(deck): tile style setting UI with a11y-labeled segmented control" +``` + +--- + +### Task 3: Restore the terminal-text registry + TerminalView registration + +**Files:** +- Restore: `src/deck/terminal-text-registry.ts` +- Restore: `test/unit/client/deck/terminal-text-registry.test.tsx` +- Modify: `src/components/TerminalView.tsx` (2 lines: import + hook call) + +**Interfaces:** +- Consumes: nothing new. +- Produces: `registerTerminalTextReader(terminalId, reader)`, `getTerminalTextSnapshot(terminalId)`, `resetTerminalTextRegistryForTests()`, `useTerminalTextRegistration(terminalId, termRef, maxLines = 12)` — consumed by Task 8 (controller `previewFor`) and Task 9 (e2e seeding). + +- [ ] **Step 1: Restore the deleted files from git (RED: the restored test file fails to resolve its import until the module is restored — restore both, then run)** + +```bash +cd /home/dan/code/freshell/.worktrees/deck-tile-modes +git show ef52f334^:src/deck/terminal-text-registry.ts > src/deck/terminal-text-registry.ts +git show ef52f334^:test/unit/client/deck/terminal-text-registry.test.tsx > test/unit/client/deck/terminal-text-registry.test.tsx +``` + +The restored module is 53 lines and must match this exactly (verify after restore): + +```ts +import { useEffect } from 'react' +import type { MutableRefObject } from 'react' + +export type TerminalTextReader = () => string[] +const readers = new Map() + +export function registerTerminalTextReader(terminalId: string, reader: TerminalTextReader): () => void { + readers.set(terminalId, reader) + return () => { + if (readers.get(terminalId) === reader) readers.delete(terminalId) + } +} +export function getTerminalTextSnapshot(terminalId: string): string[] | null { + const reader = readers.get(terminalId) + return reader ? reader() : null +} +export function resetTerminalTextRegistryForTests(): void { + readers.clear() +} + +export type XtermLike = { + buffer: { + active: { + length: number + viewportY: number + getLine(y: number): { translateToString(trimRight?: boolean): string } | undefined + } + } +} + +export function readXtermTail(term: XtermLike, maxLines: number): string[] { + const buf = term.buffer.active + const start = Math.max(0, buf.length - maxLines) + const out: string[] = [] + for (let y = start; y < buf.length; y++) { + out.push(buf.getLine(y)?.translateToString(true) ?? '') + } + return out +} + +export function useTerminalTextRegistration( + terminalId: string | undefined, + termRef: MutableRefObject, + maxLines = 12, +): void { + useEffect(() => { + if (!terminalId) return + return registerTerminalTextReader(terminalId, () => { + const term = termRef.current + return term ? readXtermTail(term, maxLines) : [] + }) + }, [terminalId, termRef, maxLines]) +} +``` + +- [ ] **Step 2: Run the restored test** + +Run: `npm run test:vitest -- run test/unit/client/deck/terminal-text-registry.test.tsx --config config/vitest/vitest.config.ts` +Expected: PASS (registry round-trip, `readXtermTail` tail semantics, hook register/cleanup/no-op cases). + +- [ ] **Step 3: Restore the TerminalView write side** + +In `src/components/TerminalView.tsx`, re-add the import (with the file's other `@/deck`-style imports): + +```ts +import { useTerminalTextRegistration } from '@/deck/terminal-text-registry' +``` + +and, immediately after the line `const terminalContent = isTerminal ? paneContent : null` (see `git show ef52f334 -- src/components/TerminalView.tsx` for the exact removal site, ~:673-677 pre-removal): + +```ts + // Register live terminal text reader for Stream Deck previews (classic tile style) + useTerminalTextRegistration(terminalContent?.terminalId, termRef) +``` + +- [ ] **Step 4: Verify TerminalView still passes its suite** + +Run: `npm run test:vitest -- run test/unit/client/components/TerminalView --config config/vitest/vitest.config.ts` +Expected: PASS (if no TerminalView test file matches, run `npm run test:vitest -- run test/unit/client/components/ --config config/vitest/vitest.config.ts` and expect PASS). + +- [ ] **Step 5: Typecheck + commit** + +Run: `npm run typecheck:client` — expected clean. + +```bash +git add src/deck/terminal-text-registry.ts test/unit/client/deck/terminal-text-registry.test.tsx src/components/TerminalView.tsx +git commit -m "feat(deck): restore terminal-text registry and TerminalView registration for classic tiles" +``` + +--- + +### Task 4: Selector layer — `pendingApproval`, `tileStyle` on the model, gated sort + +**Files:** +- Modify: `src/deck/deck-selectors.ts` +- Test: `test/unit/client/deck/deck-selectors.test.ts` + +**Interfaces:** +- Consumes: `hasWaitingPrompt` from `@/lib/pane-activity`; `collectPaneEntries` from `@/lib/pane-utils`; `DeckTileStyle` from Task 1; existing `freshAgentSessionFor`, `tilePriority`. +- Produces: `tabHasPendingApproval(state, tabId)` (exported, restored); `DeckTab.pendingApproval: boolean`; `DeckModel.tileStyle: DeckTileStyle`; sort applied only for `'status-icons'`. Tasks 5-9 rely on these exact names. + +- [ ] **Step 1: Write the failing tests** + +In `test/unit/client/deck/deck-selectors.test.ts`: + +(a) Extend the existing test named `'pending permission suppresses busy on the fresh-agent tab'` — this restores the amber-coverage hole the redesign left. Using that test's existing state fixture, add at its end: + +```ts + const model = selectDeckModel(state) + const freshTab = model.tabs.find((t) => t.id === 't2')! // adapt the tab id to the fixture's fresh-agent tab + expect(freshTab.pendingApproval).toBe(true) + expect(model.tabs.filter((t) => t.pendingApproval)).toHaveLength(1) +``` + +(b) New tests in the `selectDeckModel` describe (reuse the describe's existing state-builder — the same one its sort/stability tests use; set the tile style directly on the built state): + +```ts + it('exposes the tile style on the model (default status-icons)', () => { + const state = /* the describe's existing multi-tab state builder */ + expect(selectDeckModel(state).tileStyle).toBe('status-icons') + }) + + it('terminal-previews style keeps raw tab-bar order (no priority sort)', () => { + const state = /* the same state the existing sort test uses, where sorting reorders tabs */ + state.settings.settings.streamDeck.tileStyle = 'terminal-previews' + const model = selectDeckModel(state) + expect(model.tileStyle).toBe('terminal-previews') + expect(model.tabs.map((t) => t.id)).toEqual(state.tabs.tabs.map((t) => t.id)) + }) + + it('quiet tabs report pendingApproval false', () => { + const state = /* the describe's quiet-tabs state */ + expect(selectDeckModel(state).tabs.every((t) => t.pendingApproval === false)).toBe(true) + }) +``` + +For the `terminal-previews` test, pick/extend the exact fixture the existing `'sorts by status priority'`-style test uses, so tab-bar order and sorted order genuinely differ — the test must fail against sorted output. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm run test:vitest -- run test/unit/client/deck/deck-selectors.test.ts --config config/vitest/vitest.config.ts` +Expected: FAIL — `pendingApproval`/`tileStyle` undefined; preview-style order still sorted. + +- [ ] **Step 3: Implement** + +In `src/deck/deck-selectors.ts`: + +Restore the import and function removed by `39b9eff8` (see `git show 39b9eff8^:src/deck/deck-selectors.ts`): + +```ts +import { getBusyPaneIdsForTab, hasWaitingPrompt, resolvePaneActivity } from '@/lib/pane-activity' +``` + +```ts +export function tabHasPendingApproval(state: RootState, tabId: string): boolean { + const layout = state.panes.layouts[tabId] + if (!layout) return false + return collectPaneEntries(layout).some((entry) => + entry.content.kind === 'fresh-agent' && hasWaitingPrompt(freshAgentSessionFor(state, entry.content))) +} +``` + +Update the types and `selectDeckModel`: + +```ts +import type { DeckTileStyle } from '../../shared/settings' // match the file's existing shared-settings import style + +export type DeckModel = { tabs: DeckTab[]; activeTabId: string | null; tileStyle: DeckTileStyle } +// DeckTab gains: pendingApproval: boolean + +export function selectDeckModel(state: RootState): DeckModel { + const activeTabId = state.tabs.activeTabId + const tileStyle = state.settings.settings.streamDeck.tileStyle + const tabs = state.tabs.tabs.map((tab) => { + const active = tab.id === activeTabId + const flags = getTabStatusFlags(state, tab) + return { + id: tab.id, + title: tab.title, + active, + busy: flags.busy, + attention: flags.attention, + pendingApproval: tabHasPendingApproval(state, tab.id), + fill: tileFill(active, flags), + dot: tileDot(flags), + priority: tilePriority(active, flags), + repoIcons: getTabRepoIcons(state, tab), + } + }) + if (tileStyle === 'status-icons') { + // Status-priority sort; Array.prototype.sort is stable, so tab-bar order + // is preserved within each priority group. Paging slices this sorted list + // (visibleTabs), so the pager pages over the sorted order automatically. + // Classic terminal-previews style keeps raw tab-bar order (pre-redesign behavior). + tabs.sort((a, b) => a.priority - b.priority) + } + return { activeTabId, tabs, tileStyle } +} +``` + +- [ ] **Step 4: Run the deck unit suites** + +Run: `npm run test:vitest -- run test/unit/client/deck/ --config config/vitest/vitest.config.ts` +Expected: PASS. (Existing model-shape assertions use `toMatchObject`, which tolerates the new fields; fix any strict `toEqual` model assertions by adding `pendingApproval: false`/`tileStyle: 'status-icons'` to their expected objects.) + +- [ ] **Step 5: Typecheck + commit** + +Run: `npm run typecheck:client` — expected clean. + +```bash +git add src/deck/deck-selectors.ts test/unit/client/deck/deck-selectors.test.ts +git commit -m "feat(deck): pendingApproval flag, tileStyle on DeckModel, sort gated to status-icons style" +``` + +--- + +### Task 5: Strip "waiting" = attention ∪ pending approval (Change 2) + +**Files:** +- Modify: `src/deck/frame.ts` (`stripText`, ~:63-71) +- Test: `test/unit/client/deck/frame.test.ts` + +**Interfaces:** +- Consumes: `DeckTab.pendingApproval` from Task 4. +- Produces: `stripText` counting `t.attention || t.pendingApproval`, each tab once. Shared by both styles (no style branch). + +- [ ] **Step 1: Write the failing tests** + +In `test/unit/client/deck/frame.test.ts`, the existing strip tests build model tabs with flat `busy`/`attention` fields (see the test `'stripText counts busy and waiting from tab flags'`). Add `pendingApproval: false` to the file's tab-fixture helper (`makeDeckTab` or equivalent), then add: + +```ts + it('stripText counts a pending-approval tab as waiting', () => { + const m = makeModel(3) // the file's existing model helper + m.tabs[1].pendingApproval = true + expect(stripText(m, 1, 1)).toContain('1 waiting') + }) + + it('stripText counts waiting as the union of attention and pending approval', () => { + const m = makeModel(3) + m.tabs[0].attention = true + m.tabs[1].pendingApproval = true + expect(stripText(m, 1, 1)).toContain('2 waiting') + }) + + it('a tab that both needs attention and awaits approval counts once', () => { + const m = makeModel(2) + m.tabs[0].attention = true + m.tabs[0].pendingApproval = true + expect(stripText(m, 1, 1)).toContain('1 waiting') + }) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm run test:vitest -- run test/unit/client/deck/frame.test.ts --config config/vitest/vitest.config.ts` +Expected: FAIL — pending-approval-only tab yields `0 waiting`. + +- [ ] **Step 3: Implement** + +In `src/deck/frame.ts`, update `stripText`'s structural parameter type and the count: + +```ts +export function stripText( + model: { tabs: Array<{ title: string; active: boolean; busy: boolean; attention: boolean; pendingApproval: boolean }> }, + page: number, + pages: number, +): string { + const active = model.tabs.find((t) => t.active) + const busyCount = model.tabs.filter((t) => t.busy).length + // "waiting" = needs attention (turn complete) OR waiting for approval — each tab once. + const waitingCount = model.tabs.filter((t) => t.attention || t.pendingApproval).length + return toAscii(`${active?.title ?? '-'} | page ${page}/${pages} | ${busyCount} busy ${waitingCount} waiting`) +} +``` + +(Keep the exact string template the file has today — only the second count's source changes.) + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm run test:vitest -- run test/unit/client/deck/frame.test.ts --config config/vitest/vitest.config.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/deck/frame.ts test/unit/client/deck/frame.test.ts +git commit -m "feat(deck): strip waiting count unions needs-attention and waiting-for-approval tabs" +``` + +--- + +### Task 6: Classic KeySpec variant, `buildFrame` branching, and the classic renderer path + +This task lands the frame types and the renderer together because the renderer must exhaustively handle the new `KeySpec` union to typecheck. + +**Files:** +- Modify: `src/deck/frame.ts` (`RingColor`, `ringColor`, `KeySpec`, `FrameInputs`, `buildFrame`) +- Modify: `src/deck/tile-renderer.ts` (restore preview constants + helpers + classic draw path; dispatch on `spec.style`) +- Test: `test/unit/client/deck/frame.test.ts`, `test/unit/client/deck/tile-renderer.test.ts` + +**Interfaces:** +- Consumes: `DeckModel.tileStyle`, `DeckTab.pendingApproval` (Task 4); existing `TileFill`/`TileDot`/`TileIcon`, `drawRing`, `fitLabel`, `truncateTitle`, `BANNER_HEIGHT`, `BANNER_FILL`, `TITLE_FONT_SIZE`, `ACTIVE_COLOR` (all still present at HEAD). +- Produces: the `KeySpec` union, `RingColor`, `ringColor`, `FrameInputs.previewFor`, `RING_COLORS`, `previewGeometry`, `cropPreviewLines` exactly as listed in the Interfaces block at the top of this plan. Task 8 passes `previewFor`; Task 9 asserts decoded specs. + +- [ ] **Step 1: Write the failing frame tests** + +In `test/unit/client/deck/frame.test.ts`: + +(a) Restore the ring-priority describe deleted in `ef52f334`: + +```ts +const quiet = { busy: false, green: false, amber: false } + +describe('ringColor priority', () => { + it('amber > green > blue > none', () => { + expect(ringColor({ busy: true, green: true, amber: true })).toBe('amber') + expect(ringColor({ busy: true, green: true, amber: false })).toBe('green') + expect(ringColor({ busy: true, green: false, amber: false })).toBe('blue') + expect(ringColor(quiet)).toBeNull() + }) +}) +``` + +(b) `buildFrame` style branching (adapt the model/caps fixtures the file's existing `buildFrame` tests use; give the model helper a `tileStyle` field defaulting to `'status-icons'`): + +```ts +describe('buildFrame tile styles', () => { + it('status-icons model yields icons-style tab specs and never calls previewFor', () => { + const previewFor = vi.fn(() => ['nope']) + const frame = buildFrame({ model: makeModel(2), caps: MINI, page: 1, actionLayer: null, previewFor }) + expect(frame.keys[0]).toMatchObject({ kind: 'tab', style: 'icons' }) + expect(previewFor).not.toHaveBeenCalled() + }) + + it('terminal-previews model yields preview-style specs with lines and ring', () => { + const m = makeModel(2) + m.tileStyle = 'terminal-previews' + m.tabs[0].busy = true + m.tabs[1].pendingApproval = true + const frame = buildFrame({ + model: m, caps: MINI, page: 1, actionLayer: null, + previewFor: (tabId) => [`preview of ${tabId}`], + }) + expect(frame.keys[0]).toMatchObject({ + kind: 'tab', style: 'preview', previewLines: ['preview of t1'], ring: 'blue', + }) + expect(frame.keys[1]).toMatchObject({ kind: 'tab', style: 'preview', ring: 'amber' }) + }) +}) +``` + +Also add `previewFor: () => []` to every existing `buildFrame` call in the file (it becomes a required input), and add `style: 'icons'` to any strict spec `toEqual` expectations. + +- [ ] **Step 2: Write the failing renderer tests** + +In `test/unit/client/deck/tile-renderer.test.ts`: + +(a) Restore the two describes deleted in `ef52f334` (these encode real hardware pixel sizes — keep the numbers exact): + +```ts +describe('previewGeometry', () => { + it('matches the hardware-anchored values', () => { + expect(previewGeometry(120, 120)).toEqual({ lines: 8, columns: 21 }) + expect(previewGeometry(80, 80)).toEqual({ lines: 5, columns: 14 }) + expect(previewGeometry(72, 72)).toEqual({ lines: 4, columns: 12 }) + }) +}) + +describe('cropPreviewLines', () => { + it('drops trailing blanks, keeps last N lines and first M columns', () => { + const lines = ['one', 'two-is-longer-than-five', 'three', '', ' '] + expect(cropPreviewLines(lines, 2, 5)).toEqual(['two-i', 'three']) + }) +}) +``` + +(b) Classic tab rendering, using the file's existing `recordingCtx()` spy and `tabSpec()` helper (add a `previewSpec()` sibling helper): + +```ts +function previewSpec(overrides: Partial> = {}) { + return { + kind: 'tab' as const, style: 'preview' as const, tabId: 't1', title: 'Tab 1', + active: false, previewLines: ['$ npm test', 'PASS'], ring: null as RingColor, + ...overrides, + } +} + +describe('renderKey preview style', () => { + it('draws preview text in the preview color under the title banner', () => { + const { ctx, calls } = recordingCtx() + renderKey(previewSpec(), MINI_CAPS_LIKE, () => ctx) // adapt to the file's renderKey invocation + const texts = calls.filter((c) => c.op === 'fillText' && c.style === PREVIEW_TEXT_COLOR) + expect(texts.map((c) => c.text)).toEqual(['$ npm test', 'PASS']) + }) + + it('status ring + active tab draws the status ring plus the white inner ring', () => { + const { ctx, calls } = recordingCtx() + renderKey(previewSpec({ ring: 'green', active: true }), MINI_CAPS_LIKE, () => ctx) + expect(calls.some((c) => c.op === 'fillRect' && c.style === RING_COLORS.green)).toBe(true) + expect(calls.some((c) => c.op === 'fillRect' && c.style === ACTIVE_COLOR)).toBe(true) // white inner ring + }) + + it('amber ring renders for a waiting-for-approval tab', () => { + const { ctx, calls } = recordingCtx() + renderKey(previewSpec({ ring: 'amber' }), MINI_CAPS_LIKE, () => ctx) + expect(calls.some((c) => c.op === 'fillRect' && c.style === RING_COLORS.amber)).toBe(true) + }) + + it('icons style still renders fills (dispatch regression)', () => { + const { ctx, calls } = recordingCtx() + renderKey(tabSpec({ fill: 'green' }), MINI_CAPS_LIKE, () => ctx) + expect(calls.some((c) => c.op === 'fillRect' && c.style === '#a7f3d0')).toBe(true) // emerald-200 green fill + }) +}) +``` + +Adapt `recordingCtx` call-shape (`op`/`style`/`text` field names) and the `renderKey` argument list to what the file already uses — copy from its existing `renderKey` tests. Also add `style: 'icons'` to the file's `tabSpec()` helper. + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `npm run test:vitest -- run test/unit/client/deck/frame.test.ts test/unit/client/deck/tile-renderer.test.ts --config config/vitest/vitest.config.ts` +Expected: FAIL — missing exports (`ringColor`, `previewGeometry`, `cropPreviewLines`, `RING_COLORS`, `PREVIEW_TEXT_COLOR`), unknown `style` field. + +- [ ] **Step 4: Implement `frame.ts`** + +Restore/introduce (classic pieces verbatim from `git show ef52f334^:src/deck/frame.ts`): + +```ts +export type RingColor = 'amber' | 'green' | 'blue' | null + +export function ringColor(status: { busy: boolean; green: boolean; amber: boolean }): RingColor { + if (status.amber) return 'amber' + if (status.green) return 'green' + if (status.busy) return 'blue' + return null +} + +export type KeySpec = + | { kind: 'empty' } + | { kind: 'tab'; style: 'icons'; tabId: string; title: string; active: boolean; fill: TileFill; dot: TileDot; icons: TileIcon[] } + | { kind: 'tab'; style: 'preview'; tabId: string; title: string; active: boolean; previewLines: string[]; ring: RingColor } + | { kind: 'pager'; page: number; pageCount: number } + | { kind: 'action'; action: DeckAction; enabled: boolean } +``` + +Add to `FrameInputs`: + +```ts + /** Live terminal tail for a tab; only invoked for terminal-previews style. */ + previewFor: (tabId: string) => string[] +``` + +In `buildFrame`'s tab-key construction (keep the current icons expression byte-identical apart from the added `style` field; `iconReady` stands for the existing readiness expression already in the file): + +```ts + keys[keyIndex] = + model.tileStyle === 'terminal-previews' + ? { + kind: 'tab', style: 'preview', tabId: tab.id, title: tab.title, active: tab.active, + previewLines: previewFor(tab.id), + ring: ringColor({ busy: tab.busy, green: tab.attention, amber: tab.pendingApproval }), + } + : { + kind: 'tab', style: 'icons', tabId: tab.id, title: tab.title, active: tab.active, + fill: tab.fill, dot: tab.dot, + icons: tab.repoIcons.map((icon) => ({ ...icon, ready: /* existing readiness expression */ })), + } +``` + +- [ ] **Step 5: Implement `tile-renderer.ts`** + +Restore verbatim from `git show 62fa0ff1:src/deck/tile-renderer.ts` (constants + helpers): + +```ts +export const PREVIEW_BG = '#0a0a0a' +export const PREVIEW_TEXT_COLOR = '#a8a8a8' +export const PREVIEW_FONT_SIZE = 11 +export const PREVIEW_LINE_HEIGHT = 13 +export const PREVIEW_CHAR_WIDTH = 5.5 +export const PREVIEW_LEFT_MARGIN = 3 +export const RING_COLORS: Record, string> = { + amber: '#f59e0b', + green: '#22c55e', + blue: '#3b82f6', +} + +export function previewGeometry(width: number, height: number): { lines: number; columns: number } { + return { + lines: Math.max(1, Math.floor((height - BANNER_HEIGHT - 2) / PREVIEW_LINE_HEIGHT) + 1), + columns: Math.max(1, Math.floor((width - PREVIEW_LEFT_MARGIN) / PREVIEW_CHAR_WIDTH)), + } +} + +export function cropPreviewLines(lines: string[], maxLines: number, maxColumns: number): string[] { + const out = [...lines] + while (out.length > 0 && out[out.length - 1].trim() === '') out.pop() + return out.slice(-maxLines).map((l) => l.slice(0, maxColumns)) +} +``` + +Rename the current private `drawTab` to `drawIconsTab` (body unchanged, parameter type narrowed to the `style: 'icons'` variant), restore the classic path as `drawPreviewTab` (verbatim old `drawTab` from `62fa0ff1`, parameter narrowed to the `style: 'preview'` variant): + +```ts +function drawPreviewTab(ctx: Ctx2D, w: number, h: number, spec: Extract): void { + ctx.fillStyle = PREVIEW_BG + ctx.fillRect(0, 0, w, h) + + const { lines, columns } = previewGeometry(w, h) + const body = cropPreviewLines(spec.previewLines, lines, columns) + ctx.font = `${PREVIEW_FONT_SIZE}px monospace` + ctx.textBaseline = 'top' + ctx.fillStyle = PREVIEW_TEXT_COLOR + const baseY = h - body.length * PREVIEW_LINE_HEIGHT - 2 + body.forEach((line, i) => { + if (line.trim() === '') return + ctx.fillText(line, PREVIEW_LEFT_MARGIN, baseY + i * PREVIEW_LINE_HEIGHT) + }) + + ctx.fillStyle = BANNER_FILL + ctx.fillRect(0, 0, w, BANNER_HEIGHT) + + ctx.font = `${TITLE_FONT_SIZE}px sans-serif` + ctx.textBaseline = 'top' + ctx.fillStyle = ACTIVE_COLOR + const label = fitLabel((t) => ctx.measureText(t).width, truncateTitle(spec.title), w - 4) + drawCenteredText(ctx, label, w, 2) + + const ring = spec.ring ? RING_COLORS[spec.ring] : null + if (ring && spec.active) { + drawRing(ctx, w, h, ring, 3, 0) + drawRing(ctx, w, h, ACTIVE_COLOR, 2, 3) + } else if (ring) { + drawRing(ctx, w, h, ring, 4, 0) + } else if (spec.active) { + drawRing(ctx, w, h, ACTIVE_COLOR, 3, 0) + } +} + +function drawTab(ctx: Ctx2D, w: number, h: number, spec: Extract, getIcon: IconSource): void { + if (spec.style === 'preview') return drawPreviewTab(ctx, w, h, spec) + drawIconsTab(ctx, w, h, spec, getIcon) +} +``` + +Adapt `drawTab`/`drawIconsTab` parameter lists (`getIcon` etc.) to the file's current signatures; import `RingColor` as a type from `./frame`. `Ctx2D` needs no widening — the classic path only uses `fillRect`/`fillText`/`measureText`, so `VirtualDeckPanel`'s `noopCtx` is untouched. + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `npm run test:vitest -- run test/unit/client/deck/frame.test.ts test/unit/client/deck/tile-renderer.test.ts --config config/vitest/vitest.config.ts` +Expected: PASS. + +- [ ] **Step 7: Fix the remaining compile error, typecheck, commit** + +`deck-controller.ts` now fails typecheck (`buildFrame` requires `previewFor`). Add the minimal stub `previewFor: () => []` to its `buildFrame` call — Task 8 immediately replaces it with the real reader (this stub is a compile bridge inside this plan, not a deferred behavior; Task 8's tests prove the production path). + +Run: `npm run typecheck:client && npm run test:vitest -- run test/unit/client/deck/ --config config/vitest/vitest.config.ts` +Expected: clean + PASS. + +```bash +git add src/deck/frame.ts src/deck/tile-renderer.ts src/deck/deck-controller.ts test/unit/client/deck/frame.test.ts test/unit/client/deck/tile-renderer.test.ts +git commit -m "feat(deck): dual tile-style KeySpec with restored classic preview+ring render path" +``` + +--- + +### Task 7: Controller — style-gated capture polling and preview reads + +**Files:** +- Modify: `src/deck/deck-controller.ts` +- Test: `test/unit/client/deck/deck-controller.test.ts` + +**Interfaces:** +- Consumes: `getTerminalTextSnapshot` (Task 3), `findPaneContent` from `@/lib/pane-utils` (pre-removal import — verify the exact source module with `git show fb09f7e3^:src/deck/deck-controller.ts`), `FrameInputs.previewFor` (Task 6), `DeckTileStyle` (Task 1). +- Produces: `PREVIEW_REFRESH_TICKS = 6` (exported); `DeckControllerOptions.settings` return type gains `tileStyle: DeckTileStyle`; polling repaints only in `'terminal-previews'` style. + +- [ ] **Step 1: Update settings fixtures (compile-first)** + +In `test/unit/client/deck/deck-controller.test.ts`, the suite's settings fixture/thunk (the one its idle-dim tests customize) must gain `tileStyle: 'status-icons' as const`. Same for any other object satisfying the controller's settings type in this file. + +- [ ] **Step 2: Write the failing tests** + +Rename the existing test `'no periodic repaint: 3s of ticks paints nothing while the store is unchanged'` to scope it to the default style, and add the classic-mode pair. Model all three on the pre-removal test (`git show ef52f334^:test/unit/client/deck/deck-controller.test.ts` shows the original changing-reader idiom); use the file's `setup()` helper, passing `tileStyle` through its settings parameter the same way the idle tests pass `idleTimeoutSeconds`: + +```ts +import { registerTerminalTextReader } from '@/deck/terminal-text-registry' + + it('status-icons style: 3s of ticks paints nothing even when terminal text changes', () => { + // A CHANGING reader is what makes this RED if polling leaks into the new style. + let n = 0 + const unregister = registerTerminalTextReader('term-1', () => [`line ${n++}`]) + const { device } = setup({ tabCount: 1 }) + device.keyImages.clear() + vi.advanceTimersByTime(3_000) + expect(device.keyImages.size).toBe(0) + unregister() + }) + + it('terminal-previews style: changing terminal text repaints within PREVIEW_REFRESH_TICKS', () => { + let n = 0 + const unregister = registerTerminalTextReader('term-1', () => [`line ${n++}`]) + const { device } = setup({ tabCount: 1 }, /* settings override: */ { tileStyle: 'terminal-previews' }) + device.keyImages.clear() + vi.advanceTimersByTime(3_000) + expect(device.keyImages.size).toBeGreaterThan(0) + unregister() + }) + + it('terminal-previews style: static terminal text does not repaint on ticks', () => { + const unregister = registerTerminalTextReader('term-1', () => ['same line']) + const { device } = setup({ tabCount: 1 }, { tileStyle: 'terminal-previews' }) + device.keyImages.clear() + vi.advanceTimersByTime(3_000) + expect(device.keyImages.size).toBe(0) // spec JSON unchanged -> per-key diff skips + unregister() + }) +``` + +Adapt the `setup()` override plumbing to the helper's actual signature. Add `resetTerminalTextRegistryForTests()` to the suite's `afterEach` (the pre-removal suite did the same). + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `npm run test:vitest -- run test/unit/client/deck/deck-controller.test.ts --config config/vitest/vitest.config.ts` +Expected: FAIL — classic-mode test paints nothing (no polling exists yet). + +- [ ] **Step 4: Implement** + +In `src/deck/deck-controller.ts` (hand-merge; reference `git show fb09f7e3^:src/deck/deck-controller.ts` — do NOT reverse-apply the commit, it bundles IconImageCache work that must stay): + +```ts +import { findPaneContent } from '@/lib/pane-utils' +import { getTerminalTextSnapshot } from './terminal-text-registry' + +// Deliberate exception to the file's TIMING RULE: this is a refresh cadence, not a +// duration — under background setInterval throttling previews simply refresh slower. +export const PREVIEW_REFRESH_TICKS = 6 // previews re-checked every 3s +``` + +Options type: + +```ts + settings: () => { brightness: number; idleBrightness: number; idleTimeoutSeconds: number; tileStyle: DeckTileStyle } +``` + +Private members + methods: + +```ts + private tickCount = 0 + + private previewFor(state: RootState, tabId: string): string[] { + const paneId = state.panes.activePane[tabId] + const layout = state.panes.layouts[tabId] + if (!paneId || !layout) return [] + const content = findPaneContent(layout, paneId) + if (content && content.kind === 'terminal' && content.terminalId) { + return getTerminalTextSnapshot(content.terminalId) ?? [] + } + return [] + } + + private tick(): void { + this.dutyChecks() + if (this.settings().tileStyle !== 'terminal-previews') return + this.tickCount++ + if (this.tickCount % PREVIEW_REFRESH_TICKS === 0) this.repaint() // picks up xterm buffer changes + } +``` + +In `repaint()`, replace Task 6's `previewFor: () => []` stub with the real reader (using the method's local `state` variable): + +```ts + previewFor: (tabId) => this.previewFor(state, tabId), +``` + +Preserve the current `onStoreChange()` ordering: `probeRepoIcons()` stays BEFORE the model-JSON bail-out; preview reads happen only inside `repaint()` (i.e., after the bail-out) — restore the pre-removal ORDERING comment there: + +```ts + // ORDERING (load-bearing): compare the model JSON BEFORE any xterm buffer + // reads - previewFor is only invoked by repaint, which we skip entirely + // when the model is unchanged. +``` + +Production call sites (`deck-manager.ts:95-101`, `VirtualDeckPanel.tsx:82-88`) already pass the whole `streamDeck` settings object as the thunk — no changes needed there. + +- [ ] **Step 5: Run the controller suite + e2e compile check** + +Run: `npm run test:vitest -- run test/unit/client/deck/deck-controller.test.ts test/e2e/stream-deck-flow.test.tsx --config config/vitest/vitest.config.ts` +Expected: controller suite PASS. The e2e file fails to compile until its `defaultSettings()` gains `tileStyle: 'status-icons' as const` — make that one-line fixture edit here; all existing e2e scenarios must then PASS unchanged (the Deck+ strip test still reads `1 waiting` because its `attention: { t2: true }` seed satisfies the union). + +- [ ] **Step 6: Typecheck + commit** + +Run: `npm run typecheck:client` — expected clean. + +```bash +git add src/deck/deck-controller.ts test/unit/client/deck/deck-controller.test.ts test/e2e/stream-deck-flow.test.tsx +git commit -m "feat(deck): restore preview capture polling, gated to the terminal-previews tile style" +``` + +--- + +### Task 8: E2E — classic journey, live style switching, no-polling proof, strip union + +**Files:** +- Test: `test/e2e/stream-deck-flow.test.tsx` + +**Interfaces:** +- Consumes: everything above through the REAL store + REAL `DeckController` + `FakeDeckDevice`; `registerTerminalTextReader`/`resetTerminalTextRegistryForTests` (Task 3); the settings reducer's local-patch action. +- Produces: end-to-end proof of every user-facing requirement. + +- [ ] **Step 1: Confirm the settings patch action name** + +Open `src/store/settingsSlice.ts` (local-patch reducers ~:110-125) and note the exported action that applies a `LocalSettingsPatch` (prior survey identified `mergeLocalSettings`). Use that exact export below wherever `mergeLocalSettings` appears. + +- [ ] **Step 2: Write the failing tests** + +Add to `test/e2e/stream-deck-flow.test.tsx`. Imports: `registerTerminalTextReader`, `resetTerminalTextRegistryForTests` from `@/deck/terminal-text-registry`; the settings action from `@/store/settingsSlice`. Add `resetTerminalTextRegistryForTests()` to the existing `afterEach`. Add a live-settings setup variant next to `setup()`: + +```tsx +// Like setup(), but the controller reads settings live from the real store, +// so dispatching a settings patch changes controller behavior mid-session. +function setupLive(opts: DeckStoreOpts = {}, caps?: DeckCapabilities) { + const store = makeDeckStore(opts) + const device = new FakeDeckDevice(caps) + const controller = new DeckController({ + store: store as never, + device, + renderKey: (spec) => encodeSpec(spec), + renderStrip: (text) => new TextEncoder().encode(text) as unknown as Uint8ClampedArray, + settings: () => store.getState().settings.settings.streamDeck, + }) + controller.start() + activeController = controller + return { store, device, controller } +} +``` + +New describe: + +```tsx +describe('tile styles', () => { + it('classic style: tabs appear with titles, previews, and rings, in tab-bar order', () => { + registerTerminalTextReader('term-1', () => ['$ npm test', 'PASS']) + const { device } = setupLive({ tabs: 3, activeTab: 't1', busy: ['term-2'], attention: { t3: true } }) + // start in default style, flip to classic through the production settings path + // (or preload localSettings if makeDeckStore supports it — either is fine) + // ...dispatch happens in the switch test below; here seed classic directly: + activeController!.stop() + const { device: d2 } = setupLive({ tabs: 3, activeTab: 't1', busy: ['term-2'], attention: { t3: true }, tileStyle: 'terminal-previews' }) + expect(decodeKey(d2, 0)).toMatchObject({ + kind: 'tab', style: 'preview', tabId: 't1', + previewLines: ['$ npm test', 'PASS'], active: true, + }) + // tab-bar order, NOT attention-sorted: t3 (attention) stays on key 2 + expect(decodeKey(d2, 1)).toMatchObject({ tabId: 't2', ring: 'blue' }) + expect(decodeKey(d2, 2)).toMatchObject({ tabId: 't3', ring: 'green' }) + }) + + it('switching styles live repaints, reorders, and stops/starts polling — no reload', () => { + let n = 0 + registerTerminalTextReader('term-1', () => [`line ${n++}`]) + const { store, device } = setupLive({ tabs: 3, activeTab: 't1', attention: { t3: true } }) + // default: icons style, attention-sorted (t3 first) + expect(decodeKey(device, 0)).toMatchObject({ style: 'icons', tabId: 't3', fill: 'green' }) + + store.dispatch(mergeLocalSettings({ streamDeck: { tileStyle: 'terminal-previews' } })) + // live re-sort to tab-bar order + preview specs + expect(decodeKey(device, 0)).toMatchObject({ style: 'preview', tabId: 't1' }) + expect(decodeKey(device, 2)).toMatchObject({ style: 'preview', tabId: 't3', ring: 'green' }) + // polling is live: changing text repaints within 3s + const before = decodeKey(device, 0)! + vi.advanceTimersByTime(3_000) + expect(decodeKey(device, 0)).not.toEqual(before) + + store.dispatch(mergeLocalSettings({ streamDeck: { tileStyle: 'status-icons' } })) + // back to sorted icons style... + expect(decodeKey(device, 0)).toMatchObject({ style: 'icons', tabId: 't3' }) + // ...and polling stops: 3s of changing text paints nothing + device.keyImages.clear() + vi.advanceTimersByTime(3_000) + expect(device.keyImages.size).toBe(0) + }) + + it('mid-press style switch does not retarget the press', () => { + const { store, device } = setupLive({ tabs: 3, activeTab: 't1', attention: { t3: true } }) + // key 0 is t3 (sorted). Press down, flip style (re-sorts to tab-bar order), release. + device.emit({ type: 'keyDown', keyIndex: 0 }) + store.dispatch(mergeLocalSettings({ streamDeck: { tileStyle: 'terminal-previews' } })) + device.emit({ type: 'keyUp', keyIndex: 0 }) + expect(store.getState().tabs.activeTabId).toBe('t3') // press-snapshot guard holds across the flip + }) + + it('Deck+ strip counts waiting as attention OR pending approval, in both styles', () => { + const { store, device } = setupLive({ tabs: 2, freshAgentTab: 2, attention: { t1: true } }, PLUS_CAPS) + store.dispatch(addPermissionRequest({ + sessionId: 's1', sessionType: 'freshclaude', provider: 'claude', requestId: 'r1', + })) + expect(decodeStrip(device)).toContain('2 waiting') + store.dispatch(mergeLocalSettings({ streamDeck: { tileStyle: 'terminal-previews' } })) + expect(decodeStrip(device)).toContain('2 waiting') + }) +}) +``` + +Fixture adaptations required (make them, don't skip): (a) if `makeDeckStore` has no `tileStyle` opt, add one that preloads `settings.localSettings.streamDeck.tileStyle` (or dispatch `mergeLocalSettings` right after store creation — either way the first test must start classic BEFORE the controller's first paint, or assert post-dispatch state instead); (b) the `addPermissionRequest` import and `freshAgentTab` wiring already exist — copy from the existing test `'tile fill and dot track state changes'`; (c) `attention`/`busy` opt shapes come from the existing scenarios. + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `npm run test:vitest -- run test/e2e/stream-deck-flow.test.tsx --config config/vitest/vitest.config.ts` +Expected: the new describe FAILS only where fixtures are missing (e.g. no `tileStyle` opt) — everything behavioral should pass because Tasks 1-7 built the machinery. Fix fixture plumbing until the suite is green; if a BEHAVIORAL assertion fails, the corresponding earlier task has a bug — fix it there (with its unit test) rather than bending the e2e. + +- [ ] **Step 4: Run the whole deck surface** + +Run: `npm run test:vitest -- run test/unit/client/deck/ test/e2e/stream-deck-flow.test.tsx test/unit/client/components/VirtualDeckPanel.test.tsx --config config/vitest/vitest.config.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add test/e2e/stream-deck-flow.test.tsx +git commit -m "test(deck): e2e coverage for tile-style switching, classic previews, polling gate, strip waiting union" +``` + +--- + +### Task 9: README + full verification sweep + +**Files:** +- Modify: `README.md` (line 35 feature bullet; Stream Deck section lines 68-93) + +**Interfaces:** +- Consumes: final behavior from all prior tasks. +- Produces: accurate end-user docs; a green combined branch. + +- [ ] **Step 1: Update README** + +Replace the stale feature bullet at line 35 with: + +```markdown +- **Stream Deck** — Drive freshell from an Elgato Stream Deck: tabs on keys with repo icons and status backgrounds (or classic live previews and status rings), press to focus, long-press to approve or stop agents. See [Stream Deck](#stream-deck). +``` + +Replace the section intro paragraph (line 70) with: + +```markdown +Freshell can drive an Elgato Stream Deck straight from the browser. Each key shows a tab — by default the **Status icons** style: title on top, centered repo icons, and a status background (green for tabs that want attention), with keys sorted so attention-seeking tabs come first. Press a key to focus that tab; long-press (500 ms) to open an action layer with BACK / APPROVE / STOP keys (it closes itself after 10 s). When you have more tabs than keys, the last key pages through them (wrapping around). On a Stream Deck +, the dials cycle tabs and flip pages and the touch strip shows the active tab plus busy/waiting counts (waiting = tabs that finished a turn or are waiting for approval). The deck dims after a configurable idle timeout and wakes on activity. +``` + +Insert after the Virtual deck paragraph (after line 79): + +```markdown +**Tile style:** Settings → Stream Deck → **Tile style** switches between **Status icons** (the default, described above) and **Terminal previews** — the classic look with a title banner, a live mini terminal preview on each key, and colored status rings (blue busy, green needs-attention, amber waiting for approval), with keys in plain tab-bar order. Switching takes effect immediately, on the hardware deck and the virtual deck alike. +``` + +- [ ] **Step 2: Lint + typecheck** + +Run: `npm run lint && npm run typecheck:client` +Expected: clean. + +- [ ] **Step 3: Focused deck surface, then the coordinated suite** + +```bash +npm run test:vitest -- run test/unit/client/deck/ test/e2e/stream-deck-flow.test.tsx test/unit/client/components/ test/unit/shared/settings.stream-deck.test.ts --config config/vitest/vitest.config.ts +npm run test:status # check the coordinator gate; WAIT if another agent holds it — never kill a foreign holder +FRESHELL_TEST_SUMMARY="deck tile-style setting + strip waiting union" npm test +``` + +Expected: all green. If `npm test` surfaces failures outside the deck surface, fix only regressions this branch introduced. + +- [ ] **Step 4: Manual smoke note (no server restart!)** + +Optional visual check via the virtual deck (Settings → Stream Deck → Show virtual deck) in a dev client: toggle Tile style and watch tiles flip between icon tiles and preview tiles live. Do NOT restart anything on port 3002. + +- [ ] **Step 5: Commit** + +```bash +git add README.md +git commit -m "docs: describe the Stream Deck tile-style setting and strip waiting semantics" +``` + +--- + +## Verification Against Success Criteria + +- **Default behaves exactly like `feat/deck-tile-redesign` + waiting union:** default `'status-icons'` leaves the model/spec pipeline identical except additive fields; Task 7 proves zero polling; Task 5/8 prove the strip union. All pre-existing redesign tests keep running unmodified (only additive fixture fields). +- **Selecting Terminal previews restores previews + rings + tab-bar order with live polling; switching back stops polling:** Task 8's live-switch e2e + Task 7's unit gates, visible on the virtual deck (shares the controller/renderer). +- **Strip union in both styles:** Task 5 units (incl. count-once) + Task 8 e2e in both styles. +- **All tests green on the combined branch:** Task 9 coordinated sweep. From e5263dd5a530a94b547e358ade4f7ae75924e27d Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:41:42 -0700 Subject: [PATCH 19/30] docs(plan): apply load-bearing validation findings to deck-tile-modes plan - Task 8: mergeLocalSettings does not exist as an action; use updateSettingsLocal (verified synchronous re-resolution; sanity assertion added against the extractLegacyLocalSettingsSeed silent no-op trap) - Task 4: reducer state is immer-frozen; adopt the structuredClone withTileStyle idiom instead of direct mutation - Task 1: name the three streamDeck whitelists explicitly (silent-drop trap); record verified cross-window crossTabSync propagation - Task 2: record verified a11y-pattern evidence; note TabsView private SegmentedControl duplicate is out of scope --- docs/plans/2026-07-29-deck-tile-modes.md | 38 ++++++++++++++++-------- 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/docs/plans/2026-07-29-deck-tile-modes.md b/docs/plans/2026-07-29-deck-tile-modes.md index 6798ef8a1..054fdf248 100644 --- a/docs/plans/2026-07-29-deck-tile-modes.md +++ b/docs/plans/2026-07-29-deck-tile-modes.md @@ -147,6 +147,8 @@ Run: `grep -n "idleTimeoutSeconds" shared/settings.ts src/store/browserPreferenc Every line that mentions `idleTimeoutSeconds` is a site the new `tileStyle` field must also be added to (type, defaults, patch normalizer, resolve/compose/seed helpers around `shared/settings.ts:1298/:1389/:1451`, persistence builder). Keep the list; the round-trip test in Step 2 fails until all are covered. +Three of these sites are **explicit string whitelists that silently drop unknown streamDeck keys** (verified): the `pickKeys(raw.streamDeck, ['enabled','brightness','idleBrightness','idleTimeoutSeconds'])` list in `extractLegacyLocalSettingsSeed` (`shared/settings.ts` ~:1451-1456), the identical list in `normalizeExtractedLocalSeed` (~:631-648), and the per-field `buildLocalSettingsPatch` streamDeck section (`browserPreferencesPersistence.ts:145-152`). Missing any of them means `tileStyle` is stripped with no compile error — `updateSettingsLocal` dispatches silently no-op and cross-window sync never carries the field. The grep above catches all three; treat them as mandatory, not optional. (Cross-window note, verified: with these sites covered and `tileStyle` on `DeckModel` (Task 4), a style change in one browser window reaches a deck led by another window live via the existing `crossTabSync` receive path — no new sync machinery is needed; latency is the persistence layer's ~500 ms debounce.) + - [ ] **Step 2: Write the failing tests** Add to `test/unit/shared/settings.stream-deck.test.ts` (follow the file's existing imports/fixtures; it already tests defaults, resolve→`buildLocalSettingsPatch` round-trips, and the no-patch-at-defaults rule): @@ -271,7 +273,7 @@ Expected: FAIL — no `group` role, no such buttons. - [ ] **Step 3: Implement** -`settings-controls.tsx` — upgrade `SegmentedControl` (all existing call sites keep working; the new props are optional/behavior-preserving): +`settings-controls.tsx` — upgrade `SegmentedControl` (all existing call sites keep working; the new props are optional/behavior-preserving). Verified: existing call-site tests query `getByRole('button', { name })` only, which this upgrade preserves; the repo's jsx-a11y config has no rule demanding `radiogroup`/`aria-checked`, and `role="group"`+`aria-label` already passes lint elsewhere (`FreshAgentComposer.tsx:569`, `Pane.tsx:82`); `aria-pressed` toggle buttons are the documented WAI-ARIA APG Button pattern. (Note: `TabsView.tsx:297` has a private duplicate `SegmentedControl` that does NOT inherit this upgrade — out of scope, leave it alone.) ```tsx export function SegmentedControl({ @@ -488,7 +490,17 @@ In `test/unit/client/deck/deck-selectors.test.ts`: expect(model.tabs.filter((t) => t.pendingApproval)).toHaveLength(1) ``` -(b) New tests in the `selectDeckModel` describe (reuse the describe's existing state-builder — the same one its sort/stability tests use; set the tile style directly on the built state): +(b) New tests in the `selectDeckModel` describe (reuse the describe's existing state-builder — the same one its sort/stability tests use). Store/reducer-produced state is **deeply frozen** (RTK/immer auto-freeze — verified: direct mutation throws `Cannot assign to read only property`); do NOT assign onto the built state. Follow the file's own idiom — `withTabAttentionStyle` at `deck-selectors.test.ts:214-218` (`structuredClone`, mutate the clone, reselect) — by adding a sibling helper: + +```ts +function withTileStyle(state: RootState, tileStyle: DeckTileStyle): RootState { + const clone = structuredClone(state) as { settings: { settings: { streamDeck: { tileStyle: string } } } } + clone.settings.settings.streamDeck.tileStyle = tileStyle + return clone as unknown as RootState +} +``` + +(match `withTabAttentionStyle`'s exact typing style rather than this sketch): ```ts it('exposes the tile style on the model (default status-icons)', () => { @@ -497,8 +509,8 @@ In `test/unit/client/deck/deck-selectors.test.ts`: }) it('terminal-previews style keeps raw tab-bar order (no priority sort)', () => { - const state = /* the same state the existing sort test uses, where sorting reorders tabs */ - state.settings.settings.streamDeck.tileStyle = 'terminal-previews' + const base = /* the same state the existing sort test uses, where sorting reorders tabs */ + const state = withTileStyle(base, 'terminal-previews') // clone idiom — direct mutation throws (frozen state) const model = selectDeckModel(state) expect(model.tileStyle).toBe('terminal-previews') expect(model.tabs.map((t) => t.id)).toEqual(state.tabs.tabs.map((t) => t.id)) @@ -1082,13 +1094,13 @@ git commit -m "feat(deck): restore preview capture polling, gated to the termina - Consumes: everything above through the REAL store + REAL `DeckController` + `FakeDeckDevice`; `registerTerminalTextReader`/`resetTerminalTextRegistryForTests` (Task 3); the settings reducer's local-patch action. - Produces: end-to-end proof of every user-facing requirement. -- [ ] **Step 1: Confirm the settings patch action name** +- [ ] **Step 1: Note the settings patch action (verified)** -Open `src/store/settingsSlice.ts` (local-patch reducers ~:110-125) and note the exported action that applies a `LocalSettingsPatch` (prior survey identified `mergeLocalSettings`). Use that exact export below wherever `mergeLocalSettings` appears. +The production action is `updateSettingsLocal(payload: LocalSettingsPatch)` (`src/store/settingsSlice.ts:117-122`, exported ~:136) — NOT `mergeLocalSettings`, which is a pure helper imported from `@shared/settings`. Verified: its reducer recomputes the resolved `state.settings.settings` inline in the same dispatch, so asserting synchronously after `store.dispatch(updateSettingsLocal(...))` is valid — no settle step needed. One trap (verified): the payload is filtered through `extractLegacyLocalSettingsSeed` (`settingsSlice.ts:45-50`), so if Task 1 missed any of its whitelist sites the dispatch silently no-ops — the first new test below therefore includes a sanity assertion that the patch actually landed in `state.settings.localSettings`. - [ ] **Step 2: Write the failing tests** -Add to `test/e2e/stream-deck-flow.test.tsx`. Imports: `registerTerminalTextReader`, `resetTerminalTextRegistryForTests` from `@/deck/terminal-text-registry`; the settings action from `@/store/settingsSlice`. Add `resetTerminalTextRegistryForTests()` to the existing `afterEach`. Add a live-settings setup variant next to `setup()`: +Add to `test/e2e/stream-deck-flow.test.tsx`. Imports: `registerTerminalTextReader`, `resetTerminalTextRegistryForTests` from `@/deck/terminal-text-registry`; `updateSettingsLocal` from `@/store/settingsSlice`. Add `resetTerminalTextRegistryForTests()` to the existing `afterEach`. Add a live-settings setup variant next to `setup()`: ```tsx // Like setup(), but the controller reads settings live from the real store, @@ -1137,7 +1149,9 @@ describe('tile styles', () => { // default: icons style, attention-sorted (t3 first) expect(decodeKey(device, 0)).toMatchObject({ style: 'icons', tabId: 't3', fill: 'green' }) - store.dispatch(mergeLocalSettings({ streamDeck: { tileStyle: 'terminal-previews' } })) + store.dispatch(updateSettingsLocal({ streamDeck: { tileStyle: 'terminal-previews' } })) + // sanity: the patch survived the shared-settings whitelists (guards a silent no-op; see Step 1) + expect(store.getState().settings.settings.streamDeck.tileStyle).toBe('terminal-previews') // live re-sort to tab-bar order + preview specs expect(decodeKey(device, 0)).toMatchObject({ style: 'preview', tabId: 't1' }) expect(decodeKey(device, 2)).toMatchObject({ style: 'preview', tabId: 't3', ring: 'green' }) @@ -1146,7 +1160,7 @@ describe('tile styles', () => { vi.advanceTimersByTime(3_000) expect(decodeKey(device, 0)).not.toEqual(before) - store.dispatch(mergeLocalSettings({ streamDeck: { tileStyle: 'status-icons' } })) + store.dispatch(updateSettingsLocal({ streamDeck: { tileStyle: 'status-icons' } })) // back to sorted icons style... expect(decodeKey(device, 0)).toMatchObject({ style: 'icons', tabId: 't3' }) // ...and polling stops: 3s of changing text paints nothing @@ -1159,7 +1173,7 @@ describe('tile styles', () => { const { store, device } = setupLive({ tabs: 3, activeTab: 't1', attention: { t3: true } }) // key 0 is t3 (sorted). Press down, flip style (re-sorts to tab-bar order), release. device.emit({ type: 'keyDown', keyIndex: 0 }) - store.dispatch(mergeLocalSettings({ streamDeck: { tileStyle: 'terminal-previews' } })) + store.dispatch(updateSettingsLocal({ streamDeck: { tileStyle: 'terminal-previews' } })) device.emit({ type: 'keyUp', keyIndex: 0 }) expect(store.getState().tabs.activeTabId).toBe('t3') // press-snapshot guard holds across the flip }) @@ -1170,13 +1184,13 @@ describe('tile styles', () => { sessionId: 's1', sessionType: 'freshclaude', provider: 'claude', requestId: 'r1', })) expect(decodeStrip(device)).toContain('2 waiting') - store.dispatch(mergeLocalSettings({ streamDeck: { tileStyle: 'terminal-previews' } })) + store.dispatch(updateSettingsLocal({ streamDeck: { tileStyle: 'terminal-previews' } })) expect(decodeStrip(device)).toContain('2 waiting') }) }) ``` -Fixture adaptations required (make them, don't skip): (a) if `makeDeckStore` has no `tileStyle` opt, add one that preloads `settings.localSettings.streamDeck.tileStyle` (or dispatch `mergeLocalSettings` right after store creation — either way the first test must start classic BEFORE the controller's first paint, or assert post-dispatch state instead); (b) the `addPermissionRequest` import and `freshAgentTab` wiring already exist — copy from the existing test `'tile fill and dot track state changes'`; (c) `attention`/`busy` opt shapes come from the existing scenarios. +Fixture adaptations required (make them, don't skip): (a) if `makeDeckStore` has no `tileStyle` opt, add one that preloads `settings.localSettings.streamDeck.tileStyle` (or dispatch `updateSettingsLocal` right after store creation — either way the first test must start classic BEFORE the controller's first paint, or assert post-dispatch state instead); (b) the `addPermissionRequest` import and `freshAgentTab` wiring already exist — copy from the existing test `'tile fill and dot track state changes'`; (c) `attention`/`busy` opt shapes come from the existing scenarios. - [ ] **Step 3: Run tests to verify they fail** From 1302116c3b2f9ec9a58c63d42c44d3d13172fa42 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:56:53 -0700 Subject: [PATCH 20/30] =?UTF-8?q?docs(plan):=20fix=20Task=207=20Step=205?= =?UTF-8?q?=20e2e=20gate=20=E2=80=94=20account=20for=20style:'icons'=20bre?= =?UTF-8?q?aking=20strict=20toEqual=20assertions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fresh Eyes review found the 'all existing e2e scenarios PASS unchanged' gate unachievable: Task 6 adds style:'icons' to tab KeySpecs, which breaks three strict toEqual tab-spec expectations in test/e2e/stream-deck-flow.test.tsx that no task updated. Step 5 now instructs both fixture edits (defaultSettings tileStyle + the three toEqual expectations) and corrects the false 'fails to compile' prediction (tests are outside tsconfig include; signal is runtime assertion behavior). --- docs/plans/2026-07-29-deck-tile-modes.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/plans/2026-07-29-deck-tile-modes.md b/docs/plans/2026-07-29-deck-tile-modes.md index 054fdf248..33f816428 100644 --- a/docs/plans/2026-07-29-deck-tile-modes.md +++ b/docs/plans/2026-07-29-deck-tile-modes.md @@ -1072,7 +1072,12 @@ Production call sites (`deck-manager.ts:95-101`, `VirtualDeckPanel.tsx:82-88`) a - [ ] **Step 5: Run the controller suite + e2e compile check** Run: `npm run test:vitest -- run test/unit/client/deck/deck-controller.test.ts test/e2e/stream-deck-flow.test.tsx --config config/vitest/vitest.config.ts` -Expected: controller suite PASS. The e2e file fails to compile until its `defaultSettings()` gains `tileStyle: 'status-icons' as const` — make that one-line fixture edit here; all existing e2e scenarios must then PASS unchanged (the Deck+ strip test still reads `1 waiting` because its `attention: { t2: true }` seed satisfies the union). +Expected: controller suite PASS. The e2e file needs two small fixture edits here before its scenarios pass (test files are outside the tsconfig `include` and Vitest strips types, so neither omission fails compilation — the signal is runtime/assertion behavior, not a compile error): + +1. Add `tileStyle: 'status-icons' as const` to its `defaultSettings()` fixture. (Without it, `tileStyle` is `undefined` at runtime, which behaves as status-icons — make the edit anyway so the fixture matches the settings type.) +2. In the `'tabs appear on keys with titles, fills, dots, and icons'` scenario (~lines 198-209), add `style: 'icons'` to each of the three strict `toEqual` tab-spec expectations. Task 6's tab `KeySpec` now carries a `style` field, and strict `toEqual` fails on the extra property — without this edit those three assertions FAIL. + +After both edits, all existing e2e scenarios must PASS unchanged (the Deck+ strip test still reads `1 waiting` because its `attention: { t2: true }` seed satisfies the union). - [ ] **Step 6: Typecheck + commit** From be8f342a8ce53e0e222c2f28fa46e52dacc44876 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 10:20:05 -0700 Subject: [PATCH 21/30] feat(deck): streamDeck.tileStyle local setting - status-icons default, terminal-previews opt-in --- shared/settings.ts | 10 ++++++- src/store/browserPreferencesPersistence.ts | 1 + test/unit/shared/settings.stream-deck.test.ts | 29 +++++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/shared/settings.ts b/shared/settings.ts index 4ec9e7998..c0a7d8770 100644 --- a/shared/settings.ts +++ b/shared/settings.ts @@ -24,6 +24,7 @@ const OSC52_CLIPBOARD_VALUES = ['ask', 'always', 'never'] as const const TERMINAL_RENDERER_VALUES = ['auto', 'webgl', 'canvas'] as const const DEFAULT_NEW_PANE_VALUES = ['ask', 'shell', 'browser', 'editor'] as const const TAB_ATTENTION_STYLE_VALUES = ['highlight', 'pulse', 'darken', 'none'] as const +export const DECK_TILE_STYLE_VALUES = ['status-icons', 'terminal-previews'] as const const ATTENTION_DISMISS_VALUES = ['click', 'type'] as const const SESSION_OPEN_MODE_VALUES = ['tab', 'split'] as const const SIDEBAR_SORT_MODE_VALUES = ['recency', 'recency-pinned', 'activity', 'project'] as const @@ -95,6 +96,7 @@ export type Osc52ClipboardPolicy = (typeof OSC52_CLIPBOARD_VALUES)[number] export type TerminalRendererMode = (typeof TERMINAL_RENDERER_VALUES)[number] export type DefaultNewPane = (typeof DEFAULT_NEW_PANE_VALUES)[number] export type TabAttentionStyle = (typeof TAB_ATTENTION_STYLE_VALUES)[number] +export type DeckTileStyle = (typeof DECK_TILE_STYLE_VALUES)[number] export type AttentionDismiss = (typeof ATTENTION_DISMISS_VALUES)[number] export type SessionOpenMode = (typeof SESSION_OPEN_MODE_VALUES)[number] export type SidebarSortMode = (typeof SIDEBAR_SORT_MODE_VALUES)[number] @@ -225,6 +227,7 @@ export type LocalSettings = { brightness: number idleBrightness: number idleTimeoutSeconds: number + tileStyle: DeckTileStyle } } @@ -260,6 +263,7 @@ const Osc52ClipboardSchema = z.enum(OSC52_CLIPBOARD_VALUES) const TerminalRendererSchema = z.enum(TERMINAL_RENDERER_VALUES) const DefaultNewPaneSchema = z.enum(DEFAULT_NEW_PANE_VALUES) const TabAttentionStyleSchema = z.enum(TAB_ATTENTION_STYLE_VALUES) +const DeckTileStyleSchema = z.enum(DECK_TILE_STYLE_VALUES) const AttentionDismissSchema = z.enum(ATTENTION_DISMISS_VALUES) const SessionOpenModeSchema = z.enum(SESSION_OPEN_MODE_VALUES) const ExternalEditorSchema = z.enum(EXTERNAL_EDITOR_VALUES) @@ -642,6 +646,9 @@ function normalizeExtractedLocalSeed(patch: Record): LocalSetti if (typeof patch.streamDeck.idleTimeoutSeconds === 'number') { streamDeck.idleTimeoutSeconds = patch.streamDeck.idleTimeoutSeconds as number } + if (DeckTileStyleSchema.safeParse(patch.streamDeck.tileStyle).success) { + streamDeck.tileStyle = patch.streamDeck.tileStyle as DeckTileStyle + } if (Object.keys(streamDeck).length > 0) { normalized.streamDeck = streamDeck } @@ -897,6 +904,7 @@ export const defaultLocalSettings: LocalSettings = { brightness: 100, idleBrightness: 10, idleTimeoutSeconds: 300, + tileStyle: 'status-icons', }, } @@ -1452,7 +1460,7 @@ export function extractLegacyLocalSettingsSeed( maybeAssignNested( patch, 'streamDeck', - pickKeys(raw.streamDeck, ['enabled', 'brightness', 'idleBrightness', 'idleTimeoutSeconds']), + pickKeys(raw.streamDeck, ['enabled', 'brightness', 'idleBrightness', 'idleTimeoutSeconds', 'tileStyle']), ) } diff --git a/src/store/browserPreferencesPersistence.ts b/src/store/browserPreferencesPersistence.ts index ced1869c4..fc1cb2b7f 100644 --- a/src/store/browserPreferencesPersistence.ts +++ b/src/store/browserPreferencesPersistence.ts @@ -147,6 +147,7 @@ export function buildLocalSettingsPatch(localSettings: LocalSettings): LocalSett assignChangedScalar(streamDeck, localSettings.streamDeck, defaultLocalSettings.streamDeck, 'brightness') assignChangedScalar(streamDeck, localSettings.streamDeck, defaultLocalSettings.streamDeck, 'idleBrightness') assignChangedScalar(streamDeck, localSettings.streamDeck, defaultLocalSettings.streamDeck, 'idleTimeoutSeconds') + assignChangedScalar(streamDeck, localSettings.streamDeck, defaultLocalSettings.streamDeck, 'tileStyle') if (Object.keys(streamDeck).length > 0) { patch.streamDeck = streamDeck } diff --git a/test/unit/shared/settings.stream-deck.test.ts b/test/unit/shared/settings.stream-deck.test.ts index aa4cf2f88..659a672e6 100644 --- a/test/unit/shared/settings.stream-deck.test.ts +++ b/test/unit/shared/settings.stream-deck.test.ts @@ -15,6 +15,7 @@ describe('streamDeck local settings section', () => { brightness: 100, idleBrightness: 10, idleTimeoutSeconds: 300, + tileStyle: 'status-icons', }) }) @@ -27,6 +28,7 @@ describe('streamDeck local settings section', () => { brightness: 100, idleBrightness: 10, idleTimeoutSeconds: 60, + tileStyle: 'status-icons', }) const patch = buildLocalSettingsPatch(resolved) expect(patch.streamDeck).toEqual({ enabled: true, idleTimeoutSeconds: 60 }) @@ -52,3 +54,30 @@ describe('streamDeck local settings section', () => { expect(seed?.streamDeck).toEqual({ enabled: true, brightness: 80 }) }) }) + +describe('streamDeck.tileStyle', () => { + it('defaults to status-icons', () => { + expect(defaultLocalSettings.streamDeck.tileStyle).toBe('status-icons') + }) + + it('round-trips terminal-previews through patch normalization and persistence', () => { + const resolved = resolveLocalSettings({ + streamDeck: { tileStyle: 'terminal-previews' }, + }) + expect(resolved.streamDeck.tileStyle).toBe('terminal-previews') + const patch = buildLocalSettingsPatch(resolved) + expect(patch.streamDeck?.tileStyle).toBe('terminal-previews') + }) + + it('drops invalid tileStyle values during extraction', () => { + const seed = extractLegacyLocalSettingsSeed({ + streamDeck: { tileStyle: 'sparkly' }, + }) + expect(seed?.streamDeck?.tileStyle).toBeUndefined() + }) + + it('produces no persisted entry at the default value', () => { + const local = resolveLocalSettings({}) + expect(buildLocalSettingsPatch(local).streamDeck?.tileStyle).toBeUndefined() + }) +}) From 3a3d0a7a6ba171622e9c157e5353cddf073b27e0 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 10:26:21 -0700 Subject: [PATCH 22/30] test(deck): guard tileStyle extraction whitelists with positive-path assertion Add `tileStyle: 'terminal-previews'` to the 'survives the legacy seed normalizer' test fixture and assert it survives extraction/normalization. This positive-path guard prevents silent-strip failures if `tileStyle` is removed from the `pickKeys` whitelist in extractLegacyLocalSettingsSeed or the seed normalizer Zod block. Generated with Amplifier Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- test/unit/shared/settings.stream-deck.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/test/unit/shared/settings.stream-deck.test.ts b/test/unit/shared/settings.stream-deck.test.ts index 659a672e6..1968bfb89 100644 --- a/test/unit/shared/settings.stream-deck.test.ts +++ b/test/unit/shared/settings.stream-deck.test.ts @@ -49,9 +49,13 @@ describe('streamDeck local settings section', () => { it('survives the legacy seed normalizer (load path)', () => { const seed = extractLegacyLocalSettingsSeed({ - streamDeck: { enabled: true, brightness: 80 }, + streamDeck: { enabled: true, brightness: 80, tileStyle: 'terminal-previews' }, + }) + expect(seed?.streamDeck).toEqual({ + enabled: true, + brightness: 80, + tileStyle: 'terminal-previews', }) - expect(seed?.streamDeck).toEqual({ enabled: true, brightness: 80 }) }) }) From 3c286a8f323a88fbec3481f3c427439f6bf77511 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Wed, 29 Jul 2026 10:31:09 -0700 Subject: [PATCH 23/30] feat(deck): tile style setting UI with a11y-labeled segmented control --- .../settings/StreamDeckSettings.tsx | 20 +++++++++++++++++++ src/components/settings/settings-controls.tsx | 10 +++++++++- .../settings/StreamDeckSettings.test.tsx | 18 +++++++++++++++-- 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/src/components/settings/StreamDeckSettings.tsx b/src/components/settings/StreamDeckSettings.tsx index cf677bf78..ccabcf191 100644 --- a/src/components/settings/StreamDeckSettings.tsx +++ b/src/components/settings/StreamDeckSettings.tsx @@ -6,9 +6,11 @@ import { setVirtualDeckOpen, type DeckSliceState } from '@/store/deckSlice' import { requestDeckConnect } from '@/deck/deck-manager' import { isElectronClient, isWebHidSupported } from '@/lib/webhid-support' import type { SettingsSectionProps } from './settings-types' +import type { DeckTileStyle } from '../../../shared/settings' import { SettingsSection, SettingsRow, + SegmentedControl, SteppedRangeInput, Toggle, } from './settings-controls' @@ -74,6 +76,24 @@ export default function StreamDeckSettings({ /> + + { + const tileStyle = v as DeckTileStyle + applyLocalSetting({ streamDeck: { tileStyle } }) + }} + /> + + {connectAvailable && (