@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.
- package/apps/studio/.ai/cache/_stats.json +1 -1
- package/apps/studio/acp/bridge.ts +165 -15
- package/apps/studio/artboard-hug-commit.ts +30 -0
- package/apps/studio/client/app.jsx +107 -33
- package/apps/studio/client/panels/ChatPanel.jsx +25 -2
- package/apps/studio/client/panels/timeline-media-cache.js +38 -2
- package/apps/studio/client/styles/6-acp-chat.css +12 -0
- package/apps/studio/clip-ops.ts +84 -35
- package/apps/studio/dist/client.bundle.js +899 -899
- 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/artboard-hug-commit.test.ts +36 -0
- package/apps/studio/test/clip-ops.test.ts +84 -2
- 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/test/timeline-media-cache.test.ts +41 -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 {
|
|
@@ -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
|
|
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={
|
|
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
|
-
//
|
|
11798
|
-
// "
|
|
11799
|
-
//
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
12430
|
-
|
|
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
|
-
|
|
12518
|
-
|
|
12519
|
-
|
|
12520
|
-
|
|
12521
|
-
|
|
12522
|
-
|
|
12523
|
-
|
|
12524
|
-
|
|
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
|
-
|
|
12530
|
-
|
|
12531
|
-
|
|
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
|
-
|
|
12537
|
-
|
|
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:
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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}
|