@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.
- package/apps/studio/.ai/cache/_stats.json +1 -1
- package/apps/studio/acp/bridge.ts +165 -15
- package/apps/studio/client/app.jsx +85 -22
- package/apps/studio/client/panels/ChatPanel.jsx +25 -2
- package/apps/studio/client/styles/6-acp-chat.css +12 -0
- package/apps/studio/dist/client.bundle.js +1146 -1146
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/sync/canvas-path.ts +329 -0
- package/apps/studio/sync/codec.ts +42 -0
- package/apps/studio/sync/index.ts +490 -17
- package/apps/studio/sync/projection.ts +11 -1
- package/apps/studio/sync/remote-docs.ts +122 -15
- package/apps/studio/test/acp-bridge-lifetime.test.ts +106 -0
- package/apps/studio/test/comment-relay-origin-gate.test.ts +117 -0
- package/apps/studio/test/comments-fs-rebroadcast.test.ts +155 -0
- package/apps/studio/test/fixtures/mock-acp-agent-wedged.mjs +37 -0
- package/apps/studio/test/sync-canvas-path.test.ts +200 -0
- package/apps/studio/test/sync-path-pull.test.ts +402 -0
- package/apps/studio/test/sync-remote-docs.test.ts +55 -3
- package/apps/studio/test/sync-runtime.test.ts +118 -0
- package/apps/studio/whats-new.json +9 -0
- package/package.json +8 -8
|
@@ -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
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
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
|
-
/**
|
|
1552
|
-
|
|
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
|
-
|
|
1557
|
-
|
|
1558
|
-
}
|
|
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
|
-
/**
|
|
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
|
-
|
|
1567
|
-
this.
|
|
1568
|
-
this.
|
|
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
|
-
//
|
|
11802
|
-
// "
|
|
11803
|
-
//
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
12434
|
-
|
|
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
|
-
|
|
12522
|
-
|
|
12523
|
-
|
|
12524
|
-
|
|
12525
|
-
|
|
12526
|
-
|
|
12527
|
-
|
|
12528
|
-
|
|
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
|
-
|
|
12534
|
-
|
|
12535
|
-
|
|
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
|
-
|
|
12541
|
-
|
|
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 {
|