@1agh/maude 0.58.0 → 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 {
@@ -0,0 +1,30 @@
1
+ /**
2
+ * @file artboard-hug-commit.ts
3
+ * @purpose Pure decision for what the Artboard Inspector's Height field
4
+ * should do when a value is committed (issue #79 — typing a height
5
+ * while the artboard hugs its content silently did nothing; Width
6
+ * worked because it has no such mode). Kept framework-free + in a
7
+ * .ts file so it's unit-testable (app.jsx is bundled JSX, and its
8
+ * module-scope `createRoot(...).render(...)` makes it unsafe to
9
+ * import directly in a test) — same shape as sizing-mode.ts.
10
+ */
11
+
12
+ export type HeightCommitAction =
13
+ | { kind: 'resize'; height: number }
14
+ | { kind: 'promote-to-fixed'; height: number }
15
+ | { kind: 'noop' };
16
+
17
+ /**
18
+ * Fixed mode: a typed height is an ordinary resize, same lane as Width.
19
+ * Hug mode (DDR-027 Design Decision 1 — height is a content-driven floor,
20
+ * not an exact size): a typed height now PROMOTES the artboard to Fixed at
21
+ * that height — the same "freeze current height" write the Hug/Fixed
22
+ * segmented toggle already performs, just seeded from the typed value
23
+ * instead of the last measured one — rather than being silently dropped.
24
+ */
25
+ export function resolveHeightCommit(fixed: boolean, typedHeight: number): HeightCommitAction {
26
+ const height =
27
+ Number.isFinite(typedHeight) && typedHeight > 0 ? Math.round(typedHeight) : undefined;
28
+ if (height == null) return { kind: 'noop' };
29
+ return fixed ? { kind: 'resize', height } : { kind: 'promote-to-fixed', height };
30
+ }
@@ -20,6 +20,7 @@ import { GRID_KEYWORD_UNITS, parseTrackList, serializeTrackList } from '../grid-
20
20
  // Inspector's paper-preset picker, same "pull only pure math/data" shape as
21
21
  // canvas-cursors.ts above.
22
22
  import { PAPER_PRESETS, resolvePrintArtboard } from '../print/units.ts';
23
+ import { resolveHeightCommit } from '../artboard-hug-commit.ts';
23
24
  import { sizingModeOf, sizingModePatch } from '../sizing-mode.ts';
24
25
  // The single "what is the hub link doing" rule, shared with the cloud rail's
25
26
  // connect note so the two surfaces can never disagree. Pure data + strings —
@@ -42,6 +43,7 @@ import { FilePreview, sanitizeDisplayText } from './panels/file-preview.jsx';
42
43
  import SetupChecklistDialog, { useSetupReadiness } from './panels/SetupChecklist.jsx';
43
44
  import TimelinePanel from './panels/TimelinePanel.jsx';
44
45
  import { parseCompTimeline } from './panels/timeline-parse.js';
46
+ import { durationFramesForDrop, probeMediaDuration } from './panels/timeline-media-cache.js';
45
47
  import GenerateDialog from './generate-dialog.jsx';
46
48
  import RepoBranchSwitcher from './panels/RepoBranchSwitcher.jsx';
47
49
  import SettingsPanel from './panels/SettingsPanel.jsx';
@@ -8262,11 +8264,17 @@ function ArtboardKnobs({
8262
8264
  onResizeArtboard?.(artboardId, resolved.widthPx, resolved.heightPx);
8263
8265
  onSetArtboardPrint?.(artboardId, next);
8264
8266
  };
8265
- const setHug = (nextFixed) => {
8267
+ const setHug = (nextFixed, explicitHeight) => {
8266
8268
  if (!artboardId) return;
8267
- const freezeHeight = nextFixed && Number.isFinite(h) ? Math.round(h) : undefined;
8269
+ const src = Number.isFinite(explicitHeight) ? explicitHeight : h;
8270
+ const freezeHeight = nextFixed && Number.isFinite(src) ? Math.round(src) : undefined;
8268
8271
  onSetArtboardHug?.(artboardId, nextFixed, freezeHeight);
8269
8272
  };
8273
+ const commitHeight = (n) => {
8274
+ const action = resolveHeightCommit(fixed, n);
8275
+ if (action.kind === 'resize') commitSize(null, action.height);
8276
+ else if (action.kind === 'promote-to-fixed') setHug(true, action.height);
8277
+ };
8270
8278
  const commitStyle = (patch) => {
8271
8279
  if (!artboardId) return;
8272
8280
  onSetArtboardStyle?.(artboardId, patch);
@@ -8292,11 +8300,7 @@ function ArtboardKnobs({
8292
8300
  lead="H"
8293
8301
  steppers={false}
8294
8302
  scrub={fixed}
8295
- onCommit={(n) => {
8296
- // Hug — height is content-driven; ignore a typed value rather
8297
- // than silently switching modes.
8298
- if (fixed) commitSize(null, n);
8299
- }}
8303
+ onCommit={commitHeight}
8300
8304
  />
8301
8305
  </div>
8302
8306
  <div style={{ padding: '0 12px 8px' }}>
@@ -11790,13 +11794,23 @@ function App() {
11790
11794
 
11791
11795
  // ----- Inbound messages from iframes -----
11792
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);
11793
11807
  function onMessage(e) {
11794
11808
  // Cross-origin hardening (DDR-054): only accept dgn control messages from
11795
11809
  // the canvas-content origin — the split origin when on, else our own origin
11796
11810
  // for the same-origin iframe. Drops spoofed messages from any other window.
11797
- // The handlers below relay to inert stores (comments / selection — the
11798
- // "safe to sync" set), so the blast radius was small, but unchecked inbound
11799
- // 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`.
11800
11814
  const expectedOrigin = cfg?.canvasOrigin || window.location.origin;
11801
11815
  if (e.origin !== expectedOrigin) return;
11802
11816
  const m = e.data;
@@ -12404,13 +12418,26 @@ function App() {
12404
12418
  // Phase 6 — iframe overlay finished composing. Relay through the
12405
12419
  // existing WS `comments-add` channel; server-side persistence +
12406
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.
12407
12433
  const p = m.payload;
12408
12434
  const txt = String(p.text).trim();
12409
- if (txt) {
12435
+ const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
12436
+ if (e.source === activeWin && txt && activePath && activePath !== SYSTEM_TAB) {
12410
12437
  wsSend({
12411
12438
  type: 'comments-add',
12412
12439
  payload: {
12413
- file: p.file,
12440
+ file: activePath,
12414
12441
  selector: p.selector,
12415
12442
  index: p.index,
12416
12443
  dom_path: p.dom_path,
@@ -12424,10 +12451,21 @@ function App() {
12424
12451
  }
12425
12452
  } else if (m.dgn === 'comment-patch' && m.id && m.patch && typeof m.patch === 'object') {
12426
12453
  // Phase 6 — thread popover routes resolve / reopen through here.
12427
- 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
+ }
12428
12463
  } else if (m.dgn === 'comment-delete' && m.id) {
12429
- wsSend({ type: 'comments-delete', id: m.id });
12430
- 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
+ }
12431
12469
  } else if (m.dgn === 'comment-click' && m.id) {
12432
12470
  setFocusedCommentId(m.id);
12433
12471
  } else if (m.dgn === 'artboards' && typeof m.count === 'number') {
@@ -12514,27 +12552,48 @@ function App() {
12514
12552
  } else if (m.dgn === 'shell-shortcut') {
12515
12553
  // Same forwarding lane for the other shell chords (inspect.ts) — so
12516
12554
  // ⌘R / ⌘⇧I / ⌘⇧M / ⌘⇧E / ⌘⇧H behave identically wherever focus is.
12517
- if (m.id === 'reload') reloadActive();
12518
- else if (m.id === 'inspector') toggleRightPanel('inspector');
12519
- else if (m.id === 'assistant' && isNativeApp()) toggleRightPanel('assistant');
12520
- else if (m.id === 'comments') toggleRightPanel('comments');
12521
- else if (m.id === 'changes') toggleRightPanel('changes');
12522
- else if (m.id === 'timeline') toggleTimeline();
12523
- else if (m.id === 'export') setExportDialog({ mode: 'export' });
12524
- 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
+ }
12525
12573
  } else if (m.dgn === 'open-export') {
12526
12574
  // Plan C — the in-canvas toolbar / context menu route here so they open
12527
12575
  // the SAME shell Export dialog as the menubar (one look, all settings).
12528
12576
  // Carry the context-menu's scope hint (e.g. "Export selection").
12529
- setExportDialog({
12530
- mode: 'export',
12531
- scope: m.detail && typeof m.detail.scope === 'string' ? m.detail.scope : undefined,
12532
- });
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
+ }
12533
12588
  } else if (m.dgn === 'open-timeline-request') {
12534
12589
  // Artboard-chrome context menu's "Open Timeline" (video-comp artboards
12535
12590
  // only). Scope the Timeline to the right-clicked artboard, then open it.
12536
- if (typeof m.artboardId === 'string') setCanvasActiveArtboard(m.artboardId.slice(0, 120));
12537
- 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
+ }
12538
12597
  } else if (m.dgn === 'loaded' && m.file) {
12539
12598
  // iframe finished loading — drop the compile skeleton, push current
12540
12599
  // comments + carry over focused pin if any
@@ -14186,6 +14245,12 @@ function App() {
14186
14245
  (panelSide[id] || PANEL_SIDES_DEFAULTS[id]) === 'left' ? dragSide === 'sb' : dragSide === 'rp';
14187
14246
  const activeCanvasFile =
14188
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;
14189
14254
 
14190
14255
  // Render a panel body by id (width undefined ⇒ fills the .st-dockslot wrapper,
14191
14256
  // which owns the resizable width). Assistant is handled separately below as an
@@ -14508,6 +14573,7 @@ function App() {
14508
14573
  hidden={leftActive !== 'assistant'}
14509
14574
  activeCanvas={activeCanvasFile}
14510
14575
  selected={selected}
14576
+ openComments={activeOpenComments}
14511
14577
  designRel={(cfg?.designRel || cfg?.designRoot || '.design').replace(/^\/+|\/+$/g, '')}
14512
14578
  resizing={resizingFor('assistant')}
14513
14579
  onClose={() => setAssistantOpen(false)}
@@ -14582,6 +14648,7 @@ function App() {
14582
14648
  hidden={rightActive !== 'assistant'}
14583
14649
  activeCanvas={activeCanvasFile}
14584
14650
  selected={selected}
14651
+ openComments={activeOpenComments}
14585
14652
  designRel={(cfg?.designRel || cfg?.designRoot || '.design').replace(/^\/+|\/+$/g, '')}
14586
14653
  resizing={resizingFor('assistant')}
14587
14654
  onClose={() => setAssistantOpen(false)}
@@ -14919,10 +14986,11 @@ function App() {
14919
14986
  // fresh "New Cut" canvas only when no artboard opted in.
14920
14987
  let upgraded = false;
14921
14988
  for (const c of clips) {
14989
+ const probedSec = await probeMediaDuration(c.src).catch(() => null);
14922
14990
  const body = {
14923
14991
  canvas,
14924
14992
  lane: c.mediaKind === 'audio' && upgraded ? 'audio' : 'storyline',
14925
- durationInFrames: Math.round(fps * 3),
14993
+ durationInFrames: durationFramesForDrop(fps, probedSec),
14926
14994
  mediaTag: c.mediaKind === 'audio' ? 'Audio' : 'Video',
14927
14995
  src: c.src,
14928
14996
  };
@@ -14993,6 +15061,12 @@ function App() {
14993
15061
  continue;
14994
15062
  }
14995
15063
  const isAudio = mediaTag === 'Audio';
15064
+ // Video clips carry a real duration — probe it so the drop
15065
+ // lands at the clip's own length instead of a fixed 3s that
15066
+ // the user then has to drag out by hand. Images have no
15067
+ // inherent duration, so they keep the fallback.
15068
+ const probedSec =
15069
+ mediaTag === 'Video' ? await probeMediaDuration(up.path).catch(() => null) : null;
14996
15070
  const body = { canvas, artboardId, mediaTag, src: up.path };
14997
15071
  if (pos && (isAudio || pos.lane === 'audio')) {
14998
15072
  body.lane = 'audio';
@@ -15005,7 +15079,7 @@ function App() {
15005
15079
  body.index = slot;
15006
15080
  slot += 1; // multiple files insert in order
15007
15081
  }
15008
- body.durationInFrames = Math.round(fps * 3);
15082
+ body.durationInFrames = durationFramesForDrop(fps, probedSec);
15009
15083
  } else if (pos?.lane === 'overlay' && !isAudio) {
15010
15084
  if ((timelineSequences || []).length === 0) {
15011
15085
  // First clip of a greenfield comp = the BASE layer →
@@ -15015,14 +15089,14 @@ function App() {
15015
15089
  body.lane = 'overlay';
15016
15090
  body.from = Math.max(0, pos.frame ?? 0);
15017
15091
  }
15018
- body.durationInFrames = Math.round(fps * 3);
15092
+ body.durationInFrames = durationFramesForDrop(fps, probedSec);
15019
15093
  } else if (!isAudio) {
15020
15094
  // Default container rule: a video/image drop ANYWHERE on the
15021
15095
  // timeline lands in the storyline (append = hard cut at the
15022
15096
  // end; the caret gives it an index). The old lane-less
15023
15097
  // append refused hard-cut series ("no transition to clone").
15024
15098
  body.lane = 'storyline';
15025
- body.durationInFrames = Math.round(fps * 3);
15099
+ body.durationInFrames = durationFramesForDrop(fps, probedSec);
15026
15100
  } else {
15027
15101
  // Audio without a caret → audio band, from the start.
15028
15102
  body.lane = 'audio';
@@ -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}