@1agh/maude 0.58.1 → 0.58.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "hits": {},
3
3
  "misses": {
4
- "security": 1
4
+ "security": 2
5
5
  },
6
6
  "since": 1783681711349
7
7
  }
@@ -147,6 +147,8 @@ export interface AcpBridgeOptions {
147
147
  onUsage?: (usage: BridgeUsage) => void;
148
148
  /** Override for `PERMISSION_TIMEOUT_MS` (tests only — production always gets the real default). */
149
149
  permissionTimeoutMs?: number;
150
+ /** Override for `CANCEL_ESCALATION_MS` (tests only — production always gets the real default). */
151
+ cancelEscalationMs?: number;
150
152
  }
151
153
 
152
154
  /**
@@ -561,6 +563,16 @@ export function newSessionParams(
561
563
  // the security control, so failing open would defeat the point.
562
564
  const PERMISSION_TIMEOUT_MS = 120_000;
563
565
 
566
+ // SECURITY/RELIABILITY (issue-82) — how long cancel() waits for the
567
+ // in-flight turn to actually END after asking the adapter cooperatively,
568
+ // before forcing it to end itself. A wedged subprocess — or one that never
569
+ // implemented `session/cancel` at all — must not be able to leave the Stop
570
+ // button permanently inert (see `cancel()`'s doc comment). Bounded like
571
+ // PERMISSION_TIMEOUT_MS/INITIALIZE_TIMEOUT_MS above: generous enough not to
572
+ // misfire on a merely-slow cancel ack, short enough that Stop still feels
573
+ // responsive.
574
+ const CANCEL_ESCALATION_MS = 5_000;
575
+
564
576
  // SECURITY (ethical-hacker finding, retroactive review) — a single agent turn
565
577
  // can legitimately issue several tool calls back to back (a burst is normal
566
578
  // agent behavior, not a bug — e.g. prompt-injected content directing several
@@ -624,6 +636,32 @@ export class AcpBridge {
624
636
  // running establishSession() and stomping the single shared `replaying` flag.
625
637
  private sessionPromises = new Map<string, Promise<string>>();
626
638
  private currentSession: string | null = null; // the in-flight prompt's session
639
+ // issue-82 — every prompt() call currently racing a forced-cancellation
640
+ // (see `prompt()`), so cancel()/stop() can force ANY of them to settle
641
+ // when the adapter doesn't cooperate, instead of leaving one hanging
642
+ // forever. A Set, not a single field, on purpose (ethical-hacker finding):
643
+ // `handlePrompt` has no reentrancy guard, so a duplicate/replayed `prompt`
644
+ // frame for the same chat can start a SECOND overlapping turn on this
645
+ // bridge. A singleton field would be silently overwritten by the second
646
+ // turn's registration and then nulled by whichever turn settles first —
647
+ // even a normal, uncancelled completion — leaving an EARLIER, genuinely
648
+ // wedged turn permanently unescalatable and reopening this exact bug.
649
+ // Tracking every in-flight turn means cancel() always finds (and can
650
+ // force-end) whichever one is actually stuck, regardless of ordering.
651
+ private pendingTurns = new Set<{ reject: (err: Error) => void; settled: Promise<void> }>();
652
+ // issue-82 / ethical-hacker finding — set for good, synchronously, at the
653
+ // very top of `stop()`. `stop()` kills the subprocess but cannot make it
654
+ // die instantly (SIGTERM, not a synchronous severance of the ndjson
655
+ // stream), so a message the child had already started writing can still
656
+ // arrive and be dispatched afterward. Without this flag a
657
+ // `requestPermission`/`unstable_createElicitation` call landing in that
658
+ // window would register a fresh pending entry as if the session were
659
+ // still live — and a stale client `permission-response` could later
660
+ // resolve it as an "allow" against a session the user was told was
661
+ // closed. Checked at the top of both handlers below; never reset (a
662
+ // stopped bridge is torn out of the `bridges` map by `reap()` and never
663
+ // reused — see acp/index.ts).
664
+ private stopped = false;
627
665
  /** Sessions whose bootstrap brief already hit the transcript (audit record). */
628
666
  private briefLogged = new Set<string>();
629
667
  private starting: Promise<void> | null = null;
@@ -859,8 +897,31 @@ export class AcpBridge {
859
897
  }
