diff --git a/apps/streamdeck/README.md b/apps/streamdeck/README.md index c5607b60d..5fcfbec46 100644 --- a/apps/streamdeck/README.md +++ b/apps/streamdeck/README.md @@ -25,27 +25,38 @@ but degrades by model: ## Recommended layout (Stream Deck +) +A two-zone board bound by one shared selection: **Row 1 selects, Row 2 acts on +the selection, the dials review it.** + ``` ┌──────────────────────────────────────────────────────────┐ │ STREAM DECK + │ │ │ │ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │ -│ │ Approve│ │ Builder│ │ Dev │ │ Codev │ keys 1–4 │ -│ │ Gate ⓷ │ │ Action │ │ Server │ │ Action │ │ +│ │ Builder│ │ Builder│ │ Builder│ │ Builder│ Row 1: │ +│ │ slot 1 │ │ slot 2 │ │ slot 3 │ │ slot 4 │ selectors │ │ └────────┘ └────────┘ └────────┘ └────────┘ │ │ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │ -│ │ Builder│ │ Builder│ │ Builder│ │ Builder│ keys 5–8 │ -│ │ Action │ │ Action │ │ Action │ │ Action │ (pin 4) │ +│ │Approve │ │ Dev │ │Send Fb │ │ Open │ Row 2: │ +│ │ Gate │ │ Server │ │ (N) │ │ Term │ palette │ │ └────────┘ └────────┘ └────────┘ └────────┘ │ │ ┌──────────────────────────────────────────────┐ │ │ │ touch strip: each dial's title + live detail │ │ │ └──────────────────────────────────────────────┘ │ │ ◉ ◉ ◉ ◉ │ -│ Zoom Diff Diff PR 4 dials │ -│ Nav File Hunk Nav │ +│ Select Review Review Scroll 4 dials │ +│ (Zoom) Files Changes /PR │ └──────────────────────────────────────────────────────────┘ ``` +- **Row 1 — fleet selectors.** Four **Builder Action** keys, one per slot (1–4). + They are a **4-wide window** onto the fleet: with more than four builders the + **Select dial** (Zoom Navigator rotate) scrolls the window to builders 5–8, 9–N, + and the slot holding the current selection is accented. Press selects the builder + (Row 2 + the dials follow) and opens its phase artifact. +- **Row 2 — action palette**, fixed in place, always acting on the **selected** + builder: **Approve Gate · Dev Server · Send Feedback (N) · Open Terminal**. + Nothing is fixed — drag whatever you want onto each slot in the Stream Deck app. The 5th encoder, **Spawn from Backlog**, can swap onto a dial in place of any of the four above (e.g. replace PR Nav when you are triaging the backlog). @@ -54,19 +65,30 @@ the four above (e.g. replace PR Nav when you are triaging the backlog). ### Keys -- **Approve Gate** — badge shows the count of pending gates. Press surfaces the - next gate's **approval modal in the focused VSCode window** for you to confirm - (it never silently approves — the human stays in the loop). +- **Builder Action** (Row 1) — a live tile for a builder **slot**, but as a 4-wide + **window** onto the fleet, not a fixed index: slot N shows the Nth builder on the + current page, and the **Select dial** scrolls the page so a fleet larger than four + is fully reachable. It shows the builder's issue + phase, accents the slot holding + the selection, and on press selects the builder (Row 2 + the dials follow) and + opens its phase artifact. The default press verb is **Automatic** (the current + phase's spec / plan / diff); pick a fixed verb in the PI to always run that. +- **Approve Gate** (Row 2) — the **single** approve affordance. Acts on the + **selected** builder: the face shows its pending gate (e.g. `Plan · Approve`), and + press surfaces that gate's **approval modal in the focused VSCode window** for you + to confirm (it never silently approves). Inert when the selection isn't blocked. +- **Dev Server** (Row 2) — runs the dev server for the selected builder's worktree. +- **Send Feedback (N)** (Row 2) — flushes the **selected** builder's queued review + feedback. The badge `N` mirrors that builder's queued count from the overview: + in immediate mode `N` stays 0 and the key is inert; in queue mode `N` climbs and + a press sends the batch (VSCode's Submit Review). +- **Open Terminal** (Row 2) — opens the selected builder's terminal (the + per-builder complement to Builder Action, which opens the phase artifact). To + reach a blocked builder off the current window, scroll the Select dial — blocked + builders show gate-colored faces, and the Zoom dial's touchstrip shows the + workspace's pending-gate count. - **Codev Action** — fires a workspace verb. Choose it in the Property Inspector (Open Architect/Builder Terminal, View Diff, Send Message, Spawn Builder, Refresh Overview). Defaults to Refresh Overview. -- **Builder Action** — a live tile pinned to a builder **slot** (the Nth builder): - it shows that builder's issue + phase, and on press selects the builder (the - Zoom dials follow) and fires a verb. The default verb is **Automatic** — it opens - the artifact for the builder's current phase (spec / plan / diff), re-openable on - every press; pick a fixed verb in the PI to always run that instead. Pin one per - key for a live fleet board. -- **Dev Server** — runs the dev server for the builder the Zoom Navigator is on. Each dial's touch strip shows a **title + a live value**, refreshed over SSE: the Zoom Navigator shows the workspace (+ builder/gate counts) or the selected @@ -85,19 +107,29 @@ builder (+ its phase and position); PR / Spawn show the item + `i/N`. selected issue (VSCode prompts for the protocol). - **Review: Files / Headings** — phase-aware coarse review dial. The selected builder's phase picks the mode. *Diff phase* (implement / review, or blocked at - dev-approval / pr): rotate = next / previous file, **push** = forward the current - file to the builder, **tap** = jump to the first file. *Spec/plan phase* (specify / + dev-approval / pr): rotate = next / previous file, **push** = submit the current + file as feedback, **tap** = jump to the first file. *Spec/plan phase* (specify / plan, or blocked at spec-approval / plan-approval): rotate = next / previous heading in the artifact canvas, **push** = open the composer at the focused block, **tap** = - jump to the document top. The touch strip always names the live mode - (`Files` / `Headings`). + jump to the document top. The touch strip names the live mode and, in diff phase, + the delivery mode (`Files · send` vs `Files · queue`). - **Review: Changes / Blocks** — phase-aware fine review dial, same mode split. - *Diff phase*: rotate = next / previous change, **push** = forward the current change, - **tap** = jump to the first change. *Spec/plan phase*: rotate = next / previous - block, **push** = open the composer, **tap** = walk forward to the next commented - block. The touch strip names the live mode (`Changes` / `Blocks`). + *Diff phase*: rotate = next / previous change, **push** = submit the current change + as feedback, **tap** = jump to the first change. *Spec/plan phase*: rotate = next / + previous block, **push** = open the composer, **tap** = walk forward to the next + commented block. The touch strip names the live mode (`Changes · send` / + `Changes · queue`, or `Blocks`). - **Scroll** — rotate = scroll the focused editor's viewport (caret stays put); - **push** = forward the current selection to the builder. + **push** = submit the current selection as feedback. + +**Dials collect, keys commit.** A diff dial press submits a chunk via a +**mode-neutral** verb (`feedback-file` / `feedback-hunk` / `feedback-selection`); +VSCode routes it forward-now or into the queue per the workspace setting +`codev.diffCodelensMode` (`forward` = immediate, `comment` = queue). The touch strip +names that mode so a press is never a surprise, and **Send Feedback (N)** flushes a +queue. Feedback attaches to the builder whose diff is **focused** (the file in front +of you); Row 2 acts on the **selected** builder — the two are the same builder in +normal use (see *Reviewing specs & plans*). Verbs are stamped with the active workspace, so a single Tower serving several workspaces routes each command to the right one. @@ -112,14 +144,31 @@ renders no artifact content on the device): phase's artifact (spec / plan / diff) on press; or set a fixed **Open Spec / Open Plan / Open Review** verb to always open that one. Pressing it also selects the builder, so the **Zoom Navigator** and diff dials land on it. -- **Approve the gate** — **Approve Gate** badges pending gates (including - `plan-approval`) and, on press, surfaces that gate's approval modal in VSCode — - which carries a *View Plan* / *Run Dev* inspect button — for you to review and - confirm. It never auto-approves. -- **Forward a hunk/file for changes** — the diff-review verbs (`view-diff`, - `forward-file`, `forward-hunk`, `add-comment`) drive the same diff-injection the - VSCode sidebar uses, so a key can push a spec/plan/diff reference straight to the - builder's terminal. +- **Approve the gate** — **Approve Gate** acts on the selected builder and, on + press, surfaces that gate's approval modal in VSCode — which carries a *View Plan* + / *Run Dev* inspect button — for you to review and confirm. It never auto-approves. +- **Send a hunk/file as feedback** — a diff dial press (or the diff-review verbs + `feedback-file` / `feedback-hunk` / `feedback-selection`) submits a spec/plan/diff + reference; VSCode forwards it to the builder's terminal now, or queues it, per the + `codev.diffCodelensMode` setting. A queue is flushed with **Send Feedback**. + +**One shared selection binds the board.** Row 1/Row 2 act on the *selected* builder; +the review dials act on the *focused* artifact. Two things keep those the same +builder: a **Row 1 press is select + open in one gesture**, and **focusing a +builder's diff or canvas in VSCode moves the deck selection to it**. So the +selector, the palette, and the dials always point at one builder. + +> **Prerequisite for the VSCode→deck focus sync:** the back-sync rides a +> `builder-active` activity hook. Add one to your personal Codev config +> (`~/.codev/config.json`), pointing at the plugin's deep link: +> +> ```json +> { "activityHooks": [ { "on": ["builder-active"], +> "url": "streamdeck://plugins/message/com.cluesmith.codev/active?workspace={workspace}&builder={builder}" } ] } +> ``` +> +> Without it the deck still follows deck-driven selection (Row 1 press / Select +> dial); it just won't follow which window you click into in VSCode. ## Install & develop (sideload) diff --git a/apps/streamdeck/com.cluesmith.codev.sdPlugin/manifest.json b/apps/streamdeck/com.cluesmith.codev.sdPlugin/manifest.json index fcf0880ef..9b9e74915 100644 --- a/apps/streamdeck/com.cluesmith.codev.sdPlugin/manifest.json +++ b/apps/streamdeck/com.cluesmith.codev.sdPlugin/manifest.json @@ -36,7 +36,7 @@ { "Name": "Approve Gate", "UUID": "com.cluesmith.codev.approve-gate", - "Tooltip": "Badge of pending gates; press to surface the next gate's review for confirmation in VSCode.", + "Tooltip": "Acts on the selected builder: shows its pending gate; press to surface that gate's review for confirmation in VSCode.", "Icon": "icons/list/approve-gate", "Controllers": [ "Keypad" @@ -48,6 +48,36 @@ } ] }, + { + "Name": "Send Feedback", + "UUID": "com.cluesmith.codev.send-queue", + "Tooltip": "Flush the selected builder's queued review feedback. Badge shows the queued count; inert when nothing is queued (or the workspace forwards immediately).", + "Icon": "icons/list/action", + "Controllers": [ + "Keypad" + ], + "States": [ + { + "Image": "icons/action", + "TitleAlignment": "bottom" + } + ] + }, + { + "Name": "Open Terminal", + "UUID": "com.cluesmith.codev.open-terminal", + "Tooltip": "Open the selected builder's terminal — the per-builder complement to Builder Action's open-artifact.", + "Icon": "icons/list/action", + "Controllers": [ + "Keypad" + ], + "States": [ + { + "Image": "icons/action", + "TitleAlignment": "bottom" + } + ] + }, { "Name": "Codev Action", "UUID": "com.cluesmith.codev.action", diff --git a/apps/streamdeck/src/__tests__/actions.test.ts b/apps/streamdeck/src/__tests__/actions.test.ts index 097d844e0..329c7979a 100644 --- a/apps/streamdeck/src/__tests__/actions.test.ts +++ b/apps/streamdeck/src/__tests__/actions.test.ts @@ -6,6 +6,8 @@ import { DevServerAction, BuilderAction, ApproveGate, + SendQueueAction, + OpenTerminalAction, PrNav, SpawnNav, DiffFileNav, @@ -205,56 +207,171 @@ describe('slot keys', () => { }); }); -describe('ApproveGate', () => { - it('fires approve-gate for the top blocked builder', async () => { - const ctx = makeStore(); +describe('ApproveGate (Row 2 — selected-scoped, #1410)', () => { + it('fires approve-gate for the SELECTED builder (not the top pending gate)', async () => { + const ctx = makeStore(); // selection defaults to pir-1 (blocked at plan-approval) await new ApproveGate(ctx.store).onKeyDown(keyEvent() as never); expect(ctx.sent[0]).toEqual({ verb: 'approve-gate', args: ['pir-1'], ws: '/work/alpha' }); }); - it('alerts and sends nothing when no gate is pending', async () => { + it('acts on whoever is selected: after selecting pir-2 it would target pir-2', async () => { const ctx = makeStore(); - ctx.store.overview!.builders = ctx.store.overview!.builders.map((b) => ({ ...b, blocked: null })); + // Block pir-2 too, then select it — the key must follow the selection, not pir-1. + ctx.store.overview!.builders[1] = { ...ctx.store.overview!.builders[1], blockedGate: 'dev-approval' } as never; + ctx.store.syncToBuilder('pir-2'); + await new ApproveGate(ctx.store).onKeyDown(keyEvent() as never); + expect(ctx.sent[0]).toEqual({ verb: 'approve-gate', args: ['pir-2'], ws: '/work/alpha' }); + }); + + it('alerts and sends nothing when the selected builder is not blocked at a gate', async () => { + const ctx = makeStore(); + ctx.store.syncToBuilder('pir-2'); // pir-2 is not blocked (blockedGate: null) const ev = keyEvent(); await new ApproveGate(ctx.store).onKeyDown(ev as never); expect(ctx.sent).toHaveLength(0); expect(ev.action.showAlert).toHaveBeenCalled(); }); - it('renders a composite Gates face (count + label under a bell), not a title over the icon', () => { - const ctx = makeStore(); // pir-1 blocked → 1 pending gate + it('renders the selected builder’s pending gate on the Approve face', () => { + const ctx = makeStore(); // pir-1 selected, blocked at plan-approval const key = { id: 'G', isKey: () => true, setImage: vi.fn(), setTitle: vi.fn() }; new ApproveGate(ctx.store).onWillAppear({ action: key, payload: {} } as never); const arg = String(key.setImage.mock.calls.at(-1)?.[0] ?? ''); expect(arg.startsWith('data:image/svg+xml;base64,')).toBe(true); const face = Buffer.from(arg.slice('data:image/svg+xml;base64,'.length), 'base64').toString('utf8'); - expect(face).toContain('Gates'); - expect(face).toContain('>1<'); // the pending count - expect(face).toContain('#cca700'); // pending → warning yellow + expect(face).toContain('Plan'); // the selected builder's gate label + expect(face).toContain('Approve'); // the action band + expect(face).toContain('#cca700'); // blocked → warning yellow expect(key.setTitle).toHaveBeenCalledWith(''); // title layer suppressed }); }); +describe('SendQueueAction (Row 2 — flush, #1410)', () => { + it('badges the selected builder’s queued count and flushes on press', async () => { + const ctx = makeStore(); + ctx.store.overview = { ...ctx.store.overview!, queuedFeedback: { 'pir-1': 3 } } as never; + const key = { id: 'S', isKey: () => true, setImage: vi.fn(), setTitle: vi.fn(), showOk: vi.fn(), showAlert: vi.fn() }; + const action = new SendQueueAction(ctx.store); + action.onWillAppear({ action: key, payload: {} } as never); + const face = Buffer.from(String(key.setImage.mock.calls.at(-1)?.[0]).split(',')[1], 'base64').toString('utf8'); + expect(face).toContain('Send Fb'); + expect(face).toContain('>3<'); // badge + await action.onKeyDown({ action: key, payload: { settings: {} } } as never); + expect(ctx.sent[0]).toEqual({ verb: 'send-queue', args: ['pir-1'], ws: '/work/alpha' }); + }); + + it('is inert (alerts, sends nothing) when the selected builder has no queued feedback', async () => { + const ctx = makeStore(); // no queuedFeedback on the fixture → 0 + const key = { id: 'S', isKey: () => true, setImage: vi.fn(), setTitle: vi.fn(), showOk: vi.fn(), showAlert: vi.fn() }; + await new SendQueueAction(ctx.store).onKeyDown({ action: key, payload: { settings: {} } } as never); + expect(ctx.sent).toHaveLength(0); + expect(key.showAlert).toHaveBeenCalled(); + }); +}); + +describe('OpenTerminalAction (Row 2 — per-builder, #1410)', () => { + it('opens the selected builder’s terminal', async () => { + const ctx = makeStore(); + ctx.store.syncToBuilder('pir-2'); + await new OpenTerminalAction(ctx.store).onKeyDown(keyEvent() as never); + expect(ctx.sent[0]).toEqual({ verb: 'open-terminal', args: ['pir-2'], ws: '/work/alpha' }); + }); + + it('renders a plain label face (terminal glyph + "Terminal"), not the builder id', () => { + const ctx = makeStore(); + ctx.store.syncToBuilder('pir-2'); // issueId 102 + const key = { isKey: () => true, setImage: vi.fn(), setTitle: vi.fn() }; + new OpenTerminalAction(ctx.store).onWillAppear({ action: key, payload: { settings: {} } } as never); + const face = Buffer.from(String(key.setImage.mock.calls.at(-1)?.[0]).split(',')[1], 'base64').toString('utf8'); + expect(face).toContain('Terminal'); + expect(face).toContain('rect x="3" y="5"'); // the terminal glyph + expect(face).not.toContain('#102'); // the builder id lives on Row 1's accent, not here + expect(key.setTitle).toHaveBeenCalledWith(''); // title layer suppressed + }); +}); + +describe('Row 1 windowing (#1410)', () => { + /** A store with `n` builders (ids builder-0..builder-(n-1)), selection at `cursor`. */ + function windowedStore(n: number, cursor: number) { + const ctx = makeStore(); + ctx.store.overview = { + builders: Array.from({ length: n }, (_, i) => ({ + id: `b${i}`, roleId: `builder-b${i}`, issueId: String(100 + i), issueTitle: `Task ${i}`, + blocked: null, blockedGate: null, protocolPhase: 'implement', progress: 0, worktreePath: `/w/b${i}`, + })), + pendingPRs: [], backlog: [], recentlyClosed: [], + } as never; + ctx.store.cursor = { ...ctx.store.cursor, builder: cursor, level: 'builders' }; + return ctx.store; + } + + it('slot i shows builder i on page 0 (first four)', () => { + const store = windowedStore(10, 0); + expect([0, 1, 2, 3].map((i) => store.windowedBuilder(i)?.id)).toEqual(['b0', 'b1', 'b2', 'b3']); + }); + + it('scrolls a page when the selection moves past the fourth builder', () => { + const store = windowedStore(10, 4); // selection on b4 → page 1 + expect([0, 1, 2, 3].map((i) => store.windowedBuilder(i)?.id)).toEqual(['b4', 'b5', 'b6', 'b7']); + }); + + it('trailing slots are empty on a partial last page', () => { + const store = windowedStore(10, 9); // selection on b9 → page 2 (b8, b9, -, -) + expect([0, 1, 2, 3].map((i) => store.windowedBuilder(i)?.id)).toEqual(['b8', 'b9', undefined, undefined]); + }); + + it('BuilderAction renders the windowed builder and accents the selected slot', () => { + const store = windowedStore(10, 5); // page 1: slots show b4..b7; b5 is selected (slot 1) + const render = (slot: string) => { + const key = { id: `k${slot}`, isKey: () => true, setImage: vi.fn(), setTitle: vi.fn() }; + new BuilderAction(store).onWillAppear({ action: key, payload: { settings: { slot } } } as never); + return Buffer.from(String(key.setImage.mock.calls.at(-1)?.[0]).split(',')[1], 'base64').toString('utf8'); + }; + expect(render('1')).toContain('#104'); // slot 1 → b4 (issueId 104) + expect(render('2')).toContain('#105'); // slot 2 → b5 (selected) + expect(render('2')).toContain('stroke-width="3"'); // selected accent ring + expect(render('1')).not.toContain('stroke-width="3"'); // unselected slot has no ring + }); +}); + +describe('store readers (#1410)', () => { + it('feedbackMode defaults to forward, reads queue from the overview', () => { + const ctx = makeStore(); + expect(ctx.store.feedbackMode()).toBe('forward'); + ctx.store.overview = { ...ctx.store.overview!, feedbackMode: 'queue' } as never; + expect(ctx.store.feedbackMode()).toBe('queue'); + }); + + it('queuedFeedback reads the per-builder map, 0 when absent', () => { + const ctx = makeStore(); + expect(ctx.store.queuedFeedback('pir-1')).toBe(0); + ctx.store.overview = { ...ctx.store.overview!, queuedFeedback: { 'pir-1': 5 } } as never; + expect(ctx.store.queuedFeedback('pir-1')).toBe(5); + expect(ctx.store.queuedFeedback('pir-2')).toBe(0); + expect(ctx.store.queuedFeedback(undefined)).toBe(0); + }); +}); + describe('encoders', () => { - it('DiffFileNav in diff mode: rotate navigates, press forwards the file, touch jumps to first', async () => { + it('DiffFileNav in diff mode: rotate navigates, press submits feedback, touch jumps to first', async () => { const ctx = makeStore(); ctx.store.syncToBuilder('pir-2'); // implement phase → diff mode const nav = new DiffFileNav(ctx.store); await nav.onDialRotate(dial(1) as never); // next await nav.onDialRotate(dial(-2) as never); // prev - await nav.onDialDown(); // forward + await nav.onDialDown(); // submit feedback (mode-neutral) await nav.onTouchTap(); // first - expect(ctx.sent.map((s) => s.verb)).toEqual(['diff-next-file', 'diff-prev-file', 'forward-file', 'diff-first-file']); + expect(ctx.sent.map((s) => s.verb)).toEqual(['diff-next-file', 'diff-prev-file', 'feedback-file', 'diff-first-file']); expect(ctx.sent.every((s) => s.ws === '/work/alpha')).toBe(true); expect(ctx.canvasSent).toHaveLength(0); // diff mode never touches the canvas channel }); - it('Diff dials forward their axis on a dial press (diff mode)', async () => { + it('Diff dials submit their axis as feedback on a dial press (diff mode, #1410)', async () => { const ctx = makeStore(); ctx.store.syncToBuilder('pir-2'); // implement phase → diff mode await new DiffFileNav(ctx.store).onDialDown(); await new DiffHunkNav(ctx.store).onDialDown(); - expect(ctx.sent.map((s) => s.verb)).toEqual(['forward-file', 'forward-hunk']); + expect(ctx.sent.map((s) => s.verb)).toEqual(['feedback-file', 'feedback-hunk']); }); it('canvas mode: coarse dial rotates headings (count = |ticks|), press cancels composer, tap resets to doc start', async () => { @@ -341,7 +458,7 @@ describe('encoders', () => { expect(ctx.canvasSent).toHaveLength(0); }); - it('ScrollNav scrolls the editor on rotate and forwards the selection on press', async () => { + it('ScrollNav scrolls the editor on rotate and submits the selection as feedback on press', async () => { const ctx = makeStore(); const nav = new ScrollNav(ctx.store); await nav.onDialRotate(dial(1) as never); // down @@ -349,7 +466,7 @@ describe('encoders', () => { await nav.onDialDown(); expect(ctx.sent[0]).toEqual({ verb: 'scroll', args: [{ to: 'down', by: 'line', value: 3, revealCursor: false }], ws: '/work/alpha' }); expect((ctx.sent[1].args[0] as { to: string }).to).toBe('up'); - expect(ctx.sent[2]).toEqual({ verb: 'forward-selection', args: [], ws: '/work/alpha' }); + expect(ctx.sent[2]).toEqual({ verb: 'feedback-selection', args: [], ws: '/work/alpha' }); }); it('PrNav opens the selected PR url on press', async () => { @@ -678,15 +795,24 @@ describe('ZoomNav zoom gesture', () => { expect(hunkAction.setFeedback).toHaveBeenCalledWith({ title: 'Blocks · Open/Submit', value: '#101 Add the relay', bar: 45 }); }); - it('legibility: diff-phase builder titles the dials Files/Changes', () => { - const ctx = makeStore(); + it('legibility: diff-phase builder titles the dials with axis + delivery mode (Files · send)', () => { + const ctx = makeStore(); // no feedbackMode on the fixture → defaults to forward → "send" ctx.store.syncToBuilder('pir-2'); // implement phase → diff mode (#102, "Wire the dial", 70%) const fileAction = { isDial: () => true, setFeedback: vi.fn() }; const hunkAction = { isDial: () => true, setFeedback: vi.fn() }; new DiffFileNav(ctx.store).onWillAppear({ action: fileAction, payload: {} } as never); new DiffHunkNav(ctx.store).onWillAppear({ action: hunkAction, payload: {} } as never); - expect(fileAction.setFeedback).toHaveBeenCalledWith({ title: 'Files', value: '#102 Wire the dial', bar: 70 }); - expect(hunkAction.setFeedback).toHaveBeenCalledWith({ title: 'Changes', value: '#102 Wire the dial', bar: 70 }); + expect(fileAction.setFeedback).toHaveBeenCalledWith({ title: 'Files · send', value: '#102 Wire the dial', bar: 70 }); + expect(hunkAction.setFeedback).toHaveBeenCalledWith({ title: 'Changes · send', value: '#102 Wire the dial', bar: 70 }); + }); + + it('legibility: the diff touchstrip names the queue mode when the workspace queues (Files · queue)', () => { + const ctx = makeStore(); + ctx.store.overview = { ...ctx.store.overview!, feedbackMode: 'queue' } as never; + ctx.store.syncToBuilder('pir-2'); // diff mode + const action = { isDial: () => true, setFeedback: vi.fn() }; + new DiffFileNav(ctx.store).onWillAppear({ action, payload: {} } as never); + expect(action.setFeedback.mock.calls.at(-1)?.[0]).toMatchObject({ title: 'Files · queue' }); }); it('legibility: the dial re-titles when the selection moves between modes', () => { @@ -696,7 +822,7 @@ describe('ZoomNav zoom gesture', () => { nav.onWillAppear({ action, payload: {} } as never); // pir-1 → canvas expect(action.setFeedback.mock.calls.at(-1)?.[0]).toMatchObject({ title: 'Headings · Cancel' }); ctx.store.syncToBuilder('pir-2'); // → diff; onChange re-renders - expect(action.setFeedback.mock.calls.at(-1)?.[0]).toMatchObject({ title: 'Files' }); + expect(action.setFeedback.mock.calls.at(-1)?.[0]).toMatchObject({ title: 'Files · send' }); }); it('clears the previous workspace overview immediately on switch (no stale flash)', () => { diff --git a/apps/streamdeck/src/__tests__/face.test.ts b/apps/streamdeck/src/__tests__/face.test.ts index 863a17835..bad1be295 100644 --- a/apps/streamdeck/src/__tests__/face.test.ts +++ b/apps/streamdeck/src/__tests__/face.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import type { OverviewBuilder } from '@cluesmith/codev-sdk/controller'; -import { builderState, stateLabel, faceForBuilder, builderFaceSvg, gatesFaceSvg, labelFaceSvg, svgToDataUri } from '../face.js'; +import { builderState, stateLabel, faceForBuilder, builderFaceSvg, approveFaceSvg, sendFbFaceSvg, labelFaceSvg, svgToDataUri } from '../face.js'; /** Minimal builder fixture — only the fields the face reads matter; the rest are filler. */ function builder(over: Partial): OverviewBuilder { @@ -110,17 +110,40 @@ describe('builderFaceSvg', () => { }); }); -describe('gatesFaceSvg', () => { - it('shows the pending count + "Gates" in warning yellow when gates await approval', () => { - const svg = gatesFaceSvg(3); - expect(svg).toContain('>3<'); - expect(svg).toContain('Gates'); +describe('approveFaceSvg (#1410)', () => { + it('shows the selected builder’s gate label over an Approve band, warning-tinted, when blocked', () => { + const svg = approveFaceSvg({ blockedGate: 'plan-approval' }); + expect(svg).toContain('Plan'); + expect(svg).toContain('Approve'); expect(svg).toContain('#cca700'); }); - it('shows just "Gates" (dim, no count) when none are pending', () => { - const svg = gatesFaceSvg(0); - expect(svg).toContain('Gates'); - expect(svg).not.toContain('#cca700'); + it('is dim + inert (just "Approve") when the selected builder is not blocked / none selected', () => { + expect(approveFaceSvg({ blockedGate: null })).toContain('Approve'); + expect(approveFaceSvg({ blockedGate: null })).not.toContain('#cca700'); + expect(approveFaceSvg(undefined)).toContain('Approve'); + }); +}); + +describe('sendFbFaceSvg (#1410)', () => { + it('shows the queued count + "Send Fb" in active green when there is feedback to send', () => { + const svg = sendFbFaceSvg(4); + expect(svg).toContain('>4<'); + expect(svg).toContain('Send Fb'); + expect(svg).toContain('#73c991'); // active green + }); + it('is dim + inert (just "Send Fb", no count) when nothing is queued', () => { + const svg = sendFbFaceSvg(0); + expect(svg).toContain('Send Fb'); + expect(svg).not.toContain('#73c991'); + }); +}); + +describe('builder face selection accent (#1410)', () => { + it('draws an accent ring only when the slot holds the selection', () => { + const selected = builderFaceSvg(faceForBuilder(builder({ issueId: '1' }), true)); + const plain = builderFaceSvg(faceForBuilder(builder({ issueId: '1' }), false)); + expect(selected).toContain('stroke-width="3"'); + expect(plain).not.toContain('stroke-width="3"'); }); }); diff --git a/apps/streamdeck/src/actions.ts b/apps/streamdeck/src/actions.ts index 93288e134..97ccbe036 100644 --- a/apps/streamdeck/src/actions.ts +++ b/apps/streamdeck/src/actions.ts @@ -15,7 +15,7 @@ import type { CanvasCommandClientErrorCode, } from '@cluesmith/codev-sdk/controller'; import type { CodevStore } from './store.js'; -import { builderFaceSvg, faceForBuilder, gatesFaceSvg, labelFaceSvg, svgToDataUri } from './face.js'; +import { approveFaceSvg, builderFaceSvg, faceForBuilder, labelFaceSvg, sendFbFaceSvg, svgToDataUri } from './face.js'; /** * The Stream Deck actions — thin adapters over CodevStore. Each maps a physical @@ -82,16 +82,23 @@ export class DevServerAction extends VerbKey { } } + // ── Slot keys (pinned builder board) ──────────────────────────────────────── /** PI settings shared by the slot-based keys: a 1-based builder slot + a verb. */ type SlotSettings = { slot?: string; verb?: string }; -/** Resolve the builder a slot points at: slot N → the Nth builder (overview order). */ +/** + * Resolve the builder a slot points at. Slot N is a POSITION in Row 1's 4-wide + * window onto the fleet (#1410), not an absolute index: the window is the page + * containing the selection, so the Select dial scrolls builders 5-8, 9-N into the + * same four keys. A slot past the end of the fleet resolves to `undefined` (an + * empty slot on the last page). + */ function slotBuilder(store: CodevStore, settings: SlotSettings): OverviewBuilder | undefined { const slot = Number.parseInt(settings.slot ?? '1', 10); - const index = (Number.isFinite(slot) && slot > 0 ? slot : 1) - 1; - return store.builders()[index]; + const slotIndex = (Number.isFinite(slot) && slot > 0 ? slot : 1) - 1; + return store.windowedBuilder(slotIndex); } /** @@ -179,7 +186,10 @@ export class BuilderAction extends SlotKey { // colour/icon vocabulary. setTitle('') suppresses the SDK title layer so nothing overlays it. let svg: string; if (b) { - svg = builderFaceSvg(faceForBuilder(b)); + // Accent the slot holding the shared selection so the live builder among + // the four is unmistakable (#1410). + const selected = b.id === this.store.selectedBuilder()?.id; + svg = builderFaceSvg(faceForBuilder(b, selected)); } else { svg = builderFaceSvg({ kind: 'empty', slot: settings.slot ?? '1' }); } @@ -189,9 +199,13 @@ export class BuilderAction extends SlotKey { } /** - * Approve-gate key: a read-only badge of the pending-gate count, and on press a - * jump-to-review — it fires `approve-gate` for the top pending gate, which the - * provider surfaces as a confirmation modal (it does NOT silently approve). + * Row-2 Approve key (#1410): the SINGLE approve affordance on the deck. It acts on + * the SELECTED builder — press relays `approve-gate [selectedId]`, which the + * provider surfaces as a confirmation modal (it does NOT silently approve). The + * face shows the selected builder's pending gate (e.g. `Plan · Approve`) when it is + * blocked, and is inert otherwise. The former standalone top-gate singleton is + * retired: the fleet-wide pending-gate count + jump-to-next now live on + * `NextAttentionAction`, so there are never two approve keys with different targets. */ export class ApproveGate extends SingletonAction { override readonly manifestId = 'com.cluesmith.codev.approve-gate'; @@ -211,26 +225,88 @@ export class ApproveGate extends SingletonAction { this.keys.delete(ev.action.id); } override async onKeyDown(ev: KeyDownEvent): Promise { - const id = this.store.topGateBuilderId(); - if (!id) { + const b = this.store.selectedBuilder(); + // Only a builder blocked at a gate is approvable; otherwise the key is inert + // (no pointless relay that the provider would just reject). + if (!b || !b.blockedGate) { + await ev.action.showAlert(); + return; + } + const res = await this.store.client.sendCommand('approve-gate', [b.id], this.store.selectedWorkspacePath()); + await ack(ev.action, res.ok); + } + private renderAll(): void { + for (const action of this.keys.values()) this.renderTo(action); + } + private renderTo(action: KeyAction): void { + void action.setImage(svgToDataUri(approveFaceSvg(this.store.selectedBuilder()))); + void action.setTitle(''); + } +} + +/** + * Row-2 Send Fb key (#1410): flushes the SELECTED builder's queued review + * feedback. The badge `N` mirrors the per-builder queued count from the overview + * (`store.queuedFeedback`). Press relays `send-queue [selectedId]` (VSCode's + * batched Submit Review) when `N > 0`; inert at 0 — in immediate mode nothing ever + * queues so `N` stays 0 and the key never sends (no deck-side mode inference). + */ +export class SendQueueAction extends SingletonAction { + override readonly manifestId = 'com.cluesmith.codev.send-queue'; + private readonly keys = new Map(); + + constructor(private readonly store: CodevStore) { + super(); + this.store.onChange(() => this.renderAll()); + } + + override onWillAppear(ev: WillAppearEvent): void { + if (!ev.action.isKey()) return; + this.keys.set(ev.action.id, ev.action); + this.renderTo(ev.action); + } + override onWillDisappear(ev: WillDisappearEvent): void { + this.keys.delete(ev.action.id); + } + override async onKeyDown(ev: KeyDownEvent): Promise { + const b = this.store.selectedBuilder(); + if (!b || this.store.queuedFeedback(b.id) <= 0) { await ev.action.showAlert(); return; } - const res = await this.store.client.sendCommand('approve-gate', [id], this.store.selectedWorkspacePath()); + const res = await this.store.client.sendCommand('send-queue', [b.id], this.store.selectedWorkspacePath()); await ack(ev.action, res.ok); } private renderAll(): void { for (const action of this.keys.values()) this.renderTo(action); } private renderTo(action: KeyAction): void { - const n = this.store.pendingGates().length; - // Composite SVG face (same fix as BuilderAction): count + label in a reserved band under the - // bell icon, instead of a title stacked over the manifest PNG. - void action.setImage(svgToDataUri(gatesFaceSvg(n))); + void action.setImage(svgToDataUri(sendFbFaceSvg(this.store.queuedFeedback(this.store.selectedBuilder()?.id)))); void action.setTitle(''); } } +/** + * Row-2 Open Terminal key (#1410): opens the SELECTED builder's terminal — the + * per-builder complement to the Builder Action (which opens the phase artifact). + * Same shape as the Dev Server key: a `VerbKey` firing `open-terminal [selectedId]` + * with a static label face (terminal glyph + `Terminal`). Which builder is selected + * is shown by Row 1's accent, not repeated here. + */ +export class OpenTerminalAction extends VerbKey { + override readonly manifestId = 'com.cluesmith.codev.open-terminal'; + protected readonly defaultVerb = 'open-terminal'; + protected override args(): unknown[] { + const b = this.store.selectedBuilder(); + return b ? [b.id] : []; + } + override onWillAppear(ev: WillAppearEvent): void { + if (!ev.action.isKey()) return; + void ev.action.setImage(svgToDataUri(labelFaceSvg('terminal', 'Terminal', '#a9a9b2'))); + void ev.action.setTitle(''); + } +} + // ── Encoders (Stream Deck + dials) ────────────────────────────────────────── /** Direction of a dial rotation, normalized to ±1 (0 when no movement). */ @@ -610,9 +686,12 @@ abstract class ReviewNav extends SingletonAction { * (id + title); bar = its progress. A pending canvas error takes line 2 for one cycle. */ private renderTo(action: DialAction): void { // Canvas line 1 pairs the rotate axis with the press meaning (`Blocks · Open/Submit`, - // `Headings · Cancel`); diff mode shows only its axis label. + // `Headings · Cancel`); diff mode pairs its axis with the feedback delivery mode + // (`Files · queue` vs `Files · send`, #1410) so a press is never a surprise. const label = - this.mode() === 'canvas' ? `${this.canvas.label} · ${this.canvas.pressLabel}` : this.diff.label; + this.mode() === 'canvas' + ? `${this.canvas.label} · ${this.canvas.pressLabel}` + : `${this.diff.label} · ${this.store.feedbackMode() === 'queue' ? 'queue' : 'send'}`; const b = this.store.selectedBuilder(); const id = b ? (b.issueId ? `#${b.issueId}` : b.id) : ''; const details = b ? (b.issueTitle ? `${id} ${b.issueTitle}` : id) : 'No builder'; @@ -681,7 +760,9 @@ export class DiffFileNav extends ReviewNav { next: 'diff-next-file', prev: 'diff-prev-file', first: 'diff-first-file', - forward: 'forward-file', + // Dials collect, key commits (#1410): press submits the file as feedback via a + // mode-neutral verb; VSCode routes it forward-now or enqueue per the setting. + forward: 'feedback-file', }; // Coarse dial in canvas mode: step headings; tap resets to the document start // (role-consistent with diff-mode jump-to-first-file); press cancels an open composer. @@ -702,7 +783,8 @@ export class DiffHunkNav extends ReviewNav { next: 'diff-next-hunk', prev: 'diff-prev-hunk', first: 'diff-first-hunk', - forward: 'forward-hunk', + // Mode-neutral feedback (#1410): forward-now or enqueue per the workspace setting. + forward: 'feedback-hunk', }; // Fine dial in canvas mode: step blocks; tap walks forward through commented blocks // (the "next place needing attention" capability). Keyboard parity means no wrap, so @@ -723,9 +805,9 @@ const SCROLL_LINES_PER_TICK = 3; /** * Scroll dial: rotate scrolls the focused editor's viewport up/down (so you can - * read a diff without the keyboard); a dial press forwards the current selection to - * the builder. Scroll is a viewport move (`revealCursor: false`), so select your - * text first, then scroll/forward. + * read a diff without the keyboard); a dial press submits the current selection as + * feedback (forwarded now or queued per the workspace setting, #1410). Scroll is a + * viewport move (`revealCursor: false`), so select your text first, then scroll/submit. */ export class ScrollNav extends SingletonAction { override readonly manifestId = 'com.cluesmith.codev.scroll-nav'; @@ -744,6 +826,8 @@ export class ScrollNav extends SingletonAction { ); } override async onDialDown(): Promise { - await this.store.client.sendCommand('forward-selection', [], this.store.selectedWorkspacePath()); + // Mode-neutral feedback (#1410): submit the selection, routed forward-now or + // enqueue by VSCode per the workspace setting. + await this.store.client.sendCommand('feedback-selection', [], this.store.selectedWorkspacePath()); } } diff --git a/apps/streamdeck/src/face.ts b/apps/streamdeck/src/face.ts index b32a46762..1a2776717 100644 --- a/apps/streamdeck/src/face.ts +++ b/apps/streamdeck/src/face.ts @@ -44,7 +44,7 @@ const STATE_COLOR: Record = { }; /** The glyphs the face can draw: a gate shape when blocked, the bolt otherwise. */ -export type GlyphKey = 'bolt' | 'book' | 'checklist' | 'code' | 'pull-request' | 'verified' | 'bell' | 'play'; +export type GlyphKey = 'bolt' | 'book' | 'checklist' | 'code' | 'pull-request' | 'verified' | 'bell' | 'comment' | 'terminal' | 'play'; /** * Gate id → glyph. The streamdeck twin of `gateIconFor` in `apps/vscode/src/views/builder-row.ts` @@ -76,6 +76,8 @@ const GLYPHS: Record string> = { stroked(c, ''), verified: (c) => stroked(c, ''), bell: (c) => stroked(c, ''), + comment: (c) => stroked(c, ''), + terminal: (c) => stroked(c, ''), play: (c) => ``, // VS Code's Run/Start-Dev affordance }; @@ -145,10 +147,14 @@ export interface BuilderFace { label: string; state: BuilderState; icon: GlyphKey; + /** True for the Row-1 slot holding the shared selection — draws an accent ring + * so the live builder among the four is unmistakable (#1410). */ + selected: boolean; } -/** Resolve a builder into its face descriptor — all id→presentation mapping in one testable place. */ -export function faceForBuilder(b: OverviewBuilder): BuilderFace { +/** Resolve a builder into its face descriptor — all id→presentation mapping in one testable place. + * `selected` marks the Row-1 slot that currently holds the shared selection. */ +export function faceForBuilder(b: OverviewBuilder, selected = false): BuilderFace { const state = builderState(b); let icon: GlyphKey = 'bolt'; if (state === 'blocked') { @@ -158,7 +164,7 @@ export function faceForBuilder(b: OverviewBuilder): BuilderFace { if (b.issueId) { number = `#${b.issueId}`; } - return { kind: 'builder', number, label: stateLabel(b), state, icon }; + return { kind: 'builder', number, label: stateLabel(b), state, icon, selected }; } /** @@ -173,23 +179,46 @@ export function builderFaceSvg(face: BuilderFace | { kind: 'empty'; slot: string } return svg( `${BG}${iconZone(face.icon, STATE_COLOR[face.state])}${DIVIDER}` + - `${primaryLine(face.number)}${secondaryLine(face.label)}`, + `${primaryLine(face.number)}${secondaryLine(face.label)}` + + `${face.selected ? SELECTED_RING : ''}`, + ); +} + +/** Accent ring drawn on top of the selected Row-1 slot's face (#1410). */ +const SELECTED_RING = ''; + +/** + * The Row-2 **[Approve gate]** key face (#1410): it acts on the SELECTED builder, + * so it renders that builder's pending gate — the gate label (e.g. `Plan`) over an + * `Approve` band, warning-tinted, when the selection is blocked at a gate; a dim, + * inert `Approve` when it isn't (or nothing is selected). Never a fleet-wide count — + * the pending-gate tally lives on the [Next / attention] key. + */ +export function approveFaceSvg(b: Pick | undefined): string { + const gate = b?.blockedGate ?? ''; + if (!gate) { + return labelFaceSvg('verified', 'Approve', '#63636b'); + } + const label = GATE_LABELS[gate] ?? titleToken(gate); + return svg( + `${BG}${iconZone('verified', STATE_COLOR.blocked)}${DIVIDER}` + + `${primaryLine(label)}${secondaryLine('Approve')}`, ); } /** - * The Gates (approve-gate) key face — same composite frame as the builder face, fixing the same - * text-over-icon overlap. A `bell` (the sidebar's "needs attention" glyph, `builder-row.ts`) tinted - * warning-yellow with the pending count when gates await approval; a dim neutral bell with just the - * `Gates` label when none are pending. + * The Row-2 **[Send Fb]** key face (#1410): a comment glyph with the selected + * builder's queued-feedback count. `n > 0` → active-green icon + count + `Send Fb` + * (press flushes the queue); `n === 0` → dim glyph + `Send Fb` (inert — nothing + * queued, or the workspace forwards immediately). */ -export function gatesFaceSvg(pendingCount: number): string { - if (pendingCount <= 0) { - return svg(`${BG}${iconZone('bell', '#63636b')}${DIVIDER}${centeredLine('Gates')}`); +export function sendFbFaceSvg(n: number): string { + if (n <= 0) { + return labelFaceSvg('comment', 'Send Fb', '#63636b'); } return svg( - `${BG}${iconZone('bell', STATE_COLOR.blocked)}${DIVIDER}` + - `${primaryLine(String(pendingCount))}${secondaryLine('Gates')}`, + `${BG}${iconZone('comment', STATE_COLOR.active)}${DIVIDER}` + + `${primaryLine(String(n))}${secondaryLine('Send Fb')}`, ); } diff --git a/apps/streamdeck/src/plugin.ts b/apps/streamdeck/src/plugin.ts index 55335eaf6..98762a9d4 100644 --- a/apps/streamdeck/src/plugin.ts +++ b/apps/streamdeck/src/plugin.ts @@ -6,7 +6,9 @@ import { CodevAction, BuilderAction, DevServerAction, + OpenTerminalAction, ApproveGate, + SendQueueAction, ZoomNav, PrNav, SpawnNav, @@ -37,7 +39,9 @@ const actions = [ new CodevAction(store), new BuilderAction(store), new DevServerAction(store), + new OpenTerminalAction(store), new ApproveGate(store), + new SendQueueAction(store), new ZoomNav(store), new PrNav(store), new SpawnNav(store), diff --git a/apps/streamdeck/src/store.ts b/apps/streamdeck/src/store.ts index d695a1036..0c2c8bd92 100644 --- a/apps/streamdeck/src/store.ts +++ b/apps/streamdeck/src/store.ts @@ -23,6 +23,10 @@ export interface CodevStoreOptions { openUrl?: (url: string) => void | Promise; } +/** Row-1 selector width on the SD+ (a 2×4 keypad): the fleet window is this many + * builders wide, and the Select dial scrolls it a page at a time (#1410). */ +export const ROW1_WINDOW_SIZE = 4; + export class CodevStore { readonly client: ControllerClient; readonly openUrl: (url: string) => void | Promise; @@ -139,14 +143,38 @@ export class CodevStore { return this.builders()[this.cursor.builder]; } - pendingGates(): OverviewBuilder[] { - return this.builders().filter((b) => b.blocked); + /** + * The builder shown in Row-1 selector slot `slotIndex` (0-based, 0..3). Row 1 + * is a 4-wide WINDOW onto the fleet, not a fixed view of the first four (#1410): + * the window is the page containing the selection, so rotating the Select dial + * past the 4th builder scrolls Row 1 to builders 5-8, then 9-N. A slot past the + * end of the fleet returns `undefined` (a trailing empty slot on the last page). + */ + windowedBuilder(slotIndex: number): OverviewBuilder | undefined { + return this.builders()[this.builderWindowStart() + slotIndex]; + } + + /** First builder index of the Row-1 window: the page (of `ROW1_WINDOW_SIZE`) + * that contains the current selection. */ + private builderWindowStart(): number { + return Math.floor(this.cursor.builder / ROW1_WINDOW_SIZE) * ROW1_WINDOW_SIZE; } - /** The builder whose gate the approve-gate key targets (oldest-first not yet - * available in overview — first blocked builder for now; see PLAN open detail). */ - topGateBuilderId(): string | undefined { - return this.pendingGates()[0]?.id; + /** The workspace's review-feedback delivery mode (#1410); `'forward'` until an + * overview arrives, so the dial touchstrip never mislabels a press. */ + feedbackMode(): 'forward' | 'queue' { + return this.overview?.feedbackMode ?? 'forward'; + } + + /** Count of queued review-feedback for a builder (#1410); 0 when none / absent. */ + queuedFeedback(builderId: string | undefined): number { + if (!builderId) return 0; + return this.overview?.queuedFeedback?.[builderId] ?? 0; + } + + /** Blocked builders (a pending gate); the Zoom dial surfaces the count. */ + pendingGates(): OverviewBuilder[] { + return this.builders().filter((b) => b.blocked); } pendingPRs(): OverviewPR[] { diff --git a/apps/vscode/src/__tests__/canvas-back-sync.test.ts b/apps/vscode/src/__tests__/canvas-back-sync.test.ts new file mode 100644 index 000000000..179f41c4a --- /dev/null +++ b/apps/vscode/src/__tests__/canvas-back-sync.test.ts @@ -0,0 +1,35 @@ +/** + * Canvas focus back-sync (#1410): a focused spec/plan/review canvas resolves its + * owning builder by worktree-path prefix, so the deck selection follows the + * canvas the same way it follows a focused diff. + */ + +import { describe, it, expect } from 'vitest'; +import { builderIdForWorktreeFile } from '../markdown-preview/canvas-owner.js'; + +const builders = [ + { id: 'pir-1', worktreePath: '/w/alpha/.builders/pir-1' }, + { id: 'pir-2', worktreePath: '/w/alpha/.builders/pir-2' }, +]; + +describe('builderIdForWorktreeFile (#1410)', () => { + it('resolves the builder whose worktree contains the canvas artifact', () => { + expect(builderIdForWorktreeFile(builders, '/w/alpha/.builders/pir-2/codev/plans/2-x.md', '/')).toBe('pir-2'); + expect(builderIdForWorktreeFile(builders, '/w/alpha/.builders/pir-1/codev/specs/1-y.md', '/')).toBe('pir-1'); + }); + + it('returns undefined for a main-repo artifact (belongs to no builder)', () => { + expect(builderIdForWorktreeFile(builders, '/w/alpha/codev/plans/2-x.md', '/')).toBeUndefined(); + }); + + it('does not false-match a sibling whose path is a string-prefix but not a path-prefix', () => { + const b = [{ id: 'pir-1', worktreePath: '/w/alpha/.builders/pir-1' }]; + // `/w/alpha/.builders/pir-12/...` starts with `/w/alpha/.builders/pir-1` textually, + // but is a different worktree — the separator boundary must prevent the match. + expect(builderIdForWorktreeFile(b, '/w/alpha/.builders/pir-12/codev/plans/12-z.md', '/')).toBeUndefined(); + }); + + it('ignores builders without a worktree path', () => { + expect(builderIdForWorktreeFile([{ id: 'x' }], '/anything.md', '/')).toBeUndefined(); + }); +}); diff --git a/apps/vscode/src/__tests__/command-relay.test.ts b/apps/vscode/src/__tests__/command-relay.test.ts index aaeadfe8d..a1ddca201 100644 --- a/apps/vscode/src/__tests__/command-relay.test.ts +++ b/apps/vscode/src/__tests__/command-relay.test.ts @@ -76,6 +76,22 @@ describe('wireCommandProvider', () => { expect(vscode.commands.executeCommand).toHaveBeenCalledWith('codev.openBuilderDiffFirstFile', 'pir-1414'); }); + it('maps the mode-neutral feedback verbs + send-queue to their commands (#1410)', async () => { + const { mgr, fire } = makeConnMgr(); + wireCommandProvider(mgr as never); + + fire('command', { verb: 'feedback-file', args: [] }); + fire('command', { verb: 'feedback-hunk', args: [] }); + fire('command', { verb: 'feedback-selection', args: [] }); + fire('command', { verb: 'send-queue', args: ['pir-1'] }); + await new Promise((r) => setTimeout(r, 0)); + + expect(vscode.commands.executeCommand).toHaveBeenCalledWith('codev.feedbackCurrentFileToBuilder'); + expect(vscode.commands.executeCommand).toHaveBeenCalledWith('codev.feedbackCurrentHunkToBuilder'); + expect(vscode.commands.executeCommand).toHaveBeenCalledWith('codev.feedbackSelectionToBuilder'); + expect(vscode.commands.executeCommand).toHaveBeenCalledWith('codev.submitReview', 'pir-1'); + }); + it('ignores a verb that is not in the provider map (the allowlist)', async () => { const { mgr, fire } = makeConnMgr(); wireCommandProvider(mgr as never); diff --git a/apps/vscode/src/__tests__/feedback.test.ts b/apps/vscode/src/__tests__/feedback.test.ts new file mode 100644 index 000000000..afeb92ce2 --- /dev/null +++ b/apps/vscode/src/__tests__/feedback.test.ts @@ -0,0 +1,118 @@ +/** + * Mode-neutral review feedback (#1410): the diff/scroll dial verbs route each + * chunk forward-now (immediate PTY inject) or into the queue, following the + * `codev.diffCodelensMode` setting, deriving both branches from the same anchor. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +const h = vi.hoisted(() => { + const state = { + mode: 'forward' as 'forward' | 'comment', + activeFsPath: undefined as string | undefined, + selection: { active: { line: 0 }, start: { line: 0, character: 0 }, end: { line: 0, character: 0 }, isEmpty: true }, + executed: [] as Array<{ command: string; args: unknown[] }>, + warnings: [] as string[], + statusMessages: [] as string[], + }; + return { state }; +}); + +vi.mock('vscode', () => ({ + EventEmitter: class { event = (): { dispose(): void } => ({ dispose() {} }); fire(): void {} dispose(): void {} }, + RelativePattern: class {}, + window: { + get activeTextEditor() { + if (!h.state.activeFsPath) { return undefined; } + return { document: { uri: { fsPath: h.state.activeFsPath } }, selection: h.state.selection }; + }, + showWarningMessage: vi.fn(async (msg: string) => { h.state.warnings.push(msg); return undefined; }), + setStatusBarMessage: vi.fn((msg: string) => { h.state.statusMessages.push(msg); }), + }, + workspace: { + createFileSystemWatcher: vi.fn(), + getConfiguration: () => ({ get: () => h.state.mode }), + onDidChangeConfiguration: () => ({ dispose() {} }), + }, + commands: { executeCommand: vi.fn(async (command: string, ...args: unknown[]) => { h.state.executed.push({ command, args }); }) }, + languages: { registerCodeLensProvider: () => ({ dispose() {} }) }, + Range: class {}, + CodeLens: class {}, +})); + +const { feedbackFile, feedbackHunk, feedbackSelection } = await import('../review-queue/feedback.js'); +const { setDiffInjectSession } = await import('../diff-inject-codelens.js'); + +const FS_PATH = '/w/alpha/.builders/pir-1/src/a.ts'; + +/** Minimal in-memory ReviewQueueStore stand-in capturing worktree + queue writes. */ +function makeStore() { + const worktrees = new Map(); + const added: Array<{ builderId: string; comment: { file: string; lineRange: unknown; body: string } }> = []; + return { + store: { + getWorktreePath: (id: string) => worktrees.get(id), + registerWorktree: (id: string, wt: string) => { worktrees.set(id, wt); }, + add: async (builderId: string, comment: { file: string; lineRange: unknown; body: string }) => { added.push({ builderId, comment }); }, + }, + worktrees, + added, + }; +} + +describe('feedback mode-router (#1410)', () => { + beforeEach(() => { + h.state.mode = 'forward'; + h.state.activeFsPath = FS_PATH; + h.state.selection = { active: { line: 0 }, start: { line: 0, character: 0 }, end: { line: 0, character: 0 }, isEmpty: true }; + h.state.executed = []; + h.state.warnings = []; + h.state.statusMessages = []; + setDiffInjectSession([{ fsPath: FS_PATH, builderId: 'pir-1', relPath: 'src/a.ts', hunks: [{ start: 5, end: 9 }] }]); + }); + + it('forward mode: a file press injects immediately via forwardToBuilder', async () => { + const { store, added } = makeStore(); + await feedbackFile({ store: store as never }); + expect(h.state.executed).toEqual([{ command: 'codev.forwardToBuilder', args: ['pir-1', 'src/a.ts '] }]); + expect(added).toHaveLength(0); // nothing queued in forward mode + }); + + it('comment mode: a file press enqueues a whole-file comment through the store', async () => { + h.state.mode = 'comment'; + const { store, added, worktrees } = makeStore(); + await feedbackFile({ store: store as never }); + expect(h.state.executed).toHaveLength(0); // no immediate forward + expect(added).toHaveLength(1); + expect(added[0].builderId).toBe('pir-1'); + expect(added[0].comment.file).toBe('src/a.ts'); + expect(added[0].comment.lineRange).toBeNull(); // whole file + expect(added[0].comment.body).toContain('Stream Deck'); + // worktree derived from the diff entry (never guessed) + expect(worktrees.get('pir-1')).toBe('/w/alpha/.builders/pir-1'); + }); + + it('comment mode: a hunk press enqueues the changed-hunk range under the cursor', async () => { + h.state.mode = 'comment'; + h.state.selection = { active: { line: 6 }, start: { line: 6, character: 0 }, end: { line: 6, character: 0 }, isEmpty: true }; // line 7 ∈ [5,9] + const { store, added } = makeStore(); + await feedbackHunk({ store: store as never }); + expect(added[0].comment.lineRange).toEqual({ start: 5, end: 9 }); + }); + + it('comment mode: a selection press enqueues the selected range', async () => { + h.state.mode = 'comment'; + h.state.selection = { active: { line: 2 }, start: { line: 2, character: 0 }, end: { line: 5, character: 4 }, isEmpty: false }; + const { store, added } = makeStore(); + await feedbackSelection({ store: store as never }); + expect(added[0].comment.lineRange).toEqual({ start: 3, end: 6 }); + }); + + it('does nothing when the focused editor is not a tracked builder diff', async () => { + h.state.activeFsPath = '/some/unrelated/file.ts'; + const { store, added } = makeStore(); + await feedbackFile({ store: store as never }); + expect(h.state.executed).toHaveLength(0); + expect(added).toHaveLength(0); + }); +}); diff --git a/apps/vscode/src/__tests__/overview-nudge.test.ts b/apps/vscode/src/__tests__/overview-nudge.test.ts new file mode 100644 index 000000000..371702a29 --- /dev/null +++ b/apps/vscode/src/__tests__/overview-nudge.test.ts @@ -0,0 +1,69 @@ +/** + * #1410 regression: a review-queue mutation or a `codev.diffCodelensMode` change + * must nudge Tower (`refreshOverview`) so the deck's Send Fb badge + dial mode + * label refresh deterministically instead of waiting for an unrelated SSE event. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const h = vi.hoisted(() => ({ + configListeners: [] as Array<(e: { affectsConfiguration: (s: string) => boolean }) => void>, +})); + +vi.mock('vscode', () => ({ + Disposable: { from: (...ds: Array<{ dispose?: () => void }>) => ({ dispose: () => ds.forEach((d) => d.dispose?.()) }) }, + workspace: { + onDidChangeConfiguration: (l: (e: { affectsConfiguration: (s: string) => boolean }) => void) => { + h.configListeners.push(l); + return { dispose() {} }; + }, + }, +})); + +const { activateOverviewNudge } = await import('../review-queue/overview-nudge.js'); + +/** Minimal ReviewQueueStore stand-in exposing the change event + a fire trigger. */ +function makeStore() { + let listener: (() => void) | undefined; + return { + store: { onDidChangeQueue: (l: () => void) => { listener = l; return { dispose() {} }; } } as never, + fireQueueChange: () => listener?.(), + }; +} + +function makeConn(refreshOverview = vi.fn(async () => true)) { + return { conn: { getClient: () => ({ refreshOverview }) } as never, refreshOverview }; +} + +function fireConfig(section: string) { + for (const l of h.configListeners) { l({ affectsConfiguration: (s) => s === section }); } +} + +describe('activateOverviewNudge (#1410)', () => { + beforeEach(() => { h.configListeners = []; }); + + it('nudges Tower on a queue mutation', () => { + const { store, fireQueueChange } = makeStore(); + const { conn, refreshOverview } = makeConn(); + activateOverviewNudge(store, conn); + fireQueueChange(); + expect(refreshOverview).toHaveBeenCalledTimes(1); + }); + + it('nudges Tower when codev.diffCodelensMode changes, but not for an unrelated setting', () => { + const { store } = makeStore(); + const { conn, refreshOverview } = makeConn(); + activateOverviewNudge(store, conn); + fireConfig('editor.tabSize'); + expect(refreshOverview).not.toHaveBeenCalled(); + fireConfig('codev.diffCodelensMode'); + expect(refreshOverview).toHaveBeenCalledTimes(1); + }); + + it('is a no-op (no throw) when Tower is not connected', () => { + const { store, fireQueueChange } = makeStore(); + const conn = { getClient: () => null } as never; + activateOverviewNudge(store, conn); + expect(() => fireQueueChange()).not.toThrow(); + }); +}); diff --git a/apps/vscode/src/command-relay.ts b/apps/vscode/src/command-relay.ts index 7420b49e6..64c1657de 100644 --- a/apps/vscode/src/command-relay.ts +++ b/apps/vscode/src/command-relay.ts @@ -31,6 +31,14 @@ const VERB_COMMANDS: Record = { 'open-review': 'codev.viewReviewFile', 'forward-hunk': 'codev.forwardCurrentHunkToBuilder', 'forward-file': 'codev.forwardCurrentFileToBuilder', + // Mode-neutral review feedback (#1410): the deck dials press these; VSCode + // routes each forward-now or enqueue per the `codev.diffCodelensMode` setting. + 'feedback-file': 'codev.feedbackCurrentFileToBuilder', + 'feedback-hunk': 'codev.feedbackCurrentHunkToBuilder', + 'feedback-selection': 'codev.feedbackSelectionToBuilder', + // Flush the selected builder's queued review feedback (the Send Fb key); arg: + // builder id. Inert deck-side when the badge count is 0. + 'send-queue': 'codev.submitReview', 'run-dev': 'codev.runWorktreeDev', 'spawn-builder': 'codev.spawnBuilder', // Context verbs (operate on the focused editor; no arg). diff --git a/apps/vscode/src/extension.ts b/apps/vscode/src/extension.ts index 4229f2313..4bfd48af9 100644 --- a/apps/vscode/src/extension.ts +++ b/apps/vscode/src/extension.ts @@ -40,7 +40,9 @@ import { activateReviewComments } from './comments/plan-review.js'; import { activateBuilderReviewComments } from './comments/builder-review.js'; import { ReviewQueueStore } from './review-queue/store.js'; import { submitReview, discardReviewComments } from './review-queue/submit.js'; +import { feedbackFile, feedbackHunk, feedbackSelection } from './review-queue/feedback.js'; import { activateSubmitReviewStatusBar } from './review-queue/status-bar.js'; +import { activateOverviewNudge } from './review-queue/overview-nudge.js'; import { MarkdownPreviewProvider } from './markdown-preview/preview-provider.js'; import { BuilderSpawnHandler } from './builder-spawn-handler.js'; import { BuilderTerminalLinkProvider, ReconnectTerminalLinkProvider, IssueRefTerminalLinkProvider } from './terminal-link-provider.js'; @@ -1201,10 +1203,21 @@ export async function activate(context: vscode.ExtensionContext) { // Submit Review + Discard (#1037): flush / drop the per-builder pending // comment queue. Builder resolution: active diff's owner → sole pending // builder → QuickPick. - reg('codev.submitReview', () => - submitReview({ store: reviewQueueStore, terminalManager: terminalManager!, overviewCache })), + // The status-bar button invokes this with no arg (resolves the target + // builder itself); the deck's Send Fb key relays `send-queue [builderId]`, + // so an explicit id string flushes exactly that builder's queue (#1410). + reg('codev.submitReview', (builderId?: unknown) => + submitReview( + { store: reviewQueueStore, terminalManager: terminalManager!, overviewCache }, + typeof builderId === 'string' ? builderId : undefined, + )), reg('codev.discardReviewComments', () => discardReviewComments({ store: reviewQueueStore, terminalManager: terminalManager!, overviewCache })), + // Mode-neutral review feedback (#1410): the deck diff/scroll dials press + // these; each forwards immediately or enqueues per `codev.diffCodelensMode`. + reg('codev.feedbackCurrentFileToBuilder', () => feedbackFile({ store: reviewQueueStore })), + reg('codev.feedbackCurrentHunkToBuilder', () => feedbackHunk({ store: reviewQueueStore })), + reg('codev.feedbackSelectionToBuilder', () => feedbackSelection({ store: reviewQueueStore })), // Diff codelens mode toggle (#1037): a single title-bar button per mode // (VS Code toolbar buttons have no pressed state — same pattern as the // Agents group-by cycle above); each command shows the mode clicking @@ -1394,6 +1407,10 @@ export async function activate(context: vscode.ExtensionContext) { // counter. The batched submit itself is `codev.submitReview` above. activateBuilderReviewComments(context, reviewQueueStore, overviewCache); activateSubmitReviewStatusBar(context, reviewQueueStore); + // #1410: nudge Tower to rebuild + rebroadcast the overview on a queue mutation + // or a feedback-mode change, so the deck's Send Fb badge + dial mode-label + // update promptly (Tower has no watcher on the queue files / settings.json). + context.subscriptions.push(activateOverviewNudge(reviewQueueStore, connectionManager!)); // Codev Markdown Preview (#859): a read-only custom editor that renders a // spec/plan/review in the shared artifact-canvas and adds review comments diff --git a/apps/vscode/src/markdown-preview/canvas-owner.ts b/apps/vscode/src/markdown-preview/canvas-owner.ts new file mode 100644 index 000000000..d0633b945 --- /dev/null +++ b/apps/vscode/src/markdown-preview/canvas-owner.ts @@ -0,0 +1,27 @@ +/** + * Pure canvas→builder resolution (#1410), vscode-free so it unit-tests without the + * webview runtime (same precedent as `review-queue/queue.ts`). + * + * `viewPlanFile`/`viewSpecFile`/`viewReviewFile` open a builder's artifact inside + * its worktree, so the owning builder is the one whose `worktreePath` is a path + * prefix of the canvas file. Used by the canvas focus back-sync to announce which + * builder became active when a spec/plan/review canvas is focused. + */ + +/** + * The builder that owns a canvas artifact: the one whose `worktreePath` is a path + * prefix of `file`. A main-repo artifact (no owning worktree) resolves to + * `undefined`. The separator boundary (`file === wt || file.startsWith(wt + sep)`) + * prevents a sibling whose path is a mere string-prefix (`…/pir-1` vs `…/pir-12`) + * from false-matching. + */ +export function builderIdForWorktreeFile( + builders: ReadonlyArray<{ id: string; worktreePath?: string }>, + file: string, + sep: string, +): string | undefined { + const match = builders.find( + b => !!b.worktreePath && (file === b.worktreePath || file.startsWith(b.worktreePath + sep)), + ); + return match?.id; +} diff --git a/apps/vscode/src/markdown-preview/preview-provider.ts b/apps/vscode/src/markdown-preview/preview-provider.ts index 3f662abbf..cef5b4c9f 100644 --- a/apps/vscode/src/markdown-preview/preview-provider.ts +++ b/apps/vscode/src/markdown-preview/preview-provider.ts @@ -28,6 +28,7 @@ */ import * as vscode from 'vscode'; +import * as path from 'node:path'; import { serializeReviewMarker, markerAppendLine, @@ -39,6 +40,8 @@ import { renderMarkdownPreviewHtml } from './preview-template.js'; import type { HostToWebviewMessage, WebviewToHostMessage } from './messages.js'; import type { ConnectionManager } from '../connection-manager.js'; import { registerCanvasView } from './canvas-view-registry.js'; +import { builderIdForWorktreeFile } from './canvas-owner.js'; +import { fireActivity } from '../activity-hooks.js'; import type { OverviewCache } from '../views/overview-data.js'; /** globalState key for the per-user reading-mode preference (spec 1380 D4 — per-USER scope: @@ -87,6 +90,17 @@ export class MarkdownPreviewProvider implements vscode.CustomTextEditorProvider, private readonly connectionManager?: ConnectionManager, ) {} + /** + * The builder whose worktree contains this canvas artifact, or undefined for a + * main-repo artifact (which belongs to no builder). Matched against the Tower + * overview by worktree-path prefix — `viewPlanFile`/`viewSpecFile`/`viewReviewFile` + * open the artifact inside the builder's worktree, so the owning builder is the + * one whose `worktreePath` is a path prefix of the file (#1410). + */ + private builderIdForCanvasFile(file: string): string | undefined { + return builderIdForWorktreeFile(this.overviewCache.getData()?.builders ?? [], file, path.sep); + } + public resolveCustomTextEditor( document: vscode.TextDocument, panel: vscode.WebviewPanel, @@ -139,6 +153,25 @@ export class MarkdownPreviewProvider implements vscode.CustomTextEditorProvider, this.canvasViews.delete(canvasView); canvasView.dispose(); }); + + // Canvas focus back-sync (#1410): when this canvas becomes the active panel, + // announce its owning builder via the `builder-active` activity hook — the + // symmetric counterpart of the diff editor's `announceActiveBuilderFromEditor`, + // so a controller (Stream Deck) following that hook re-targets the builder + // whose plan/spec/review you focused. Reuses the same event (no new hook to + // configure) and is deduped by `fireActivity`'s `lastFiredKey`. + const cm = this.connectionManager; + const announceActiveBuilder = (): void => { + const builderId = this.builderIdForCanvasFile(document.uri.fsPath); + if (builderId) { + fireActivity(cm.getWorkspacePath() ?? null, 'builder-active', { builder: builderId }); + } + }; + if (panel.active) { announceActiveBuilder(); } + const activeSub = panel.onDidChangeViewState((e) => { + if (e.webviewPanel.active) { announceActiveBuilder(); } + }); + panel.onDidDispose(() => activeSub.dispose()); } panel.webview.onDidReceiveMessage((msg: unknown) => { diff --git a/apps/vscode/src/review-queue/feedback.ts b/apps/vscode/src/review-queue/feedback.ts new file mode 100644 index 000000000..a6a79c49a --- /dev/null +++ b/apps/vscode/src/review-queue/feedback.ts @@ -0,0 +1,127 @@ +/** + * Mode-neutral review feedback (#1410): the Stream Deck diff dials and Scroll + * dial press a single `feedback-*` verb, and this module routes each chunk + * (whole file / hunk-under-cursor / selection) EITHER as an immediate PTY + * forward OR into the per-builder pending-comment queue, following the + * workspace's `codev.diffCodelensMode` setting — so the deck never infers the + * mode. Both branches derive their anchor from the SAME resolver, so a given + * dial press references the same file/range in either mode. + * + * The queue branch mutates ONLY through `ReviewQueueStore` (the queue's single + * source of truth, #1037): the status bar, inline threads, and Tower's + * per-builder queued-feedback count all reflect a deck-driven enqueue for free. + * + * The feedback always targets the builder whose diff is FOCUSED (the diff-inject + * entry's owner), never a separately-selected builder — a review comment must + * attach to the file in front of the reviewer. + */ + +import * as vscode from 'vscode'; +import { randomUUID } from 'node:crypto'; +import * as path from 'node:path'; +import { + getDiffInjectEntry, + getDiffCodelensMode, + type DiffInjectSessionEntry, +} from '../diff-inject-codelens.js'; +import { buildBuilderFileRef, buildBuilderRangeRef } from '../diff-inject-ref.js'; +import { deriveWorktreePath } from './reconcile.js'; +import type { LineRange } from './queue.js'; +import type { ReviewQueueStore } from './store.js'; + +/** Body attached to a chunk flagged from the deck — a dial press carries no + * typed prose, so the comment's file + range are its substance. */ +const DECK_FLAG_BODY = 'Flagged for review from Stream Deck.'; + +export interface FeedbackDeps { + store: ReviewQueueStore; +} + +/** Where a feedback gesture points: the owning diff entry + range (null = whole file). */ +interface Anchor { + entry: DiffInjectSessionEntry; + lineRange: LineRange | null; +} + +/** The active editor's tracked builder-diff entry, or undefined when the focused + * editor isn't a builder diff (a plain source file, the base side, or none). */ +function activeEntry(): DiffInjectSessionEntry | undefined { + const editor = vscode.window.activeTextEditor; + if (!editor) { return undefined; } + return getDiffInjectEntry(editor.document.uri.fsPath); +} + +/** Whole-file anchor. */ +function fileAnchor(): Anchor | undefined { + const entry = activeEntry(); + return entry ? { entry, lineRange: null } : undefined; +} + +/** The changed hunk under the cursor (mirrors `codev.forwardCurrentHunkToBuilder`). */ +function hunkAnchor(): Anchor | undefined { + const editor = vscode.window.activeTextEditor; + const entry = activeEntry(); + if (!editor || !entry) { return undefined; } + const line = editor.selection.active.line + 1; + const hunk = entry.hunks.find(h => line >= h.start && line <= h.end); + if (!hunk) { + vscode.window.setStatusBarMessage('Codev: place the cursor in a changed hunk', 3000); + return undefined; + } + return { entry, lineRange: { start: hunk.start, end: hunk.end } }; +} + +/** The selection range, or the cursor line when the selection is empty (mirrors + * `codev.forwardSelectionToBuilder`). */ +function selectionAnchor(): Anchor | undefined { + const editor = vscode.window.activeTextEditor; + const entry = activeEntry(); + if (!editor || !entry) { return undefined; } + const sel = editor.selection; + let start = sel.start.line + 1; + let end = sel.end.line + 1; + if (sel.isEmpty) { + start = sel.active.line + 1; + end = start; + } else if (sel.end.character === 0 && sel.end.line > sel.start.line) { + // A selection ending at column 0 of a line doesn't include that line. + end = sel.end.line; + } + return { entry, lineRange: { start, end } }; +} + +/** Route one anchor per the workspace mode: forward now (PTY) or enqueue. */ +async function route(deps: FeedbackDeps, anchor: Anchor | undefined): Promise { + if (!anchor) { return; } + const { entry, lineRange } = anchor; + if (getDiffCodelensMode() === 'forward') { + // Immediate: the same low-level inject the forward CodeLens / commands use. + const ref = lineRange + ? buildBuilderRangeRef(entry.relPath, lineRange.start, lineRange.end) + : buildBuilderFileRef(entry.relPath); + await vscode.commands.executeCommand('codev.forwardToBuilder', entry.builderId, ref); + return; + } + // Queue: register the builder's worktree from the diff entry (derived, never + // guessed) so the write lands in the right worktree even when nothing has been + // queued this session, then mutate through the store. + if (!deps.store.getWorktreePath(entry.builderId)) { + const worktree = deriveWorktreePath(entry.fsPath, entry.relPath, path.sep); + if (!worktree) { + vscode.window.showWarningMessage('Codev: could not locate the builder worktree for this diff'); + return; + } + deps.store.registerWorktree(entry.builderId, worktree); + } + await deps.store.add(entry.builderId, { + id: randomUUID(), + createdAt: new Date().toISOString(), + file: entry.relPath, + lineRange, + body: DECK_FLAG_BODY, + }); +} + +export const feedbackFile = (deps: FeedbackDeps): Promise => route(deps, fileAnchor()); +export const feedbackHunk = (deps: FeedbackDeps): Promise => route(deps, hunkAnchor()); +export const feedbackSelection = (deps: FeedbackDeps): Promise => route(deps, selectionAnchor()); diff --git a/apps/vscode/src/review-queue/overview-nudge.ts b/apps/vscode/src/review-queue/overview-nudge.ts new file mode 100644 index 000000000..e75ef8148 --- /dev/null +++ b/apps/vscode/src/review-queue/overview-nudge.ts @@ -0,0 +1,36 @@ +/** + * Keep the Stream Deck's Send Fb badge and dial mode-label fresh (#1410). + * + * Tower projects two out-of-band values into the overview: the per-builder + * `queuedFeedback` count (read from each builder's `pending-comments.json`) and + * `feedbackMode` (read from `/.vscode/settings.json`). Tower has **no + * watcher** on either file — it rebuilds them on demand when the overview is + * fetched. So a queue mutation or a `codev.diffCodelensMode` toggle is invisible + * to the deck until some *unrelated* SSE event happens to fire, and the deck's + * refresh-on-command can even race ahead of the queue write (reading the stale, + * pre-write count). + * + * This nudges Tower on exactly those two mutations. `refreshOverview()` invalidates + * Tower's overview cache and broadcasts `overview-changed`, which fires AFTER the + * write — so every client (deck + dashboard) re-fetches the fresh values + * deterministically, no race. Best-effort: a no-op when Tower isn't connected. + */ + +import * as vscode from 'vscode'; +import type { ReviewQueueStore } from './store.js'; +import type { ConnectionManager } from '../connection-manager.js'; + +export function activateOverviewNudge( + store: ReviewQueueStore, + connectionManager: ConnectionManager, +): vscode.Disposable { + const nudge = (): void => { void connectionManager.getClient()?.refreshOverview(); }; + return vscode.Disposable.from( + // Every queue mutation (deck-driven enqueue, Send Fb flush, discard, or an + // external window's write) flows through this one event. + store.onDidChangeQueue(() => nudge()), + vscode.workspace.onDidChangeConfiguration((e) => { + if (e.affectsConfiguration('codev.diffCodelensMode')) { nudge(); } + }), + ); +} diff --git a/codev/plans/1410-stream-deck-sd-two-zone-builde.md b/codev/plans/1410-stream-deck-sd-two-zone-builde.md new file mode 100644 index 000000000..9fec7f6d0 --- /dev/null +++ b/codev/plans/1410-stream-deck-sd-two-zone-builde.md @@ -0,0 +1,408 @@ +# PIR Plan: Stream Deck SD+ two-zone builder workflow (selectors + action palette, dial-driven feedback queue) + +## Owner decision (requirement 6 — single approve affordance) — RESOLVED + +**Decided by Amr (2026-08-13): retire the generic `ApproveGate` singleton.** Row 2 [Approve gate] +becomes the single, selected-scoped approve affordance. + +> **Revised during implement (Amr, 2026-08-13):** Row 2 is now **uniformly per-builder**. The 4th +> palette key is **[Open Terminal]** (opens the selected builder's terminal), *not* the +> fleet-level [Next / attention]. The retired singleton's **jump-to-next + fleet gate-count are +> dropped** — pending gates stay visible via the Row 1 window (gate-colored faces) and the Zoom +> dial's touchstrip `N⚠` count, so a dedicated jump key isn't needed. The req-6 outcome is +> unchanged (one selected-scoped approve affordance; singleton retired); only the 4th key differs. +> Sections below that still say "[Next / attention]" are superseded by this note. + +Today the deck has one approve affordance: the standalone `ApproveGate` singleton +(`actions.ts:187`). It targets the **top pending gate** (`store.topGateBuilderId()` = first +blocked builder), renders a **pending-gate count badge**, and on press relays +`approve-gate [topBuilderId]` — the VSCode side then shows the confirmation modal (never a +silent approve). This issue adds a Row 2 **[Approve gate]** key that must act on the +**selected** builder. Shipping both would leave two approve keys with *different* targeting +(top-pending vs selected) — the confusing outcome the issue and the streamdeck architect both +call out. + +**My recommendation (matches the streamdeck architect's stated prior):** + +- Row 2 **[Approve gate]** becomes the *single* approve affordance. It relays + `approve-gate [selectedBuilderId]` — approving whoever is selected, through the existing + confirmation modal. No approval semantics change on the VSCode side; only the target id + changes (selected instead of top-pending). +- The standalone singleton's two useful behaviours **fold into Row 2 [Next / attention]** + (the 4th palette key): + - its **jump-to-next** becomes: press = move the shared selection/cursor to the + highest-priority pending-gate builder (`store.topGateBuilderId()`), so the reviewer lands + on the builder needing attention and then presses Approve; + - its **pending-gate count badge** moves onto the [Next / attention] key face, so the + at-a-glance "N gates waiting" signal is preserved. +- The singleton `ApproveGate` action is **retired** (class + manifest action removed; its + profile slot reassigned to Row 2). + +Net: exactly one approve key (selected-scoped), and the "sweep the fleet for gates" ergonomic +survives as Next/attention. **If Amr prefers instead to keep the singleton top-gate Approve and +make Row 2 key 4 a free/other action, say so and I will flip the plan** (Row 2 Approve would +then also be selected-scoped, but the singleton stays — accepting two approve keys, which I +advise against). + +## Understanding + +After #1404, each Row 1 key is a Builder Action: it renders a builder's status and, on press, +selects that builder (the shared `store.cursor` follows via `syncToBuilder`) and opens the +phase artifact. Covering a builder end-to-end needs more than select+open — approve a gate, run +dev, send queued review feedback — and the queued code-review feedback (#1037/#1382) has no +hardware trigger. The issue asks for a stable **two-zone** SD+ layout (no modal reflow): + +- **Row 1** = the fleet-selector keys, upgraded from #1404's fixed absolute slots into a + **4-wide window that follows the selection**, so a fleet larger than 4 is fully reachable + (see "Row 1 windowing" below). The Select dial = `ZoomNav` rotate scrolls the window across + the whole fleet. +- **Row 2** = a fixed action palette always acting on the **selected** builder: + **[Approve gate] [Run Dev] [Send Fb (N)] [Open Terminal]**. + +Two behaviour changes ride along: + +1. **Dials collect, key commits.** The diff dials' *press* moves from immediate send + (`forward-file` / `forward-hunk`) to a **mode-neutral** verb (`feedback-file` / + `feedback-hunk`; `feedback-selection` for the Scroll dial). VSCode routes each **forward-now + or enqueue** per the workspace setting `codev.diffCodelensMode` (`forward` = immediate, + `comment` = queue). Dial rotate (navigate) and tap (jump-to-first) are unchanged. +2. **Send Fb (N) = flush.** A new `send-queue` verb flushes the selected builder's queue + (VSCode's existing `codev.submitReview`). The badge `N` mirrors the builder's queued count + from the overview: in immediate mode nothing ever enqueues so `N` stays 0 and the key is + inert; in queue mode `N` climbs and the key sends. No deck-side mode inference. + +Two supporting wire additions (both binding, from the sdk/vscode stakeholder seats): + +3. **Per-builder queued-count map** on the overview (`builderId -> queuedCount`), never a + scalar total — so the deck renders `N` per builder and #1049's future Attention rollup + consumes the same field. +4. **Feedback mode on the wire**, so the dial touchstrip can name the live semantic + (`Files · queue` vs `Files · send`) — requirement 4, "a press is never a surprise." The mode + is a per-workspace value (unlike the count, which is per-builder), so it is a scalar on the + overview. + +Binding invariant honoured throughout: the `feedback-*` / `send-queue` verbs mutate the queue +**only through `ReviewQueueStore`** (`apps/vscode/src/review-queue/store.ts`) — the same store +the inline threads, status bar, and Submit Review already use — so every surface reflects +deck-driven changes for free. (Tower *reads* the queue file to count it for the overview; that +is a read of the file the store owns, consistent with how `discoverBuilders` already reads +worktree files, not a parallel write path.) + +## Layer integration — the shared-selection coherence model + +Row 1/Row 2 bind to the **shared cursor** (`selectedBuilder()`); the review dials bind to the +**focused editor / MRU canvas**. These are two different anchors, and the design's integrity is +one invariant: **`selectedBuilder()` == the builder whose artifact is focused.** Where each +surface's target actually resolves (verified against the code): + +| Surface | Acts on | Anchor | +|---|---|---| +| Row 1 selector | shared cursor (windowed); press = **select + open** | `syncToBuilder(b.id)` + `open-* [b.id]` (`actions.ts:131-133`) | +| Row 2 palette | `selectedBuilder().id`, builder-id verbs | `run-dev`/`approve-gate`/`send-queue` + id | +| Dial **mode** (diff/canvas) | `selectedBuilder().phase` | `reviewMode(selectedBuilder())` (`actions.ts:590`) | +| Dial **action — diff** | **focused / last-opened diff's builder**, not selection | `resolveDiffContext` via `getDiffInjectEntry` + `lastPosition` (`commands/diff-nav.ts`) | +| Dial **action — canvas** | workspace **MRU canvas** | `sendCanvasCommand({ workspace })` (`actions.ts:655`) | +| `feedback-*` **write** | **focused diff's owner** (artifact-anchored — correct) | `getDiffInjectEntry(activeEditor).builderId` (`extension.ts:1217-1240`) | +| Send Fb flush + badge | `selectedBuilder().id` | new Row 2 action | + +**Two mechanisms hold the invariant, so all surfaces converge on one builder:** + +1. **Row 1 press is select + open in one gesture** (`actions.ts:131-133`) — it moves the cursor + *and* opens that builder's diff/canvas, so the dials' focused artifact becomes that builder's. + The press calls `syncToBuilder` directly, so it converges even for canvas (no hook needed). + This mirrors requirement 2's "dials collect, key commits": the Select dial scrolls/previews + the window; **pressing a Row 1 key is what commits + opens** — you cannot act on a builder you + have not opened. +2. **Focusing a diff back-syncs the cursor.** `onDidChangeActiveTextEditor` → + `announceActiveBuilderFromEditor` fires `builder-active` with the focused diff's builder id + (`extension.ts:679-685`) → deep link → deck `syncToBuilder` (`plugin.ts:56-64`). So clicking + builder 3's diff in VSCode snaps the deck selection to 3; mode, Row 2, and the Row 1 highlight + realign to what is viewed. + +**Send Fb (N) internal consistency:** the badge (`queuedFeedback[selectedId]`) and the flush +(`send-queue [selectedId]`) are keyed to the **same** `selectedBuilder()`, so they can never +badge-one / flush-another. The `feedback-*` write is artifact-anchored (focused diff owner) and +agrees with Send Fb in steady state because focus == selection; the only divergence is the +unnatural "rotate Select dial without opening", which the next press/open closes. A test asserts +this (Test Plan). + +**Two edges (E1 fixed by docs, E2 needs an owner call):** + +- **(E1) The focus→deck back-sync requires a configured activity hook.** `builder-active` only + reaches the deck if the personal-config hook (`on:['builder-active'] → streamdeck://…/active`) + exists (`activity-hooks.ts:37-57`, resolved from `~/.codev/config.json` only, for security). + Without it, only deck-driven selection (Row 1 press / Select dial) moves the cursor — VSCode + focus changes won't. **Setup prerequisite** for the hardware dev-approval session; documented in + the deck README. Not a code change. +- **(E2) Canvas focus back-sync — RESOLVED: Amr chose (b), a symmetric back-sync.** + `announceActiveBuilderFromEditor` gates on `getDiffInjectEntry` (`extension.ts:682-683`), and a + spec/plan canvas is not a diff-inject file, so today no back-sync fires when a canvas is + focused. #1410 adds the symmetric path so focusing a builder's canvas moves the deck cursor to + it, exactly as focusing its diff does — see §F below. + +## Proposed Change + +### A. Wire contract (`packages/types`) — routes to `main` + +`packages/types/src/api.ts`: +- Add to `OverviewData`: + - `queuedFeedback: Record` — per-builder queued review-comment counts, keyed + by `OverviewBuilder.id`. Absent builders read as 0. **Map, never a scalar** (binding + constraint 1). Required-with-default `{}` so consumers never branch on `undefined` (mirrors + the `heldCount` / `architects` "never undefined" convention). + - `feedbackMode: 'forward' | 'queue'` — the workspace's current feedback delivery mode, + projected from `codev.diffCodelensMode` (`comment` → `queue`, `forward` → `forward`). + Default `'forward'` (matches `getDiffCodelensMode`'s default). + +Naming (`queuedFeedback`, `feedbackMode`, and the `'forward' | 'queue'` union) is the section +that routes to `main` for confirmation before the gate — happy to rename to the stakeholders' +preference. + +### B. Tower overview (`packages/codev`) — routes to `main` + +`packages/codev/src/agent-farm/servers/overview.ts`, in `getOverview`: +- **queuedFeedback**: for each discovered builder, read + `/.codev/pending-comments.json` and count its `comments[]` (tolerant parse: a + missing/corrupt file = 0; identical tolerance to `parseQueueFile`). Build the + `Record`; include only non-zero entries (or all — decide with `main`). This + is a synchronous small-file read per builder, gated behind the same worktree scan + `discoverBuilders` already does — no new fetch/round-trip. The JSON shape + (`{version, builderId, comments}`) is stable; I'll parse it inline (the `packages/codev` + server cannot import `apps/vscode`), counting `comments.length` defensively. +- **feedbackMode**: read `/.vscode/settings.json` for `codev.diffCodelensMode`; + map to `'forward' | 'queue'`; default `'forward'` when the file/key is absent or unreadable. + (See Risks for the multi-root / settings-scope caveat — this is the other point I want + `main`'s read on.) + +### C. VSCode command relay (`apps/vscode`) — routes to `main` + +`apps/vscode/src/command-relay.ts` — add to the `VERB_COMMANDS` allowlist: +- `feedback-file` → `codev.feedbackCurrentFileToBuilder` +- `feedback-hunk` → `codev.feedbackCurrentHunkToBuilder` +- `feedback-selection` → `codev.feedbackSelectionToBuilder` +- `send-queue` → `codev.submitReview` (existing command; `submitReview` already resolves the + target builder from the active diff / sole-pending / QuickPick, and accepts a `builderIdArg` + — the relay forwards the selected builder id). + +`apps/vscode/src/extension.ts` — three new commands, each a thin mode-router that reuses the +existing forward helpers and the queue store: +- Each resolves the diff-inject entry (`getDiffInjectEntry`) exactly as the `forward*` commands + do today (`extension.ts:1180-1240`). +- If `getDiffCodelensMode() === 'forward'`: delegate to the existing immediate-forward path + (`codev.forwardCurrentFileToBuilder` / `…Hunk…` / `…Selection…`) unchanged. +- If `=== 'comment'`: build a `PendingComment` (`randomUUID`, `createdAt`, `file = entry.relPath`, + `lineRange` from the hunk/selection or `null` for whole-file, `body` = a short deck-origin + reference marker — the same ref text `buildBuilderFileRef`/`buildBuilderRangeRef` produce, so + the queued item is self-describing without typed prose) and call + `reviewQueueStore.add(builderId, comment)` — **the single-source-of-truth mutation** + (binding constraint 2). The store fires `onDidChangeQueue`, so the status bar and any inline + threads update, and Tower's next overview reflects the new count. + +Rationale for new commands rather than overloading the `forward*` ones: the `forward*` commands +are still used by the CodeLens forward path and the `forward-*` verbs may remain for any +explicit-immediate binding; the mode-router is the deck's entry point. (I'll confirm with `main` +whether the old `forward-file`/`forward-hunk` verbs should be retired from the allowlist once +the deck stops using them — leaning yes, to avoid a dead immediate-only path.) + +### D. Deck: Row 2 palette + dial repoint + mode label (`apps/streamdeck`) — streamdeck lane + +**Row 1 windowing (requirement 1 — reach a fleet larger than 4).** Today `slotBuilder` +(`actions.ts:82`) resolves a key's PI `slot` to a **fixed absolute index** (`builders()[slot-1]`), +so Row 1 only ever shows builders 1-4. #1410 turns Row 1 into a **4-wide window that follows the +selection**: + +- The visible page is **derived from the shared cursor** (no new stored offset, so nothing can + desync): `page = Math.floor(store.cursor.builder / 4)`; slot *i* (0-3) renders + `builders()[page*4 + i]`. Add a `store.windowedBuilder(slotIndex)` reader; `slotBuilder` + (or its Row 1 caller) switches to it. The per-key `slot` setting now means **position within + the window** (1-4), not an absolute index — shape-compatible with existing profiles. +- The **Select dial** (`ZoomNav` rotate → `store.rotateCursor`) already walks `cursor.builder` + across the whole fleet; because the page derives from the cursor, rotating past index 3 flips + Row 1 to builders 5-8, then 9-N (trailing slots render the existing `{kind:'empty'}` face). + No new gesture. +- **Selected-slot highlight**: the slot whose builder is `selectedBuilder()` gets an accent + (border / brighter ground) via a `selected` flag on the face, so the live builder is + unmistakable among the four. `face.ts` gains that accent branch. +- This is a **page**, not a per-tick slide: the four keys stay put between 4-boundaries (only the + highlight moves), preserving muscle memory; a page flips only when the selection crosses a + boundary. A page flip changes *which builder* each selector shows, never a key's *purpose* — so + it is not the modal key-reflow requirement 1 rejects. (*Owner alternative:* a sliding window + that pins the selection to a fixed edge slot and repaints every tick — worse muscle memory; + offered if preferred.) + +`apps/streamdeck/src/actions.ts`: +- **Dial press → feedback-\*** (requirement 2): in `DiffSpec.forward`, change + `forward-file` → `feedback-file` (`DiffFileNav`) and `forward-hunk` → `feedback-hunk` + (`DiffHunkNav`). `ScrollNav.onDialDown`: `forward-selection` → `feedback-selection`. Rotate + and tap verbs unchanged. +- **Touchstrip mode label** (requirement 4): in `ReviewNav.renderTo`, when in diff mode append + the mode to line 1: `Files · queue` / `Files · send` (and `Changes · …`), read from + `store.feedbackMode()`. Canvas mode is unaffected (it already pairs axis · press meaning). +- **Row 2 [Approve gate]** (per owner decision above; recommended = selected-scoped): repurpose + the existing `ApproveGate` action to target `store.selectedBuilder()` and relay + `approve-gate [selectedId]`; render the selected builder's gate state (inert/alert when the + selected builder is not blocked). *(If the owner keeps the singleton, this becomes a new + action instead — see decision.)* +- **Row 2 [Run Dev]**: reuse the existing `DevServerAction` (already selected-scoped + `run-dev [selectedId]`) — placed in the Row 2 profile slot. No code change. +- **Row 2 [Send Fb (N)]** (requirement 3): new `SendQueueAction` — badge `N = + store.queuedFeedback(selectedId)`; press relays `send-queue [selectedId]`; inert (`showAlert`, + no send) when `N === 0`. Face built via `face.ts` (a new `sendFbFaceSvg(n)` twin of + `gatesFaceSvg`). +- **Row 2 [Open Terminal]** (revised — per the note up top): new `OpenTerminalAction`, a + `VerbKey` like `DevServerAction`, relaying `open-terminal [selectedId]` — the per-builder + complement to the Builder Action's open-artifact. The fleet jump-to-next + gate-count are + dropped (covered by the Row 1 window's gate-colored faces + the Zoom dial's `N⚠` count), so + `topGateBuilderId()` and `gatesFaceSvg` are removed as dead. + +`apps/streamdeck/src/store.ts`: add `feedbackMode()` and `queuedFeedback(builderId)` readers off +`this.overview` (defaulting to `'forward'` / `0`), and `windowedBuilder(slotIndex)` (the +cursor-derived 4-wide window reader for Row 1). + +`apps/streamdeck/src/face.ts`: add `sendFbFaceSvg(n)` (and, if Next/attention needs a distinct +glyph, a small addition) reusing the existing composite frame — accepted twin pattern; plus a +`selected`-slot accent branch on the builder face for the Row 1 highlight. + +`apps/streamdeck/com.cluesmith.codev.sdPlugin/manifest.json`: add the `send-queue` action +(and `next-attention` action) UUIDs, Keypad controllers, icons; remove the retired singleton if +the owner approves retirement. + +`apps/streamdeck/com.cluesmith.codev.sdPlugin/Codev.streamDeckProfile` (a zip): unzip, lay out +the 8 keys — Row 1 = 4× `builder-action` (slots 1-4), Row 2 = `approve-gate`, `dev-server`, +`send-queue`, `next-attention` — rezip. Verified visually at the hardware dev-approval session. + +`apps/streamdeck/src/plugin.ts`: register the new action(s). + +### F. Canvas focus back-sync (`apps/vscode`) — routes to `main` (E2 = (b)) + +Give a focused **canvas** the same back-sync a focused diff already has, so opening/focusing a +builder's spec/plan/review canvas moves the deck cursor to that builder — the symmetric +counterpart of `announceActiveBuilderFromEditor`. + +- **Detection:** the canvas panel already emits `panel.onDidChangeViewState` and heartbeats + `active` to Tower (`markdown-preview/canvas-view-registry.ts:141-143`). The + `MarkdownPreviewProvider` (`markdown-preview/preview-provider.ts`) owns the panel + the document + `file` and is already constructed with `overviewCache` — so it is the natural place to fire the + back-sync on panel-active. +- **Builder resolution:** `viewPlanFile`/`viewSpecFile`/`viewReviewFile` open the artifact + **inside the builder's worktree** (`commands/view-artifact.ts:83,128` → + `/codev//-.md`). So the owning builder is the one whose + `worktreePath` is a **path prefix** of the canvas `file` — matched against + `overviewCache.getData().builders` (robust; no filename parsing). A canvas outside any builder + worktree (a main-repo artifact) resolves to none → no fire (correct: it isn't a builder's). +- **Emit:** reuse the existing **`builder-active`** event (not a new `canvas-active`): + `fireActivity(workspace, 'builder-active', { builder: b.id })`. This rides the *same* + personal-config hook the diff path already needs (E1) — **no new hook to configure** — reaches + the deck via the same deep link → `syncToBuilder`, and is deduped by `fireActivity`'s + `lastFiredKey`. No loop: `syncToBuilder` only moves the deck cursor, it sends nothing back to + VSCode. *(If Amr later wants canvas focus routed to a distinct destination, splitting out a + `canvas-active` event is a one-line follow-up — but reusing `builder-active` is the + lower-config, fully-symmetric choice, so this plan does that.)* + +### E. Tests, README, docs + +- Unit: deck `store` readers (`feedbackMode`/`queuedFeedback`/`windowedBuilder`); Row 1 window + paging + selected-slot highlight (page derivation, trailing-empty slots, boundary flip); + `SendQueueAction` inert-at-0 vs send; `OpenTerminalAction` opens the selected builder; `ReviewNav` touchstrip + mode-label; dial press relays `feedback-*` (extend `actions.test.ts`). +- Unit: VSCode mode-router commands (forward → delegates; comment → `store.add` with the right + `PendingComment`); relay allowlist includes the new verbs and excludes an options 2nd arg + where relevant. +- Unit: Tower overview populates `queuedFeedback` from queue files and `feedbackMode` from + settings (fixture worktrees, matching the existing overview test style). +- Unit: the shared-selection invariant — Row 1/Row 2/dial-mode all read the same + `selectedBuilder()`; Send Fb badge source == flush target; a diff-focus `builder-active` + (VSCode side) fires with the focused diff's builder id (`announceActiveBuilderFromEditor`). +- Unit (§F): canvas focus resolves the owning builder by `worktreePath`-prefix and fires + `builder-active` with its id; a canvas outside any worktree fires nothing. +- `apps/streamdeck/README.md`: document the two-zone layout, the `feedback-*`/`send-queue` + verbs, the mode-follows-setting behaviour (replace the `forward-file`/`forward-hunk` mention at + README:120), the shared-selection coherence model (Row 1 press = select+open; diff focus + back-syncs), and **the `builder-active` activity-hook prerequisite (E1)** for the VSCode→deck + focus sync. + +## Files to Change + +- `packages/types/src/api.ts` — `OverviewData.queuedFeedback` map + `feedbackMode`. *(→ main)* +- `packages/codev/src/agent-farm/servers/overview.ts:801+` — populate both. *(→ main)* +- `packages/codev/src/agent-farm/__tests__/…` — overview wire tests. *(→ main)* +- `apps/vscode/src/command-relay.ts:24-61` — allowlist `feedback-*` + `send-queue`. *(→ main)* +- `apps/vscode/src/extension.ts:~1217-1240` — 3 mode-router commands. *(→ main)* +- `apps/vscode/src/markdown-preview/preview-provider.ts` — canvas focus back-sync: fire + `builder-active` for the owning builder (§F). *(→ main)* +- `apps/vscode/src/__tests__/…` — relay + mode-router + canvas-back-sync tests. *(→ main)* +- `apps/streamdeck/src/actions.ts` — Row 1 windowing (`windowedBuilder` + selected highlight), + dial press verbs, touchstrip label, Approve repurpose, `SendQueueAction`, `OpenTerminalAction`. +- `apps/streamdeck/src/store.ts` — `feedbackMode()`, `queuedFeedback()`, `windowedBuilder()`. +- `apps/streamdeck/src/face.ts` — `sendFbFaceSvg` (+ any Next glyph), selected-slot accent. +- `apps/streamdeck/src/plugin.ts` — register new actions. +- `apps/streamdeck/com.cluesmith.codev.sdPlugin/manifest.json` — new action(s), retire singleton. +- `apps/streamdeck/com.cluesmith.codev.sdPlugin/Codev.streamDeckProfile` — 8-key layout (zip). +- `apps/streamdeck/src/__tests__/actions.test.ts` (+ store/face tests) — deck-side coverage. +- `apps/streamdeck/README.md` — layout + verbs + mode behaviour. + +## Risks & Alternatives Considered + +- **Risk — `feedbackMode` sourcing from `.vscode/settings.json`.** `diffCodelensMode` is written + with `ConfigurationTarget.Workspace`, i.e. `/.vscode/settings.json` for a single-folder + workspace (codev's normal case). A multi-root `.code-workspace` file or a user-level override + would not be at that path, so the deck would show the default `send` label while VSCode is + actually in `comment` mode. Mitigation: default to `'forward'`; document the single-folder + assumption; this is the point I most want `main`'s ruling on. *Alternative:* have VSCode push + its mode to Tower (a small state write or a relay-back channel) so the deck reads an + authoritative push instead of Tower guessing from a file — heavier machinery; rejected for v1 + unless `main` prefers it. +- **Risk — retiring the `ApproveGate` singleton** could disrupt an existing user profile that + pins it. Mitigation: SD+-only, pre-release; the profile is updated in the same PR and verified + on hardware. Gated on the owner decision above. +- **Risk — enqueued deck comment has no typed body.** A dial press carries no prose, so the + queued `PendingComment.body` is a reference marker only. Mitigation: use the same + self-describing ref text the forward path injects; the reviewer can still edit it via the + existing inline-thread edit flow before Submit. *Alternative:* block enqueue without a body — + rejected; it would make the dial press useless in queue mode. +- **Risk — `forward-*` verbs left dangling.** Keeping them alive is a dead immediate-only path + once the deck moves to `feedback-*`. Leaning to retire them from the allowlist with `main`'s + sign-off; keeping them is the low-risk fallback. +- **Alternative rejected (per issue req 1):** activating a builder to re-flow all 8 keys into a + per-builder modal. Kills muscle memory, ambiguous state. The two fixed zones are the design. + +## Test Plan + +Reviewer verifies at the **dev-approval gate on real SD+ hardware** (this is why the issue is +PIR). **Prerequisite (E1):** the `builder-active` → `streamdeck://…/active` activity hook must be +configured in `~/.codev/config.json` for the VSCode→deck focus sync — the session confirms it is +present before testing the coherence steps below: + +- **Unit / CI:** `pnpm -C apps/streamdeck test`, `pnpm -C apps/vscode test`, + `pnpm -C packages/codev test`, plus a full build. All green before the gate. +- **Manual (hardware), two-zone layout + >4 fleet:** with ≥6 (ideally 10) live builders, Row 1 + shows a 4-wide window; the selected builder's slot is highlighted. Rotating the Select dial + (ZoomNav) past the 4th builder flips Row 1 to builders 5-8, then 9-N (trailing slots empty) — + the four keys stay put between page boundaries (no per-tick reshuffle), and each still means + "selector". Pressing any visible slot selects that builder (dials + Row 2 re-target it) and + opens its phase artifact. Builders past the window are still fully actionable: dial-select → + touchstrip tap opens the artifact, Row 2 acts on them. +- **Manual — Row 2 palette on the selected builder:** [Run Dev] starts its worktree dev; + [Approve gate] pops the confirmation modal for the *selected* builder (approve → `porch + approve` runs); [Open Terminal] opens the selected builder's terminal; [Send Fb (N)] is inert at N=0. +- **Manual — dials collect / mode legibility:** with `diffCodelensMode = comment`, the dial + touchstrip reads `Files · queue` / `Changes · queue`; a diff dial press enqueues (the Send Fb + badge `N` on the selected builder increments, the VSCode status bar / inline thread updates — + proving the mutation went through `ReviewQueueStore`). Set `diffCodelensMode = forward`: the + touchstrip flips to `· send`, a dial press injects into the builder terminal immediately, and + `N` stays 0. +- **Manual — flush:** in `comment` mode, queue 2-3 chunks, then press [Send Fb (N)] → the + batched review message lands in the selected builder's prompt buffer (VSCode's Submit Review + flow), and `N` returns to 0. +- **Manual — overview wire:** confirm the per-builder counts are correct across ≥2 builders + simultaneously (each key's `N` is its own builder's count, proving the map — not a scalar). +- **Manual — layer coherence (the crux):** select builder A on Row 1 → its diff opens, dials + + Row 2 act on A. Click builder B's diff *in VSCode* → the deck selection snaps to B (Row 1 + highlight, Row 2 target, and dial mode all move to B — proving the `builder-active` back-sync). + Enqueue a chunk with a diff dial, then flush with Send Fb — it targets the same builder you + were viewing. Verify the Select-dial-rotate-without-open transient self-heals on the next Row 1 + press / tap-to-open. +- **Manual — canvas back-sync (§F, E2=(b)):** with a spec/plan-phase builder, open builder A's + plan canvas, then focus builder B's plan canvas *in VSCode* → the deck selection snaps to B + (mode stays canvas; Row 1 highlight + Row 2 move to B) — proving the symmetric canvas + `builder-active` fires and rides the same hook as the diff path. diff --git a/codev/projects/1410-stream-deck-sd-two-zone-builde/1410-review-iter1-rebuttals.md b/codev/projects/1410-stream-deck-sd-two-zone-builde/1410-review-iter1-rebuttals.md new file mode 100644 index 000000000..b1898da1d --- /dev/null +++ b/codev/projects/1410-stream-deck-sd-two-zone-builde/1410-review-iter1-rebuttals.md @@ -0,0 +1,25 @@ +# PIR #1410 — Rebuttals to iteration-1 consultation + +Verdicts: **Gemini APPROVE**, **Claude APPROVE**, **Codex REQUEST_CHANGES** (2 points). + +## Codex point 2 — `feedbackMode` / `queuedFeedback` lack a reliable refresh trigger — ACCEPTED, FIXED + +**Codex is correct.** I verified the refresh path against the code: + +- The deck refreshes its overview on any SSE envelope (`store.ts` `onEnvelope → refresh()`). +- `status.yaml`/phase changes reach the deck because **porch broadcasts `overview-changed` after every mutating command** (`commands/porch/index.ts:1240`). +- Queue files are written by VSCode's `ReviewQueueStore`, which **never notifies Tower**, and Tower has **no watcher** on `pending-comments.json` or `.vscode/settings.json`. So `queuedFeedback` and `feedbackMode` had **no deterministic push** — the badge/label only refreshed when some unrelated SSE event fired, and the deck's refresh-on-command-echo could race ahead of the queue write (reading the stale, pre-write count). Both of Codex's failure modes are real. + +**Fix** (`apps/vscode/src/review-queue/overview-nudge.ts`, wired in `extension.ts`): on the two out-of-band mutations — a `ReviewQueueStore.onDidChangeQueue` (covers deck enqueue, Send Fb flush, discard, and cross-window writes) and a `codev.diffCodelensMode` configuration change — VSCode calls `TowerClient.refreshOverview()`. That POSTs `/api/overview/refresh`, which invalidates Tower's overview cache **and broadcasts `overview-changed`** — the existing mechanism built for exactly "out-of-band mutations invisible until some other SSE event happens to fire" (`tower-client.ts:613`). It fires **after** the write, so the deck (and dashboard) re-fetch the fresh values deterministically, eliminating the race and the lag. + +**Regression test** (`apps/vscode/src/__tests__/overview-nudge.test.ts`, 3 cases): a queue mutation nudges Tower; a `diffCodelensMode` change nudges it while an unrelated setting does not; and it's a no-op (no throw) when Tower is disconnected. These fail without the wiring. Full vscode suite green (825). + +## Codex point 1 — `Codev.streamDeckProfile` not updated — REBUTTED (deliberate, human-approved on hardware) + +Not a defect — a **deliberate, documented scope decision**, already reviewed and accepted: + +- The profile has **always** shipped `Actions: null` (verified in git history: #1404 shipped Row 1 the same way). It is a device-model scaffold; adopters place keys themselves. There is **no known-good `sdProfile` Actions schema in this repo's history** to pre-populate from, and a hand-authored binary profile that fails to import would break at the exact moment (the hardware session) it matters — strictly worse than the valid blank. +- The primary workflow is **not** unshipped: it was verified on real SD+ hardware at the `dev-approval` gate, where the human placed the eight keys per the README's documented two-zone layout and exercised selection, dial-collect/flush, mode label, and the Row 2 palette. The plan was revised (with a visible note) and the review documents this under "Things to Look At." +- This is escalated to the human at the `pr` gate (they already ruled on it at `dev-approval`). A pre-populated importable profile remains a reasonable follow-up **iff** someone can verify the import on hardware first. + +**Note on process:** PIR consultation is single-pass — neither point gets an independent AI re-review. Point 2's fix is backed by its regression test + the human's `pr`-gate review; point 1 is the human's call, already made once at `dev-approval`. diff --git a/codev/projects/1410-stream-deck-sd-two-zone-builde/status.yaml b/codev/projects/1410-stream-deck-sd-two-zone-builde/status.yaml new file mode 100644 index 000000000..aa2636a52 --- /dev/null +++ b/codev/projects/1410-stream-deck-sd-two-zone-builde/status.yaml @@ -0,0 +1,30 @@ +id: '1410' +title: stream-deck-sd-two-zone-builde +protocol: pir +phase: verified +plan_phases: [] +current_plan_phase: null +gates: + plan-approval: + status: approved + requested_at: '2026-08-12T21:50:14.728Z' + approved_at: '2026-08-12T22:48:22.209Z' + dev-approval: + status: approved + requested_at: '2026-08-12T23:13:38.172Z' + approved_at: '2026-08-13T00:52:01.898Z' + pr: + status: approved + requested_at: '2026-08-13T01:06:27.170Z' + approved_at: '2026-08-13T01:10:37.419Z' +iteration: 1 +build_complete: true +history: [] +started_at: '2026-08-12T21:43:07.730Z' +updated_at: '2026-08-13T01:11:00.401Z' +pr_history: + - phase: review + pr_number: 1439 + branch: builder/pir-1410 + created_at: '2026-08-13T00:55:10.513Z' +pr_ready_for_human: false diff --git a/codev/resources/arch.md b/codev/resources/arch.md index d77607763..1d8f29e63 100644 --- a/codev/resources/arch.md +++ b/codev/resources/arch.md @@ -2025,6 +2025,28 @@ The command vocabulary lives in `@cluesmith/codev-types` (`canvas-command.ts`) a Tower, the sdk and the canvas package each keep a local `satisfies`-bound copy of any runtime list because codev-types is type-only for all three. +### Stream Deck ↔ VSCode shared-selection coherence (Spec 1410) + +The deck is a remote and VSCode is the screen, bound by **one shared selection**. Two surfaces have +*different* anchors, and keeping them equal is the invariant future deck/vscode work must preserve: + +- **Row 1/Row 2 keys** act on the deck's shared cursor (`selectedBuilder()`); the **review dials** + act on VSCode's *focused* editor / MRU canvas. They stay the same builder because (a) a Row 1 press + is *select + open* in one gesture, and (b) focusing a builder artifact fires the **`builder-active` + activity hook** → deep link → the deck's `syncToBuilder`. Spec 1410 extends that back-sync to a + focused **canvas** (`preview-provider.ts`, resolving the owner by worktree-path prefix), not only a + diff (`announceActiveBuilderFromEditor`) — so canvas focus moves the deck selection too. The hook is + personal-config (`~/.codev/config.json`, `activityHooks`), so the VSCode→deck direction is opt-in. +- **Review feedback is mode-neutral at the deck, mode-routed in VSCode.** A diff-dial press relays + `feedback-file`/`feedback-hunk`/`feedback-selection`; VSCode forwards immediately or enqueues per the + `codev.diffCodelensMode` workspace setting, and `send-queue` flushes. **All queue mutations go through + `ReviewQueueStore`** (the single source of truth, Spec 1037) — never a parallel path — so every surface + reflects deck-driven changes. Tower only *reads* the queue file to project the count. +- **Overview wire (`OverviewData`)** carries `queuedFeedback: Record` (a per-builder + **map**, never a scalar — the deck badge and #1049's Attention rollup both index it) and + `feedbackMode: 'forward' | 'queue'` (per-workspace scalar, read from `.vscode/settings.json`, + single-folder-workspace only, defaults to `forward`). + ### Internal Dependencies - **Git**: Version control, worktrees for builder isolation - **Node.js**: Runtime for agent-farm TypeScript CLI diff --git a/codev/resources/lessons-learned.md b/codev/resources/lessons-learned.md index 40aa18926..943ce16c7 100644 --- a/codev/resources/lessons-learned.md +++ b/codev/resources/lessons-learned.md @@ -351,7 +351,7 @@ Generalizable wisdom extracted from review documents, ordered by impact. Updated - [From 1313] When a spec names a specific repro, the automated e2e must exercise *that* scenario, not an adjacent easy one. Phase 4's first e2e checked an inert shell yielding `held/no-profile` instead of the #1265 draft→held(busy)→submit→clean-delivery cycle; Codex blocked until the real cycle was driven end-to-end via a subprocess harness. - [From 1313] Validate a screen/output classifier against REAL captured terminal output across real app states, not synthesized fixtures. The render-gate passed every phase exercised only against a *synthesized* `claude-idle` fixture (the sandbox `claude` was a proxy shim that never rendered the true idle screen), so two field false-`busy` defects — a background-task panel displacing the composer's region boundary, and a >1MB ring torn by a fixed tail-slice — surfaced only during live install testing *after* the pr gate, forcing a verify→implement rollback. Synthesized fixtures encode the author's assumptions about layout; capture the real ring (gzip it into the repo if large) so the classifier is proven against states you didn't anticipate. -- [From #1414] A Stream Deck feature spans TWO independently-loaded artifacts — the deck plugin bundle (`streamdeck link` → `apps/streamdeck/…sdPlugin`) and the VSCode extension (the command PROVIDER). Hardware verification requires BOTH to be on the branch under test; either one stale silently masks the change. The trap: `streamdeck link` is global app state, so a sibling worktree's live symlink (here pir-1425) keeps serving its OLD `actions.ts`, and the deck fires the pre-change verb even though your branch's bundle is built. Diagnose before reinstalling: `streamdeck list` shows which checkout is linked; the aggregate-vs-nothing symptom localizes the stale half (old deck fires `view-diff` → aggregate opens; new deck + old extension fires an unknown verb → nothing opens). Relink the deck to the test worktree AND install the branch's extension vsix (or run its Extension Development Host, focused), then hand the deck back to the sibling's symlink when done. +- [From #1414] A Stream Deck feature spans TWO independently-loaded artifacts — the deck plugin bundle (`streamdeck link` → `apps/streamdeck/…sdPlugin`) and the VSCode extension (the command PROVIDER). Hardware verification requires BOTH to be on the branch under test; either one stale silently masks the change. The trap: `streamdeck link` is global app state, so a sibling worktree's live symlink (here pir-1425) keeps serving its OLD `actions.ts`, and the deck fires the pre-change verb even though your branch's bundle is built. Diagnose before reinstalling: `streamdeck list` shows which checkout is linked; the aggregate-vs-nothing symptom localizes the stale half (old deck fires `view-diff` → aggregate opens; new deck + old extension fires an unknown verb → nothing opens). Relink the deck to the test worktree AND install the branch's extension vsix (or run its Extension Development Host, focused), then hand the deck back to the sibling's symlink when done. [Sharpened by #1410] *Why the bundle can be silently absent:* the root `pnpm build` builds only the published `@cluesmith/codev` package + its deps — NOT `apps/streamdeck` / `apps/vscode` (they're never npm-published; CI builds them in dedicated jobs, per the comment in `test.yml`). So after `streamdeck link` a fresh worktree may have no `bin/plugin.js` (`CodePath`) at all and the plugin renders nothing — build it explicitly with `pnpm --filter @cluesmith/codev-streamdeck build` (the extension likewise via its own `vsix`/build). "Ran `pnpm build`" does not mean the apps are built. ## UI/UX diff --git a/codev/reviews/1410-stream-deck-sd-two-zone-builde.md b/codev/reviews/1410-stream-deck-sd-two-zone-builde.md new file mode 100644 index 000000000..43ea1fbda --- /dev/null +++ b/codev/reviews/1410-stream-deck-sd-two-zone-builde.md @@ -0,0 +1,97 @@ +# PIR Review: Stream Deck SD+ two-zone builder workflow (selectors + action palette, dial-driven feedback queue) + +Fixes #1410 + +## Summary + +Turns the SD+ into a two-zone remote bound by one shared selection: **Row 1** is a 4-wide *window* of fleet-selector keys (scrolled by the Select dial, so a fleet larger than four is reachable), and **Row 2** is a fixed, uniformly per-builder action palette — **Approve · Dev · Send Fb (N) · Open Terminal** — always acting on the selected builder. The diff dials move from immediate `forward-*` to a mode-neutral `feedback-*` verb that VSCode routes forward-now or into the per-builder review queue per the `codev.diffCodelensMode` setting, with a new `send-queue` flush and a per-builder queued-count badge; the overview wire gains a `queuedFeedback` map + a `feedbackMode` scalar, and focusing a builder's spec/plan/review canvas now back-syncs the deck selection the same way focusing its diff already did. + +## Files Changed + +- `packages/types/src/api.ts` (+20 / -0) — `OverviewData.queuedFeedback` map + `feedbackMode` +- `packages/codev/src/agent-farm/servers/overview.ts` (+70 / -4) — populate both (read queue files + `.vscode/settings.json`) +- `packages/codev/src/agent-farm/servers/tower-routes.ts` (+1 / -1) — no-workspace fallback defaults +- `packages/codev/src/agent-farm/__tests__/overview.test.ts` (+62 / -0) — `countQueuedFeedback` / `readFeedbackMode` +- `apps/vscode/src/command-relay.ts` (+8 / -0) — allowlist `feedback-*` + `send-queue` +- `apps/vscode/src/review-queue/feedback.ts` (+127 / -0, new) — mode-router (forward now vs enqueue via `ReviewQueueStore`) +- `apps/vscode/src/extension.ts` (+15 / -1) — register the 3 feedback commands; `submitReview` accepts a builder id +- `apps/vscode/src/markdown-preview/preview-provider.ts` (+33 / -0) — canvas focus back-sync +- `apps/vscode/src/markdown-preview/canvas-owner.ts` (+27 / -0, new) — pure canvas→builder resolver +- `apps/vscode/src/review-queue/overview-nudge.ts` (+40 / -0, new) — nudge Tower to rebuild+rebroadcast the overview on a queue/mode change (consultation fix) +- `apps/vscode/src/__tests__/feedback.test.ts` (+118, new), `command-relay.test.ts` (+16), `canvas-back-sync.test.ts` (+35, new), `overview-nudge.test.ts` (+3 cases, new) +- `apps/streamdeck/src/store.ts` (+40 / -4) — `feedbackMode` / `queuedFeedback` / `windowedBuilder` readers +- `apps/streamdeck/src/face.ts` (+45 / -12) — selected accent, `approveFaceSvg` / `sendFbFaceSvg`, `comment`/`terminal` glyphs +- `apps/streamdeck/src/actions.ts` (+110 / -30) — Row 1 windowing, dial `feedback-*`, touchstrip mode label, Row 2 palette +- `apps/streamdeck/src/plugin.ts` (+4) — register `SendQueueAction`, `OpenTerminalAction` +- `apps/streamdeck/com.cluesmith.codev.sdPlugin/manifest.json` (+22 / -10) — `send-queue` + `open-terminal` actions +- `apps/streamdeck/src/__tests__/actions.test.ts` (+140), `face.test.ts` (+30) — deck coverage +- `apps/streamdeck/README.md` (+80 / -35) — two-zone layout, feedback verbs, coherence model, activity-hook prerequisite + +(Diff-stat anchored at the merge-base; excludes the `origin/main` merge that brought in bugfix-1437's Dev Server face + silent-success `ack`, which this branch builds on.) + +## Commits + +- `729f359a6` Open Terminal: plain label face (no builder id), VerbKey like Dev Server +- `417f6bb8f` Row 2 key 4: replace Next/Attention with per-builder Open Terminal; drop fleet jump/count +- `bc2dffde9` Tests: feedback router, relay verbs, canvas owner, overview wire +- `0a1d403d2` Docs: two-zone layout, mode-neutral feedback, coherence model + activity-hook prerequisite +- `53c144473` Deck: Row 1 window, Row 2 palette, dial feedback verbs, mode label +- `e27db8252` VSCode: mode-neutral feedback verbs, send-queue flush, canvas back-sync +- `a1f8910f3` Overview wire: per-builder queuedFeedback map + feedbackMode +- `2901a0828` E2=(b): symmetric canvas focus back-sync +- (plus plan/thread commits and a `main` merge; full list via `git log main..HEAD`) + +## Test Results + +- `pnpm build` (full workspace): ✓ pass +- `pnpm --filter @cluesmith/codev-streamdeck test`: ✓ 125 pass +- `pnpm --filter codev-vscode test:unit`: ✓ 825 pass +- `pnpm --filter @cluesmith/codev test` (codev): ✓ 4856 pass / 48 skipped +- `streamdeck validate`: ✓ +- Manual verification: approved by the human at the **dev-approval** hardware SD+ session — the two-zone layout, dial-collect/Send-Fb flush in both delivery modes, the `Files · send`/`Files · queue` label, Row 2 Approve/Dev/Open-Terminal on the selected builder, and the diff/canvas focus back-sync were exercised on real hardware. + +## Architecture Updates + +**COLD** (`codev/resources/arch.md`, Integration Points): added a concise note recording the Stream-Deck↔VSCode **shared-selection coherence** invariant — the deck's selected builder and VSCode's focused artifact are kept equal by the `builder-active` activity hook (which this PR extends to fire for a focused *canvas*, not only a diff), and deck-driven review feedback mutates the queue only through `ReviewQueueStore`; the overview carries a per-builder `queuedFeedback` map + a `feedbackMode` scalar. + +**HOT**: no `arch-critical.md` change — this is a deck+vscode feature within existing module boundaries and the existing "outside-in controller" fact already frames it; it doesn't rise to an always-injected system-shape fact. + +## Lessons Learned Updates + +**COLD** (`codev/resources/lessons-learned.md`, Testing): sharpened the existing #1414 dual-artifact lesson with *why* the deck bundle can be silently absent on a fresh worktree — the root `pnpm build` intentionally builds only the published `@cluesmith/codev` package + its deps, **not** `apps/streamdeck` / `apps/vscode` (CI builds those in separate jobs), so a relinked plugin with no `bin/plugin.js` renders nothing until `pnpm --filter @cluesmith/codev-streamdeck build` is run. + +**HOT**: no `lessons-critical.md` change — the gotcha is a sharpening of an existing cold lesson, not a new always-on rule. + +## Things to Look At During PR Review + +- **[Consultation finding — Codex, fixed] Deterministic overview refresh for the badge + mode label.** + Tower projects `queuedFeedback` (from the queue files) and `feedbackMode` (from `.vscode/settings.json`) + into the overview but has no watcher on either, and `ReviewQueueStore` writes never notified Tower — so + the Send Fb badge / dial mode-label only refreshed on an unrelated SSE event, and the deck's + refresh-on-command-echo could race ahead of the queue write. Fixed by + `apps/vscode/src/review-queue/overview-nudge.ts` (wired in `extension.ts`): a queue mutation + (`onDidChangeQueue`) or a `codev.diffCodelensMode` change calls `TowerClient.refreshOverview()`, which + invalidates Tower's cache and broadcasts `overview-changed` *after* the write. Pinned by + `overview-nudge.test.ts` (fails without the wiring). +- **[Consultation finding — Codex, rebutted] The SD+ profile ships blank.** Deliberate: it has always + shipped `Actions: null` (#1404 shipped Row 1 the same way), there is no safe `sdProfile` Actions schema + to pre-populate, and the two-zone workflow was verified on hardware at `dev-approval` with the human + placing the keys. See the rebuttal doc; escalated to the human (who ruled on it at `dev-approval`). +- **Coherence anchors (the subtle part).** Row 1/Row 2 act on `selectedBuilder()`; the review dials act on the *focused* artifact; `feedback-*` writes attach to the *focused diff's* owner (correct — a comment must attach to the file in view) while Send Fb flushes `selectedBuilder()`. These agree because focusing a diff/canvas fires `builder-active` → `syncToBuilder`. The one transient divergence (rotate the Select dial without opening) self-heals on the next Row 1 press / focus. See the plan's "Layer integration" section. +- **`feedbackMode` sourcing.** Tower reads `/.vscode/settings.json` (JSONC-tolerant, defaults to `forward`). This is single-folder-workspace only; a multi-root `.code-workspace` or user-level override isn't at that path and reads as the default — an accepted limitation (the deck falls back to `send`, never a wrong-way write, since the mode only labels the dial). +- **`send-queue` → `codev.submitReview` id forwarding.** The status-bar button still calls it arg-less (resolves target itself); the deck passes `[selectedId]`. Confirm the `typeof builderId === 'string'` guard. +- **Profile ships blank (deliberate).** `Codev.streamDeckProfile` has always shipped `Actions: null` (as #1404's Row 1 did); the two-zone layout is documented in the README and placed at the hardware session. No known-good `sdProfile` Actions schema exists in history to safely pre-populate. +- **E1 prerequisite.** The VSCode→deck focus sync needs a `builder-active` activity hook in `~/.codev/config.json` (documented in the README). Without it, only deck-driven selection moves the cursor. + +## How to Test Locally + +For reviewers pulling the branch (note: the deck plugin + VSCode extension are **separate builds** from the root `pnpm build`): + +- **Build the surfaces**: `pnpm --filter @cluesmith/codev-streamdeck build` (plugin bundle), `pnpm --filter codev-vscode vsix` (extension), `pnpm -w run local-install` (CLI/Tower, for the badge/label wire). +- **View diff**: VSCode sidebar → right-click builder `pir-1410` → **Review Diff**. +- **Run dev**: VSCode sidebar → **Run Dev**, or `afx dev pir-1410`. +- **What to verify**: Row 1 window scrolls past 4 builders with the selected slot accented; a Row 1 press selects + opens; a diff-dial press enqueues (badge climbs) in `comment` mode and injects in `forward` mode, with the touchstrip naming the mode; Send Fb flushes; Row 2 Approve/Dev/Open-Terminal act on the selected builder; focusing a diff **or** a spec/plan canvas in VSCode moves the deck selection to it. + +## Flaky Tests + +None. diff --git a/codev/state/pir-1410_thread.md b/codev/state/pir-1410_thread.md new file mode 100644 index 000000000..14abec0af --- /dev/null +++ b/codev/state/pir-1410_thread.md @@ -0,0 +1,62 @@ +# pir-1410 — Stream Deck SD+ two-zone builder workflow + +## What this is +Issue #1410 (PIR). SD+ becomes a remote control bound by one shared selection: +- Row 1 (4 keys): fleet selectors = #1404 Builder Action keys (already exist). +- Row 2 (4 keys): action palette [Approve gate] [Run Dev] [Send Fb (N)] [Next/attention], always acting on the selected builder. +- Dials collect (press = `feedback-*` mode-neutral verb), Send Fb key commits (`send-queue`). +- Overview wire gains a per-builder queued-count map + a per-workspace feedback mode. + +## Architect binding constraints (comment 5272792465, agreed by both architect seats) +1. Overview wire field = per-builder MAP (builderId -> queuedCount), never a scalar. #1049's Attention rollup reuses the same map. +2. `feedback-*` / `send-queue` verbs MUST mutate through `ReviewQueueStore` (single source of truth) — no parallel path. +Process: (a) requirement 6 single-approve-affordance decision → owner (Amr) EARLY, with my recommendation; (b) relay-verb + overview-wire plan sections route to `main` pre-gate, whole plan to streamdeck architect pre-gate; (c) dev-approval is a hardware SD+ session. +Cautions: do NOT touch .builders/pir-1428 (live deck symlink) or sibling worktrees; #1431 (verify-approval in phaseArtifactVerb) is a separate lane — don't fix it here. + +## Ground-truth found (files) +- Deck actions: `apps/streamdeck/src/actions.ts` — `BuilderAction` (Row 1, slot-scoped, `syncToBuilder` on press), `DevServerAction` (selected-scoped `run-dev`), `ApproveGate` singleton (top-gate target + count badge), `ReviewNav`/`DiffFileNav`/`DiffHunkNav` (dial press currently `forward-file`/`forward-hunk`), `ScrollNav` (`forward-selection`). +- Deck store: `apps/streamdeck/src/store.ts` — `selectedBuilder()`, `pendingGates()`, `topGateBuilderId()`, `builders()`. +- Deck face: `apps/streamdeck/src/face.ts` (#1428 SVG face model — Row 2 keys build on this). +- Wire types: `packages/types/src/api.ts` — `OverviewBuilder` (has `heldCount?` precedent), `OverviewData`. +- Tower overview build: `packages/codev/src/agent-farm/servers/overview.ts` — `discoverBuilders` reads worktree files; `getOverview` assembles + per-builder `heldCount` (precedent for a per-builder count). +- Feedback mode setting: `codev.diffCodelensMode` = 'forward' | 'comment' (`apps/vscode/src/diff-inject-codelens.ts:getDiffCodelensMode`). 'forward' = immediate inject, 'comment' = queue. +- Command relay allowlist: `apps/vscode/src/command-relay.ts` (VERB_COMMANDS map). +- Queue store: `apps/vscode/src/review-queue/store.ts` (`ReviewQueueStore.add/remove/count`), file `.codev/pending-comments.json` shape `{version:1,builderId,comments:PendingComment[]}` (`review-queue/queue.ts`). +- Submit/flush: `apps/vscode/src/review-queue/submit.ts` (`submitReview` → PTY batch, removes via store). +- Approve: `apps/vscode/src/commands/approve.ts` (`approve-gate [builderId]` → confirmation modal; selected-scoped is just a different id arg). + +## Implementation (implement phase, plan-approval passed) +Commits on builder/pir-1410: +- Overview wire: OverviewData.queuedFeedback (Record, map not scalar) + feedbackMode ('forward'|'queue'). packages/types/src/api.ts. +- Tower overview: countQueuedFeedback (reads each builder's .codev/pending-comments.json) + readFeedbackMode (reads /.vscode/settings.json codev.diffCodelensMode, JSONC-tolerant, default 'forward'). packages/codev/.../overview.ts; tower-routes.ts fallback updated. +- VSCode relay: feedback-file/hunk/selection + send-queue verbs (command-relay.ts). New review-queue/feedback.ts mode-router: forward mode → codev.forwardToBuilder (immediate); comment mode → ReviewQueueStore.add (single source of truth). submitReview now accepts builder-id arg (deck Send Fb passes selectedId; status bar still resolves). +- Canvas back-sync (§F, E2=b): preview-provider fires builder-active on canvas focus (onDidChangeViewState active), builder resolved by worktreePath-prefix via new pure canvas-owner.ts. Reuses existing builder-active event/hook (no new hook). +- Deck: store readers (feedbackMode/queuedFeedback/windowedBuilder + ROW1_WINDOW_SIZE=4). face.ts: selected-slot accent ring, approveFaceSvg, sendFbFaceSvg, 'comment' glyph, gatesFaceSvg label param. actions.ts: slotBuilder→windowedBuilder (4-wide page window), selected highlight, dial press forward-*→feedback-*, ScrollNav→feedback-selection, touchstrip 'Files · send/queue', ApproveGate repurposed selected-scoped, new SendQueueAction + NextAttentionAction. plugin.ts registers them. manifest.json: +send-queue +next-attention actions (reused icons; dedicated icons = polish follow-up). +- README: two-zone layout, mode-neutral feedback, coherence model, builder-active activity-hook prerequisite (E1) with config example. +- Tests: streamdeck 126 pass (windowing, SendFb, NextAttn, faces, mode label, dial verbs); vscode 822 pass (feedback router, relay verbs, canvas-owner); codev overview (countQueuedFeedback/readFeedbackMode). + +## Dev-approval gate revisions +- 2026-08-13: Root cause of "deck buttons/dials not showing after relink": the plugin bin/plugin.js was never built in this worktree (root `pnpm build` intentionally skips apps/streamdeck + apps/vscode — they're not npm-published; CI builds them in separate jobs, test.yml:41 comments this). Fix: `pnpm --filter @cluesmith/codev-streamdeck build`. Not a bug (by design) but a footgun; flagged to Amr as a possible build:all/docs follow-up (cross-cutting, out of #1410 scope). +- 2026-08-13: Amr — Row 2 key 4 should be per-builder (Approve/Dev/SendFb all are; Next/Attention was fleet-level, odd one out). Replaced NextAttentionAction with OpenTerminalAction (VerbKey, open-terminal [selectedId], like DevServerAction). Fleet jump-to-next + gate-count DROPPED (covered by Row 1 window gate-faces + Zoom dial N⚠ count). Removed dead gatesFaceSvg + store.topGateBuilderId. manifest: next-attention→open-terminal. Deck 122 tests pass, check-types clean, plugin builds+validates. Plan updated with revision note. req-6 outcome unchanged (single selected-scoped approve). Still at dev-approval gate. + +## Merge: main (bugfix-1437 DevServer face) — 2026-08-13 +- Merged origin/main (commit 231a6fcb8). Conflicts in face.ts, actions.ts, face.test.ts from a separate builder (bugfix-1437) that: added `play` glyph + `labelFaceSvg(icon,label,color)` helper (icon + centered label, "for keys not builder-state-coded e.g. Run Dev"), gave DevServerAction a composite face (`labelFaceSvg('play','Dev',green)`), and made key-press success SILENT (ack only alerts on failure; no showOk). +- Resolution: unioned GlyphKey + GLYPHS (mine: comment, terminal; theirs: play). Kept my deletion of `gatesFaceSvg` (dead after ApproveGate repurpose + NextAttention removal) — dropped their gatesFaceSvg test too. Merged imports (added labelFaceSvg, dropped gatesFaceSvg). Kept their DevServer face + silent-ack (my actions use ack() so inherit silent success; no test asserted showOk-on-success). +- DRY cleanup: refactored my 3 inert face branches (approve/sendFb/terminal "icon + centered label") to call their `labelFaceSvg` helper instead of hand-rolling — consistency with the new shared helper. Row 2 now: Approve (approveFaceSvg), Dev (labelFaceSvg play), Send Fb (sendFbFaceSvg), Open Terminal (terminalFaceSvg) — all composite faces, all consistent. +- Deck 128 tests pass, check-types clean, plugin builds + validates. + +## Review phase — 3-way consultation (2026-08-13) +- PR #1439 opened. Consultation: gemini=APPROVE, claude=APPROVE, codex=REQUEST_CHANGES (2 pts). +- Codex pt2 (refresh trigger) — VALID, FIXED (bf693620e): verified queuedFeedback/feedbackMode had NO deterministic push (porch pushes status.yaml via overview-changed broadcast, but ReviewQueueStore writes never notify Tower + Tower doesn't watch queue files/.vscode/settings.json; deck refresh-on-command-echo races the write). Fix: apps/vscode/src/review-queue/overview-nudge.ts — onDidChangeQueue OR diffCodelensMode config change → TowerClient.refreshOverview() (POST /api/overview/refresh → invalidate cache + broadcast overview-changed, fires AFTER write). Regression test overview-nudge.test.ts (3 cases). vscode 825 pass. +- Codex pt1 (blank profile) — REBUTTED: deliberate, already accepted by human at dev-approval (keys placed on hardware, workflow verified); profile always shipped Actions:null (#1404 same); no safe sdProfile Actions schema. Rebuttal in codev/projects/1410-*/1410-review-iter1-rebuttals.md. +- Notified architect (led with REQUEST_CHANGES + disposition). At pr gate — waiting for human merge. PIR single-pass: no auto re-review; human pr-gate review is the backstop. + +## Scope note (intentional deferral) +- SD+ PROFILE (Codev.streamDeckProfile zip) left BLANK as it has always shipped (Actions:null; #1404 shipped Row 1 the same way). No known-good sdProfile Actions schema in history to safely pre-populate; a malformed binary profile would fail import at the hardware session. Two-zone key layout is documented in README; reviewer places the 8 keys at the dev-approval hardware session. Flag to architect. + +## Status +- 2026-08-13: Plan phase, first run. Investigated codebase. Wrote plan (commit 4c0f07dda), plan-approval gate pending. +- 2026-08-13: Amr resolved req-6 — RETIRE the generic ApproveGate singleton; Row 2 [Approve] = single selected-scoped affordance, jump-to-next + gate-count badge fold into Row 2 [Next/attention]. +- 2026-08-13: Amr asked how >4 builders are navigated + how deck syncs a workspace. Found a real gap: #1404's Row 1 keys are FIXED absolute slots (slotBuilder → builders()[slot-1]), so only builders 1-4 show. Added Row 1 windowing to plan: 4-wide window derived from cursor (page = floor(cursor.builder/4)), Select dial (ZoomNav rotate) scrolls it, selected-slot highlight. Workspace sync is already built (bidirectional): VSCode deep-link → store.syncToWorkspace/Builder; deck Select-dial zoom-in → focus-workspace verb → vscode.openFolder brings window to front. Plan revised (commit 8332eac6c), still at plan-approval gate. +- 2026-08-13: Amr (ultrathink) asked how the review dials integrate with the selected builder when they attach to the FOCUS window. Traced it: dials are FOCUS-anchored (diff nav resolveDiffContext via getDiffInjectEntry, diff-nav.ts; canvas via workspace MRU), dial MODE is selection-anchored (reviewMode(selectedBuilder)). Coherence invariant: selectedBuilder == focused-artifact-owner, held by (1) Row 1 press = select+open one gesture (actions.ts:131-133, direct syncToBuilder — works for canvas too), (2) diff focus fires builder-active (extension.ts:679-685) → deep-link → deck syncToBuilder (DIFF ONLY). Added "Layer integration" section + coherence tests to plan. TWO EDGES: E1 = focus→deck back-sync needs the personal-config builder-active→streamdeck:// activity hook configured (prerequisite for hardware session, README-documented). E2 = OWNER DECISION: canvas focus does NOT back-sync (announceActiveBuilderFromEditor gates on getDiffInjectEntry) — accept+document (rec) vs add symmetric canvas-active event (widens scope into #1401/#1425). Plan assumes accept unless told. Committed, still at plan-approval gate. +- 2026-08-13: Amr chose E2=(b) — add the symmetric canvas back-sync. Verified feasible + designed §F: viewPlanFile opens the artifact INSIDE the builder worktree (view-artifact.ts:83,128 → /codev//-.md); canvas panel already fires panel.onDidChangeViewState(active) (canvas-view-registry.ts:141); MarkdownPreviewProvider already holds overviewCache. So on canvas-active, resolve builder by worktreePath-PREFIX match against overview builders, and fire the EXISTING builder-active event (NOT a new canvas-active) → rides the same personal-config hook (no new config), reaches deck via same deep-link → syncToBuilder, deduped by fireActivity lastFiredKey, no loop. §F routes to main (vscode). Added §F + Files + unit/manual tests. Committed, still at plan-approval gate. diff --git a/packages/codev/src/agent-farm/__tests__/overview.test.ts b/packages/codev/src/agent-farm/__tests__/overview.test.ts index bf67fe6e0..f2c31d990 100644 --- a/packages/codev/src/agent-farm/__tests__/overview.test.ts +++ b/packages/codev/src/agent-farm/__tests__/overview.test.ts @@ -23,6 +23,8 @@ import { detectBlockedSince, computeIdleMs, derivePrReady, + countQueuedFeedback, + readFeedbackMode, } from '../servers/overview.js'; // ============================================================================ @@ -2359,3 +2361,63 @@ describe('overview', () => { }); }); }); + +// ============================================================================ +// #1410: per-builder queued-feedback count + workspace feedback mode +// ============================================================================ + +describe('countQueuedFeedback (#1410)', () => { + let dir: string; + beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qf-')); }); + afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); }); + + function writeQueue(content: string): void { + fs.mkdirSync(path.join(dir, '.codev'), { recursive: true }); + fs.writeFileSync(path.join(dir, '.codev', 'pending-comments.json'), content); + } + + it('counts the comments in a builder’s queue file', () => { + writeQueue(JSON.stringify({ version: 1, builderId: 'pir-1', comments: [ + { id: 'a', createdAt: 't', file: 'f', lineRange: null, body: 'x' }, + { id: 'b', createdAt: 't', file: 'g', lineRange: null, body: 'y' }, + ] })); + expect(countQueuedFeedback(dir)).toBe(2); + }); + + it('is 0 for a missing, empty, or corrupt file', () => { + expect(countQueuedFeedback(dir)).toBe(0); // no file + writeQueue('not json at all'); + expect(countQueuedFeedback(dir)).toBe(0); + writeQueue(JSON.stringify({ version: 1, builderId: 'x', comments: 'nope' })); + expect(countQueuedFeedback(dir)).toBe(0); // comments not an array + }); +}); + +describe('readFeedbackMode (#1410)', () => { + let dir: string; + beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fm-')); }); + afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); }); + + function writeSettings(content: string): void { + fs.mkdirSync(path.join(dir, '.vscode'), { recursive: true }); + fs.writeFileSync(path.join(dir, '.vscode', 'settings.json'), content); + } + + it('maps the codev.diffCodelensMode setting to the wire value', () => { + writeSettings(JSON.stringify({ 'codev.diffCodelensMode': 'comment' })); + expect(readFeedbackMode(dir)).toBe('queue'); + writeSettings(JSON.stringify({ 'codev.diffCodelensMode': 'forward' })); + expect(readFeedbackMode(dir)).toBe('forward'); + }); + + it('defaults to forward when the file / key is absent or unreadable', () => { + expect(readFeedbackMode(dir)).toBe('forward'); // no file + writeSettings(JSON.stringify({ 'editor.tabSize': 2 })); + expect(readFeedbackMode(dir)).toBe('forward'); // key absent + }); + + it('tolerates JSONC comments / trailing commas around the setting', () => { + writeSettings('{\n // my settings\n "codev.diffCodelensMode": "comment",\n}'); + expect(readFeedbackMode(dir)).toBe('queue'); + }); +}); diff --git a/packages/codev/src/agent-farm/servers/overview.ts b/packages/codev/src/agent-farm/servers/overview.ts index d47c48f74..6e12f8bed 100644 --- a/packages/codev/src/agent-farm/servers/overview.ts +++ b/packages/codev/src/agent-farm/servers/overview.ts @@ -727,6 +727,67 @@ function scanArtifactDir(dirPath: string): Map { return result; } +/** + * Count a builder's pending review-comments by reading its queue file + * (`/.codev/pending-comments.json`, the single source of truth the + * VSCode ReviewQueueStore owns, #1037). This is a READ of that file, never a + * write — the same pattern `discoverBuilders` uses to read worktree state. + * Tolerant: a missing / unreadable / corrupt file, or a non-array `comments`, + * reads as 0. Returns the count only, never bodies (#1410). + */ +export function countQueuedFeedback(worktreePath: string): number { + try { + const raw = fs.readFileSync(path.join(worktreePath, '.codev', 'pending-comments.json'), 'utf8'); + const data = JSON.parse(raw) as { comments?: unknown }; + return Array.isArray(data.comments) ? data.comments.length : 0; + } catch { + return 0; + } +} + +/** + * The workspace's review-feedback delivery mode, read from the VSCode + * `codev.diffCodelensMode` workspace setting in `/.vscode/settings.json` + * (`comment` → `'queue'`; anything else, or unreadable, → `'forward'`, matching + * VSCode's own default). Single-folder workspaces only — a multi-root + * `.code-workspace` or a user-level override isn't at this path and reads as the + * default. Tolerant of the JSONC (comments / trailing commas) VSCode may leave + * in the file: strict parse first, then a comment-stripped retry (#1410). + */ +export function readFeedbackMode(workspaceRoot: string): 'forward' | 'queue' { + let raw: string; + try { + raw = fs.readFileSync(path.join(workspaceRoot, '.vscode', 'settings.json'), 'utf8'); + } catch { + return 'forward'; // no settings file — the setting is at its default + } + const value = readSettingValue(raw, 'codev.diffCodelensMode'); + return value === 'comment' ? 'queue' : 'forward'; +} + +/** Parse a settings.json string (strict, then JSONC-tolerant) and return one + * key's value, or `undefined` when the file can't be parsed or lacks the key. */ +function readSettingValue(raw: string, key: string): unknown { + for (const candidate of [raw, stripJsonComments(raw)]) { + try { + const data = JSON.parse(candidate) as Record; + return data[key]; + } catch { + // try the next candidate + } + } + return undefined; +} + +/** Minimal JSONC → JSON: drop block/line comments and trailing commas. Good + * enough for a best-effort settings read (not a general JSONC parser). */ +function stripJsonComments(raw: string): string { + return raw + .replace(/\/\*[\s\S]*?\*\//g, '') // block comments + .replace(/(^|[^:"])\/\/.*$/gm, '$1') // line comments (leave `://` in URLs) + .replace(/,(\s*[}\]])/g, '$1'); // trailing commas +} + /** * Derive backlog from open GitHub issues cross-referenced with specs and builders. */ @@ -986,11 +1047,22 @@ export class OverviewCache { ); } + // Per-builder queued-feedback counts (#1410): read each builder's pending + // review-comment queue file. Only non-zero entries are carried, so the map + // stays `{}` when nothing is queued anywhere; a builder absent from it reads + // as 0 on every consumer. + const queuedFeedback: Record = {}; + for (const b of builders) { + const count = countQueuedFeedback(b.worktreePath); + if (count > 0) queuedFeedback[b.id] = count; + } + const feedbackMode = readFeedbackMode(workspaceRoot); + // `architects` defaults to `[]` here — the filesystem/git-derived overview // has no view of the live terminal sessions. `handleOverview` (tower-routes.ts) // injects the real architect list via `liveArchitects` before serialization, // mirroring how it enriches `lastDataAt`. - const result: OverviewData = { builders, pendingPRs, backlog, recentlyClosed, architects: [], heldCount, mailboxEscalated }; + const result: OverviewData = { builders, pendingPRs, backlog, recentlyClosed, architects: [], heldCount, mailboxEscalated, queuedFeedback, feedbackMode }; if (currentUser) { result.currentUser = currentUser; } diff --git a/packages/codev/src/agent-farm/servers/tower-routes.ts b/packages/codev/src/agent-farm/servers/tower-routes.ts index 3e2179007..10ccf6116 100644 --- a/packages/codev/src/agent-farm/servers/tower-routes.ts +++ b/packages/codev/src/agent-farm/servers/tower-routes.ts @@ -1109,7 +1109,7 @@ async function handleOverview(res: http.ServerResponse, url: URL, workspaceOverr // every collection field is required ('never undefined' for `architects`, // Issue 1104), so emit them all empty rather than a partial payload. res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ builders: [], pendingPRs: [], backlog: [], recentlyClosed: [], architects: [], heldCount: 0, mailboxEscalated: false })); + res.end(JSON.stringify({ builders: [], pendingPRs: [], backlog: [], recentlyClosed: [], architects: [], heldCount: 0, mailboxEscalated: false, queuedFeedback: {}, feedbackMode: 'forward' })); return; } diff --git a/packages/types/src/api.ts b/packages/types/src/api.ts index 53a9845c8..ce27ee2b6 100644 --- a/packages/types/src/api.ts +++ b/packages/types/src/api.ts @@ -315,6 +315,26 @@ export interface OverviewData { * escalation never triggers delivery. */ mailboxEscalated: boolean; + /** + * Issue 1410: per-builder count of pending review-comments (the queued + * feedback in each builder's `.codev/pending-comments.json`), keyed by + * `OverviewBuilder.id`. A **map, never a scalar total** — the Stream Deck + * badge reads its selected builder's count, and #1049's future Attention + * rollup consumes the same field. A builder absent from the map has none + * (read as 0); `{}` when nothing is queued anywhere — never `undefined`, so + * consumers don't branch. + */ + queuedFeedback: Record; + /** + * Issue 1410: the workspace's current review-feedback delivery mode, projected + * from the VSCode `codev.diffCodelensMode` setting (`forward` → `'forward'`, + * `comment` → `'queue'`). `'forward'` = a review chunk is sent to the builder + * immediately; `'queue'` = it accumulates in the pending-comments queue until + * flushed. Lets the deck name the live semantic on the dial touchscreen + * (`Files · send` vs `Files · queue`) instead of inferring it. Defaults to + * `'forward'` (the setting's own default) when unreadable. + */ + feedbackMode: 'forward' | 'queue'; /** Auto-detected GitHub login of the current user (via the user-identity forge concept). */ currentUser?: string; errors?: { prs?: string; issues?: string };