860
898
  }
861
899
 
862
- /** Spawn + handshake exactly once; concurrent callers share the same promise. */
900
+ /** Spawn + handshake exactly once; concurrent callers share the same promise.
901
+ *
902
+ * issue-82 follow-up (ethical-hacker finding on the fix itself) — once a
903
+ * bridge has been `stop()`'d, refuse to resurrect it rather than silently
904
+ * respawning. `stop()` is currently ONLY reached via `reap()` (which also
905
+ * deletes the bridge from `index.ts`'s map, so a fresh `AcpBridge` — with
906
+ * `stopped` correctly false — is created for the next turn) OR via THIS
907
+ * file's own cancel()-escalation path (`cancel()` calling `stop()` when a
908
+ * turn doesn't end in time), which does NOT go through `reap()` and leaves
909
+ * the SAME, now-permanently-`stopped` instance live in that map. Without
910
+ * this guard, a merely-slow (not malicious) turn that just misses
911
+ * `CANCEL_ESCALATION_MS` would respawn a working-looking subprocess whose
912
+ * `requestPermission`/`unstable_createElicitation` handlers silently
913
+ * auto-deny forever (the `stopped` check in both) — a hard-to-diagnose,
914
+ * silent regression of the permission gate with no visible error. Throwing
915
+ * here instead surfaces a clear, actionable error through the SAME
916
+ * try/catch every caller already has (`handlePrompt` etc.), so the failure
917
+ * is loud, not silent — the user is prompted to start a new chat, whose
918
+ * `getOrCreateEntry` makes a brand-new bridge with `stopped` unset. */
863
919
  async ensureStarted(): Promise<void> {
920
+ if (this.stopped) {
921
+ throw new Error(
922
+ 'This AI editing session was stopped and cannot be reused — start a new chat.'
923
+ );
924
+ }
864
925
  if (this.conn) return;
865
926
  if (!this.starting) {
866
927
  this.starting = this.start().finally(() => {
@@ -1139,6 +1200,12 @@ export class AcpBridge {
1139
1200
  void this.appendTranscript({ role: 'agent', update: u }, seq);
1140
1201
  },
1141
1202
  requestPermission: (params: RequestPermissionRequest): Promise<RequestPermissionResponse> => {
1203
+ // issue-82 / ethical-hacker finding — a message the child had already
1204
+ // started writing before `stop()` killed it can still be dispatched
1205
+ // in the brief window before the process actually dies. Fail closed
1206
+ // immediately rather than register a pending entry a stale client
1207
+ // response could later resolve as "allow" against a torn-down session.
1208
+ if (this.stopped) return Promise.resolve({ outcome: { outcome: 'cancelled' } });
1142
1209
  // Milestone B (retires DDR-125 F2's blanket auto-approve) — the
1143
1210
  // permission POLICY is now the selected session mode (sourced from
1144
1211
  // Claude Code itself): `bypassPermissions`/`dontAsk` short-circuit
@@ -1195,6 +1262,11 @@ export class AcpBridge {
1195
1262
  unstable_createElicitation: (
1196
1263
  params: CreateElicitationRequest
1197
1264
  ): Promise<CreateElicitationResponse> => {
1265
+ // issue-82 / ethical-hacker finding — same fail-closed guard as
1266
+ // `requestPermission` above, and for the same reason: a message
1267
+ // already in flight from the child when `stop()` fires must not
1268
+ // register a pending entry a stale client response could resolve.
1269
+ if (this.stopped) return Promise.resolve({ action: 'decline' });
1198
1270
  // feature-acp-ask-user-question — mirrors requestPermission's shape
1199
1271
  // exactly. Fires for BOTH `AskUserQuestion` and any MCP-server
1200
1272
  // elicitation (see the plan's Research section) — the toolCallId/
@@ -1316,10 +1388,40 @@ export class AcpBridge {
1316
1388
  }
1317
1389
  }
1318
1390
  await this.appendTranscript({ role: 'user', text });
1319
- const response = await conn.prompt({
1320
- sessionId,
1321
- prompt: [{ type: 'text', text }],
1391
+
1392
+ // issue-82 — race the real adapter response against a forced rejection
1393
+ // `cancel()`/`stop()` can trigger. `turn.settled` resolves the moment the
1394
+ // RACE ITSELF is decided (security-auditor finding: resolving it only
1395
+ // after the transcript-append below would let a slow disk write
1396
+ // masquerade as a still-hanging turn and trigger a needless escalation
1397
+ // even though the real response already won) — the append is bookkeeping
1398
+ // on an already-settled outcome, not part of what `cancel()` waits on.
1399
+ let resolveSettled!: () => void;
1400
+ const settled = new Promise<void>((resolve) => {
1401
+ resolveSettled = resolve;
1322
1402
  });
1403
+ const turn: { reject: (err: Error) => void; settled: Promise<void> } = {
1404
+ reject: () => {}, // replaced synchronously below before this can matter
1405
+ settled,
1406
+ };
1407
+ const forced = new Promise<never>((_, reject) => {
1408
+ turn.reject = reject;
1409
+ });
1410
+ let response: PromptResponse;
1411
+ // `add` happens INSIDE the try (not before it) so a synchronous throw
1412
+ // from `conn.prompt()` still reaches the `finally` and removes `turn` —
1413
+ // otherwise it would leak in the Set forever, and a later cancel()
1414
+ // calling `turn.reject()` on it would reject a `forced` promise nothing
1415
+ // is racing anymore (an unhandled rejection).
1416
+ try {
1417
+ this.pendingTurns.add(turn);
1418
+ const promptCall = conn.prompt({ sessionId, prompt: [{ type: 'text', text }] });
1419
+ promptCall.catch(() => {}); // a forced-reject race must not surface as an unhandled rejection
1420
+ response = await Promise.race([promptCall, forced]);
1421
+ } finally {
1422
+ this.pendingTurns.delete(turn);
1423
+ resolveSettled();
1424
+ }
1323
1425
  await this.appendTranscript({ role: 'stop', stopReason: response.stopReason });
1324
1426
  return { stopReason: response.stopReason };
1325
1427
  }
@@ -1548,24 +1650,72 @@ export class AcpBridge {
1548
1650
  }
1549
1651
  }
1550
1652
 
1551
- /** Cancel the in-flight turn (no-op if nothing is running). */
1552
- async cancel(): Promise<void> {
1653
+ /** Deny pending permissions/elicitations, then fire the cooperative ACP
1654
+ * `session/cancel` request — WITHOUT waiting for it to settle. A wedged
1655
+ * or non-cooperating subprocess can leave this RPC unanswered forever (or
1656
+ * reject it immediately if `session/cancel` isn't even implemented);
1657
+ * either way this must never make a caller wait on it, so both
1658
+ * `cancel()`'s escalation and `stop()`'s teardown stay bounded no matter
1659
+ * what the adapter does with the request. */
1660
+ private requestCancel(): void {
1553
1661
  this.denyAllPendingPermissions();
1554
1662
  this.declineAllPendingElicitations();
1555
1663
  if (this.conn && this.currentSession) {
1556
- try {
1557
- await this.conn.cancel({ sessionId: this.currentSession });
1558
- } catch {
1559
- /* turn may already have finished */
1560
- }
1664
+ this.conn.cancel({ sessionId: this.currentSession }).catch(() => {
1665
+ /* turn may already have finished, or the adapter doesn't implement cancel */
1666
+ });
1561
1667
  }
1562
1668
  }
1563
1669
 
1564
- /** Tear down: cancel, kill the subprocess, drop all handles + sessions. */
1670
+ /**
1671
+ * Cancel the in-flight turn (no-op if nothing is running).
1672
+ *
1673
+ * Fires the cooperative ACP `session/cancel` request, but does not simply
1674
+ * trust it: a wedged subprocess — or one that never implemented
1675
+ * `session/cancel` at all — can leave the pending `prompt()` call
1676
+ * unsettled forever, which used to mean Stop silently did nothing
1677
+ * (issue-82): the client's turn loop never exits, `busy` stays true, and
1678
+ * the Stop button stays visible but inert. Any turn that hasn't actually
1679
+ * ended within `CANCEL_ESCALATION_MS` of asking is forced to end — reject
1680
+ * its pending `prompt()` — so it always settles as an error the client
1681
+ * surfaces, instead of hanging. If ANY turn needed forcing, the whole
1682
+ * bridge (one subprocess serving every turn) is presumed wedged and torn
1683
+ * down.
1684
+ */
1685
+ async cancel(): Promise<void> {
1686
+ this.requestCancel();
1687
+ const turns = [...this.pendingTurns];
1688
+ if (turns.length === 0) return; // nothing in flight to escalate
1689
+ const escalated = await Promise.all(
1690
+ turns.map(async (turn) => {
1691
+ const result = await withTimeout(
1692
+ turn.settled,
1693
+ this.opts.cancelEscalationMs ?? CANCEL_ESCALATION_MS
1694
+ );
1695
+ if (result !== TIMED_OUT) return false;
1696
+ turn.reject(new Error('Cancelled — the agent did not respond in time and was stopped.'));
1697
+ return true;
1698
+ })
1699
+ );
1700
+ if (escalated.some(Boolean)) await this.stop();
1701
+ }
1702
+
1703
+ /** Tear down: cancel, kill the subprocess, drop all handles + sessions.
1704
+ * Never blocks on the cooperative cancel ask (see `requestCancel`) — a
1705
+ * wedged subprocess must not be able to make `stop()` hang too. */
1565
1706
  async stop(): Promise<void> {
1566
- await this.cancel();
1567
- this.denyAllPendingPermissions(); // belt-and-suspenders — cancel() already does this
1568
- this.declineAllPendingElicitations(); // ditto
1707
+ // Fail-closed from this instant on — see the `stopped` field comment.
1708
+ this.stopped = true;
1709
+ this.requestCancel();
1710
+ // Any turn(s) still in flight — a direct stop() with no prior cancel(),
1711
+ // or cancel()'s own escalation calling back in here. Force them to
1712
+ // settle before the connection dies underneath them, so the client's
1713
+ // turn loop always exits instead of awaiting a promise that can now
1714
+ // never resolve.
1715
+ for (const turn of this.pendingTurns) {
1716
+ turn.reject(new Error('Stopped — the AI editing session was closed.'));
1717
+ }
1718
+ this.pendingTurns.clear();
1569
1719
  try {
1570
1720
  this.proc?.kill();
1571
1721
  } catch {
@@ -11794,13 +11794,23 @@ function App() {
11794
11794
 
11795
11795
  // ----- Inbound messages from iframes -----
11796
11796
  useEffect(() => {
11797
+ // Does this comment id belong to the canvas the user is actually looking
11798
+ // at? Scopes the patch/delete relays below — see the SECURITY note there.
11799
+ //
11800
+ // The leading `!!activePath` is load-bearing, not defensive noise: with no
11801
+ // active canvas the branches' `activeWin` is `null`, and `e.source` is also
11802
+ // `null` for a message whose source context was discarded before dispatch —
11803
+ // so `e.source === activeWin` can pass as `null === null`. This is the
11804
+ // second conjunct that closes that path. Don't "simplify" it away.
11805
+ const ownsActiveComment = (id) =>
11806
+ !!activePath && (commentsByFile[activePath] || []).some((c) => c && c.id === id);
11797
11807
  function onMessage(e) {
11798
11808
  // Cross-origin hardening (DDR-054): only accept dgn control messages from
11799
11809
  // the canvas-content origin — the split origin when on, else our own origin
11800
11810
  // for the same-origin iframe. Drops spoofed messages from any other window.
11801
- // The handlers below relay to inert stores (comments / selection — the
11802
- // "safe to sync" set), so the blast radius was small, but unchecked inbound
11803
- // postMessage is a confused-deputy seam the F1 hardening should close.
11811
+ // Every canvas iframe shares that origin, so the check below proves only
11812
+ // "a canvas said this", NOT "the canvas the user is looking at said it" —
11813
+ // each mutating branch additionally gates on `e.source === activeWin`.
11804
11814
  const expectedOrigin = cfg?.canvasOrigin || window.location.origin;
11805
11815
  if (e.origin !== expectedOrigin) return;
11806
11816
  const m = e.data;
@@ -12408,13 +12418,26 @@ function App() {
12408
12418
  // Phase 6 — iframe overlay finished composing. Relay through the
12409
12419
  // existing WS `comments-add` channel; server-side persistence +
12410
12420
  // broadcast back are identical to the legacy shell-composer flow.
12421
+ //
12422
+ // SECURITY — gated on `activeWin` and PINNED to `activePath`, the same
12423
+ // shape as every other mutating branch in this handler. The origin
12424
+ // check at the top passes for EVERY canvas iframe (they all share
12425
+ // `canvasOrigin`), so without this a canvas sitting in a BACKGROUND
12426
+ // tab could post comments onto any file it cared to name, with no user
12427
+ // gesture. This handler's preamble waived that on the grounds that
12428
+ // comments are an "inert store" — the ACP panel's one-click
12429
+ // "Implement N comments" action retires that premise, because open
12430
+ // comments are now a feed the agent acts on. `p.file` is ignored
12431
+ // rather than validated: the active canvas is the only file the user
12432
+ // can actually see themselves commenting on.
12411
12433
  const p = m.payload;
12412
12434
  const txt = String(p.text).trim();
12413
- if (txt) {
12435
+ const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
12436
+ if (e.source === activeWin && txt && activePath && activePath !== SYSTEM_TAB) {
12414
12437
  wsSend({
12415
12438
  type: 'comments-add',
12416
12439
  payload: {
12417
- file: p.file,
12440
+ file: activePath,
12418
12441
  selector: p.selector,
12419
12442
  index: p.index,
12420
12443
  dom_path: p.dom_path,
@@ -12428,10 +12451,21 @@ function App() {
12428
12451
  }
12429
12452
  } else if (m.dgn === 'comment-patch' && m.id && m.patch && typeof m.patch === 'object') {
12430
12453
  // Phase 6 — thread popover routes resolve / reopen through here.
12431
- wsSend({ type: 'comments-patch', id: m.id, patch: m.patch });
12454
+ // SECURITY — same gate as comment-submit, plus an ownership check: a
12455
+ // bare id would otherwise reach ANY comment in the project. Patch and
12456
+ // delete change or remove somebody else's note, which is exactly why
12457
+ // ws.ts refuses both for a viewer session; the iframe lane needs the
12458
+ // matching restriction.
12459
+ const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
12460
+ if (e.source === activeWin && ownsActiveComment(m.id)) {
12461
+ wsSend({ type: 'comments-patch', id: m.id, patch: m.patch });
12462
+ }
12432
12463
  } else if (m.dgn === 'comment-delete' && m.id) {
12433
- wsSend({ type: 'comments-delete', id: m.id });
12434
- setFocusedCommentId((prev) => (prev === m.id ? null : prev));
12464
+ const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
12465
+ if (e.source === activeWin && ownsActiveComment(m.id)) {
12466
+ wsSend({ type: 'comments-delete', id: m.id });
12467
+ setFocusedCommentId((prev) => (prev === m.id ? null : prev));
12468
+ }
12435
12469
  } else if (m.dgn === 'comment-click' && m.id) {
12436
12470
  setFocusedCommentId(m.id);
12437
12471
  } else if (m.dgn === 'artboards' && typeof m.count === 'number') {
@@ -12518,27 +12552,48 @@ function App() {
12518
12552
  } else if (m.dgn === 'shell-shortcut') {
12519
12553
  // Same forwarding lane for the other shell chords (inspect.ts) — so
12520
12554
  // ⌘R / ⌘⇧I / ⌘⇧M / ⌘⇧E / ⌘⇧H behave identically wherever focus is.
12521
- if (m.id === 'reload') reloadActive();
12522
- else if (m.id === 'inspector') toggleRightPanel('inspector');
12523
- else if (m.id === 'assistant' && isNativeApp()) toggleRightPanel('assistant');
12524
- else if (m.id === 'comments') toggleRightPanel('comments');
12525
- else if (m.id === 'changes') toggleRightPanel('changes');
12526
- else if (m.id === 'timeline') toggleTimeline();
12527
- else if (m.id === 'export') setExportDialog({ mode: 'export' });
12528
- else if (m.id === 'handoff') setExportDialog({ mode: 'handoff' });
12555
+ //
12556
+ // SECURITY — every one of these is a chord the user pressed INSIDE the
12557
+ // canvas they are looking at, so it gets the same `activeWin` gate as
12558
+ // its siblings. Ungated, a background canvas could reload the active
12559
+ // file out from under an edit or pop the Export/Handoff dialog on
12560
+ // demand — a modal-timing primitive, and the mirror image of the
12561
+ // present-enter branch that was already hardened against modal HIDING.
12562
+ const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
12563
+ if (e.source === activeWin) {
12564
+ if (m.id === 'reload') reloadActive();
12565
+ else if (m.id === 'inspector') toggleRightPanel('inspector');
12566
+ else if (m.id === 'assistant' && isNativeApp()) toggleRightPanel('assistant');
12567
+ else if (m.id === 'comments') toggleRightPanel('comments');
12568
+ else if (m.id === 'changes') toggleRightPanel('changes');
12569
+ else if (m.id === 'timeline') toggleTimeline();
12570
+ else if (m.id === 'export') setExportDialog({ mode: 'export' });
12571
+ else if (m.id === 'handoff') setExportDialog({ mode: 'handoff' });
12572
+ }
12529
12573
  } else if (m.dgn === 'open-export') {
12530
12574
  // Plan C — the in-canvas toolbar / context menu route here so they open
12531
12575
  // the SAME shell Export dialog as the menubar (one look, all settings).
12532
12576
  // Carry the context-menu's scope hint (e.g. "Export selection").
12533
- setExportDialog({
12534
- mode: 'export',
12535
- scope: m.detail && typeof m.detail.scope === 'string' ? m.detail.scope : undefined,
12536
- });
12577
+ //
12578
+ // SECURITY — gated with `shell-shortcut` above rather than separately:
12579
+ // both reach the same `setExportDialog`, so leaving this one open would
12580
+ // hand back the capability the other now refuses.
12581
+ const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
12582
+ if (e.source === activeWin) {
12583
+ setExportDialog({
12584
+ mode: 'export',
12585
+ scope: m.detail && typeof m.detail.scope === 'string' ? m.detail.scope : undefined,
12586
+ });
12587
+ }
12537
12588
  } else if (m.dgn === 'open-timeline-request') {
12538
12589
  // Artboard-chrome context menu's "Open Timeline" (video-comp artboards
12539
12590
  // only). Scope the Timeline to the right-clicked artboard, then open it.
12540
- if (typeof m.artboardId === 'string') setCanvasActiveArtboard(m.artboardId.slice(0, 120));
12541
- setTimelineOpen(true);
12591
+ // SECURITY — same class: a context-menu action from the canvas in view.
12592
+ const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
12593
+ if (e.source === activeWin) {
12594
+ if (typeof m.artboardId === 'string') setCanvasActiveArtboard(m.artboardId.slice(0, 120));
12595
+ setTimelineOpen(true);
12596
+ }
12542
12597
  } else if (m.dgn === 'loaded' && m.file) {
12543
12598
  // iframe finished loading — drop the compile skeleton, push current
12544
12599
  // comments + carry over focused pin if any
@@ -14190,6 +14245,12 @@ function App() {
14190
14245
  (panelSide[id] || PANEL_SIDES_DEFAULTS[id]) === 'left' ? dragSide === 'sb' : dragSide === 'rp';
14191
14246
  const activeCanvasFile =
14192
14247
  activePath && activePath !== SYSTEM_TAB && /\.(tsx|html)$/i.test(activePath) ? activePath : null;
14248
+ // Issue #74 — drives the chat panel's "Implement N comments" quick action.
14249
+ // Canvas-wide on purpose: the verb operates on every open comment of the
14250
+ // active canvas, so it is deliberately NOT scoped to the current selection.
14251
+ const activeOpenComments = activeCanvasFile
14252
+ ? openCount(commentsByFile[activeCanvasFile])
14253
+ : 0;
14193
14254
 
14194
14255
  // Render a panel body by id (width undefined ⇒ fills the .st-dockslot wrapper,
14195
14256
  // which owns the resizable width). Assistant is handled separately below as an
@@ -14512,6 +14573,7 @@ function App() {
14512
14573
  hidden={leftActive !== 'assistant'}
14513
14574
  activeCanvas={activeCanvasFile}
14514
14575
  selected={selected}
14576
+ openComments={activeOpenComments}
14515
14577
  designRel={(cfg?.designRel || cfg?.designRoot || '.design').replace(/^\/+|\/+$/g, '')}
14516
14578
  resizing={resizingFor('assistant')}
14517
14579
  onClose={() => setAssistantOpen(false)}
@@ -14586,6 +14648,7 @@ function App() {
14586
14648
  hidden={rightActive !== 'assistant'}
14587
14649
  activeCanvas={activeCanvasFile}
14588
14650
  selected={selected}
14651
+ openComments={activeOpenComments}
14589
14652
  designRel={(cfg?.designRel || cfg?.designRoot || '.design').replace(/^\/+|\/+$/g, '')}
14590
14653
  resizing={resizingFor('assistant')}
14591
14654
  onClose={() => setAssistantOpen(false)}
@@ -1058,9 +1058,29 @@ function ChatEmpty() {
1058
1058
  );
1059
1059
  }
1060
1060
 
1061
- function QuickActions() {
1061
+ function QuickActions({ openComments = 0 }) {
1062
1062
  return (
1063
1063
  <div className="chat-quick">
1064
+ {/* Issue #74 — the reporter asked for a button that IMPLEMENTS the
1065
+ comments they already dropped on the canvas, not one that adds a new
1066
+ one. `/design:edit` already owns the whole behaviour (edit.md §3: a
1067
+ generic feedback string + open comments → iterate each as a scoped
1068
+ edit, resolve each after it lands), so this is purely a shortcut to
1069
+ the phrasing that triggers it. Shown only when there IS something to
1070
+ implement — a permanently-disabled verb would just be noise.
1071
+ Prefill, never fire blind: same rule as QUICK_ACTIONS above, so the
1072
+ user can narrow the ask ("…but skip the one about the colour")
1073
+ before sending. */}
1074
+ {openComments > 0 ? (
1075
+ <ThreadPrimitive.Suggestion
1076
+ prompt="/design:edit fix open comments"
1077
+ send={false}
1078
+ className="btn btn--ghost btn--sm chat-qa chat-qa--comments"
1079
+ data-testid="chat-qa-implement-comments"
1080
+ >
1081
+ {`✓ Implement ${openComments} comment${openComments === 1 ? '' : 's'}`}
1082
+ </ThreadPrimitive.Suggestion>
1083
+ ) : null}
1064
1084
  {QUICK_ACTIONS.map((a) => (
1065
1085
  <ThreadPrimitive.Suggestion
1066
1086
  key={a.label}
@@ -1495,6 +1515,7 @@ function ChatThread({
1495
1515
  activeCanvas,
1496
1516
  selected,
1497
1517
  designRel,
1518
+ openComments,
1498
1519
  transcriptView,
1499
1520
  onSetTranscriptView,
1500
1521
  onPermissionRequest,
@@ -1735,7 +1756,7 @@ function ChatThread({
1735
1756
  ) : (
1736
1757
  <ErrorCard error={turnError} onRetry={retryLastTurn} />
1737
1758
  )}
1738
- <QuickActions />
1759
+ <QuickActions openComments={openComments} />
1739
1760
  <Composer
1740
1761
  activeCanvas={activeCanvas}
1741
1762
  chatCtx={ctxDismissed ? null : chatCtx}
@@ -1766,6 +1787,7 @@ export default function ChatPanel({
1766
1787
  activeCanvas,
1767
1788
  selected,
1768
1789
  designRel,
1790
+ openComments = 0,
1769
1791
  width,
1770
1792
  resizing,
1771
1793
  onClose,
@@ -2288,6 +2310,7 @@ export default function ChatPanel({
2288
2310
  activeCanvas={activeCanvas}
2289
2311
  selected={selected}
2290
2312
  designRel={designRel}
2313
+ openComments={openComments}
2291
2314
  transcriptView={transcriptView}
2292
2315
  onSetTranscriptView={setTranscriptView}
2293
2316
  onPermissionRequest={onPermissionRequest}
@@ -1235,6 +1235,18 @@
1235
1235
  .chat-qa {
1236
1236
  font-family: var(--font-mono);
1237
1237
  }
1238
+ /* Issue #74 — the "Implement N comments" action. Its siblings are literal
1239
+ slash commands (hence mono); this one is prose ABOUT the canvas's current
1240
+ state and only appears when there is something to act on, so it reads as
1241
+ the row's contextual action: proportional type + a light accent tint. */
1242
+ .chat-qa--comments {
1243
+ font-family: inherit;
1244
+ border-color: var(--accent-muted);
1245
+ color: var(--accent);
1246
+ }
1247
+ .chat-qa--comments:hover {
1248
+ background: var(--accent-tint);
1249
+ }
1238
1250
 
1239
1251
  /* Composer */
1240
1252
  .chat-composer {