diff --git a/.agents/plugins/agent-native-design/.codex-plugin/plugin.json b/.agents/plugins/agent-native-design/.codex-plugin/plugin.json index ae0aab86ce..80191bbafd 100644 --- a/.agents/plugins/agent-native-design/.codex-plugin/plugin.json +++ b/.agents/plugins/agent-native-design/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "agent-native-design", - "version": "1.0.0+codex.e5519282396d", + "version": "1.0.0+codex.66dbb8d10905", "description": "Explore, compare, iterate, and export interactive UI design prototypes from the Design app.", "author": { "name": "Agent-Native", "url": "https://agent-native.com" }, "homepage": "https://design.agent-native.com", diff --git a/.agents/plugins/agent-native-design/skills/visual-edit/SKILL.md b/.agents/plugins/agent-native-design/skills/visual-edit/SKILL.md index 79437cb8f0..9f2875e166 100644 --- a/.agents/plugins/agent-native-design/skills/visual-edit/SKILL.md +++ b/.agents/plugins/agent-native-design/skills/visual-edit/SKILL.md @@ -205,6 +205,20 @@ replace them with copied `srcdoc` HTML unless the user explicitly asks for a frozen snapshot. To change a state, rerun `add-localhost-screens` with the new path/query or duplicate the screen and update the copy's URL metadata. +## React Source Writeback + +- Use compiler/debug provenance (project-relative file, line, column, + component, and runtime multiplicity) to locate React/TSX source. Treat it as + evidence, not as permission for a generic AST structural transform. +- Reparenting, grouping/ungrouping, wrappers, dynamic expressions, repeated + `.map()` instances, shared components, and cross-file changes go through the + coding agent with exact subject/target anchors and their runtime + relationship. +- Before each write, read the file and pass its exact `versionHash` to + `write-local-file` with `requireExpectedVersionHash: true`; on conflict, + re-read and re-plan. Keep the optimistic preview until HMR/runtime confirms + the result. Human write consent remains mandatory and agents cannot grant it. + ## Verification - `list-localhost-connections` returns the expected connection and routes. diff --git a/.changeset/bright-local-react-previews.md b/.changeset/bright-local-react-previews.md new file mode 100644 index 0000000000..c62324c702 --- /dev/null +++ b/.changeset/bright-local-react-previews.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Keep local React visual-edit previews hydrated by preserving Vite request metadata and response lengths through the bridge, and recover exact development source locations from React jsxDEV Fiber stacks. diff --git a/.changeset/calm-content-flushes.md b/.changeset/calm-content-flushes.md new file mode 100644 index 0000000000..105e98f370 --- /dev/null +++ b/.changeset/calm-content-flushes.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Expose strict active-collaboration awareness reads so safety-critical sync flows can distinguish no open editor from presence-storage failures. diff --git a/.changeset/confirmed-local-chat-delivery.md b/.changeset/confirmed-local-chat-delivery.md new file mode 100644 index 0000000000..24773f3ea5 --- /dev/null +++ b/.changeset/confirmed-local-chat-delivery.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Add cancellation-safe `sendToAgentChatAndConfirm` delivery confirmation and its submit-result event contract so callers can preserve user work when a local chat message is rejected or times out, without allowing that timed-out message to arrive later. diff --git a/.changeset/f3b-fig-binary-builder-index.md b/.changeset/f3b-fig-binary-builder-index.md new file mode 100644 index 0000000000..e3b00aa99b --- /dev/null +++ b/.changeset/f3b-fig-binary-builder-index.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Fix `.fig` and other binary files silently corrupting when uploaded through `index-design-system-with-builder`: `buildBuilderDesignSystemIndexFiles` always UTF-8-encoded file content even though `mimeTypeForBuilderDesignSystemFilename` already recognized `.fig` as binary (`application/octet-stream`). Add an optional per-file `encoding: "utf8" | "base64"` (defaults to `utf8`, unchanged for existing text-file callers) so binary files can be base64-encoded by the caller and decoded byte-exact here. diff --git a/.changeset/fresh-authoritative-reverts.md b/.changeset/fresh-authoritative-reverts.md new file mode 100644 index 0000000000..26636bc6d5 --- /dev/null +++ b/.changeset/fresh-authoritative-reverts.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Prevent pre-seed collaborative editor updates from reaching autosave, and allow newer authoritative restores to reapply content previously emitted during mount while retaining stale-echo protection during active typing. diff --git a/.changeset/guard-recap-ssr-failures.md b/.changeset/guard-recap-ssr-failures.md new file mode 100644 index 0000000000..1a9b94bbe3 --- /dev/null +++ b/.changeset/guard-recap-ssr-failures.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Prevent PR recap workflows from publishing screenshots of failed recap pages, and report SSR render failures through configured error monitoring. diff --git a/.changeset/loop-protection-background-continuation.md b/.changeset/loop-protection-background-continuation.md new file mode 100644 index 0000000000..66fd126101 --- /dev/null +++ b/.changeset/loop-protection-background-continuation.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Fix long agent-chat turns dying with a Netlify "508 Loop Detected" error after several server-driven continuation chunks: nested self-dispatch chains now break proactively before the platform's undocumented loop-protection limit, and a 508 that does occur is classified distinctly and deferred to the existing unclaimed-run sweep for recovery instead of failing the turn outright. diff --git a/.changeset/lost-turns-recover-lost-handoffs.md b/.changeset/lost-turns-recover-lost-handoffs.md new file mode 100644 index 0000000000..1ec366d2be --- /dev/null +++ b/.changeset/lost-turns-recover-lost-handoffs.md @@ -0,0 +1,7 @@ +--- +"@agent-native/core": patch +--- + +Recover background chat turns whose continuation handoff dispatch failed instead of failing them immediately: the pre-inserted successor run is left claimable so the unclaimed-run sweep can redispatch it (and the client poll defers to that sweep within a bound), backed by a backstop that still fails loud if the handoff never lands. + +Operational note — terminal-reason change: when a continuation handoff exhausts its dispatch retry budget but the successor row exists, the run's `terminal_reason` is now `background_continuation_dispatch_deferred` (recoverable, awaiting sweep redispatch) instead of `background_continuation_dispatch_failed`. The old `background_continuation_dispatch_failed` reason is still emitted, but now only in the narrower case where the successor row itself could not be pre-inserted. Any dashboards, alerts, or audit queries matching `background_continuation_dispatch_failed` should add `background_continuation_dispatch_deferred` to keep covering this failure class. diff --git a/.changeset/plenty-cobras-cursor.md b/.changeset/plenty-cobras-cursor.md new file mode 100644 index 0000000000..8caffcc6e7 --- /dev/null +++ b/.changeset/plenty-cobras-cursor.md @@ -0,0 +1,7 @@ +--- +"@agent-native/core": patch +--- + +Fix an awareness "storm" in `useCollaborativeDoc`: the shared collab connection's fast awareness-push path re-broadcast a client's own (unchanged) presence state whenever ANY remote participant's awareness changed, not just when the local user actually moved their cursor or updated their presence. With several people collaborating on the same document, every peer's cursor move caused every other connected client to also re-POST its own state, multiplying awareness traffic roughly quadratically with participant count. The fast-push listener now only fires on locally-originated awareness changes (`origin === "local"`), matching the equivalent guard already present in `usePresence`. + +Also add a defensive size cap to the collab `recentEdits` ring (`appendRecentEdit`): descriptor strings (`quote`, `selector`, path entries) and the edit `label` are now truncated to 500 characters. The ring was already bounded in length, but an app forgetting to trim its own excerpt before publishing (e.g. passing a whole paragraph or document as `quote`) could otherwise push an oversized payload through the awareness fast-path broadcast and into the `_collab_awareness` SQL mirror. diff --git a/.changeset/rare-bridges-reconnect.md b/.changeset/rare-bridges-reconnect.md new file mode 100644 index 0000000000..5466710563 --- /dev/null +++ b/.changeset/rare-bridges-reconnect.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Give the Design localhost bridge a per-boot `bridgeInstanceId`, exposed on `/health`, the `/live-edit-bridge` registration response, and the `/live-edit` "unknown bridge key" 409 (now carrying a machine-readable `code: "unknown-bridge-key"` and the echoed `bridgeKey`). This lets a client distinguish a bridge process restart — which silently empties the in-memory live-edit bridge script registry — from a genuine registration bug, instead of guessing from free-text error strings. diff --git a/.changeset/silly-slots-sanitize.md b/.changeset/silly-slots-sanitize.md new file mode 100644 index 0000000000..712c2be3a6 --- /dev/null +++ b/.changeset/silly-slots-sanitize.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Fix a recurring `DataCloneError` console error when any embedded extension (Design's Tools/Extensions panel, or any other app using `EmbeddedExtension`) loads or its slot context updates. Slot contexts commonly carry live callback functions the host uses internally; `postMessage`'s structured clone algorithm throws on function-valued properties, so the context update silently never reached the extension iframe. Sanitize the context through a JSON round-trip (dropping functions) before posting it. diff --git a/.changeset/strong-local-source-writes.md b/.changeset/strong-local-source-writes.md new file mode 100644 index 0000000000..5da38394ca --- /dev/null +++ b/.changeset/strong-local-source-writes.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Harden local Design source writes with SHA-256 version checks, per-file serialization, atomic replacement, and repeated symlink containment validation. diff --git a/.claude/launch.json b/.claude/launch.json index 3f86893f46..fba58050ef 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -91,6 +91,22 @@ "3104" ], "port": 3104 + }, + { + "name": "slides", + "runtimeExecutable": "env", + "runtimeArgs": [ + "DATABASE_URL=file:/private/tmp/claude-501/-Users-steve-Projects-builder-agent-native-framework/726cb1e5-ab5f-4658-b340-e7877455395b/scratchpad/slides-verify.sqlite", + "PORT=3106", + "AUTH_MODE=local", + "pnpm", + "--dir", + "templates/slides", + "dev", + "--port", + "3106" + ], + "port": 3106 } ] } diff --git a/.gitignore b/.gitignore index f7e1992181..0b706601a5 100644 --- a/.gitignore +++ b/.gitignore @@ -73,6 +73,7 @@ test-results/ e2e/.auth/ **/.auth/ **/.auth-debug/ +**/.auth-*/ # Wrangler local state .wrangler diff --git a/packages/core/src/agent/production-agent.chain-continuation.spec.ts b/packages/core/src/agent/production-agent.chain-continuation.spec.ts index 16c61e29df..4c314c2841 100644 --- a/packages/core/src/agent/production-agent.chain-continuation.spec.ts +++ b/packages/core/src/agent/production-agent.chain-continuation.spec.ts @@ -6,7 +6,9 @@ import { } from "./durable-background.js"; import { chainServerDrivenContinuation, + isLoopProtectionDispatchError, MAX_BACKGROUND_RUN_CONTINUATIONS, + MAX_NESTED_SELF_DISPATCH_DEPTH, resolveContinuationDispatchBudget, type ChainServerDrivenContinuationDeps, } from "./production-agent.js"; @@ -131,11 +133,13 @@ async function runChain( chainViaDurableBackground?: boolean; workerProvenInBackgroundFunction?: boolean; requestBody?: Record; + backgroundContinuationCount?: number; + run?: ActiveRun; }, ): Promise { await chainServerDrivenContinuation({ event: {}, - run: timeoutBoundaryRun(), + run: opts?.run ?? timeoutBoundaryRun(), effectiveThreadId: "thread-1", effectiveTurnId: "turn-1", requestBody: opts?.requestBody ?? { @@ -144,7 +148,7 @@ async function runChain( threadId: "thread-1", [AGENT_CHAT_BACKGROUND_RUN_FIELD]: { runId: "run-chunk0" }, }, - backgroundContinuationCount: 0, + backgroundContinuationCount: opts?.backgroundContinuationCount ?? 0, chainViaDurableBackground: opts?.chainViaDurableBackground ?? false, workerProvenInBackgroundFunction: opts?.workerProvenInBackgroundFunction, deps: harness.deps, @@ -256,45 +260,81 @@ describe("chainServerDrivenContinuation — transactional handoff (foreground se expect(h.deps.updateRunStatusIfRunning).not.toHaveBeenCalled(); }); - it("fails LOUD when every attempt dies: successor + chunk errored, diag stage recorded — never silent", async () => { + it("DEFERS (never errors) the pre-inserted successor when every attempt dies — the unclaimed-run sweep gets a chance to recover it", async () => { const dispatchMock = vi.fn().mockRejectedValue(new Error("dispatch down")); const h = makeHarness({ fireInternalDispatch: dispatchMock as any }); await runChain(h); // Foreground path: 2 attempts (initial + one retry). expect(dispatchMock).toHaveBeenCalledTimes(2); - // The pre-inserted successor is errored immediately (not left for the - // sweep) with a truthful terminal reason… - expect(h.deps.updateRunStatusIfRunning).toHaveBeenCalledWith( + // The pre-inserted successor is LEFT ALONE — still status='running', + // dispatch_mode='background', dispatch_payload intact — so the + // unclaimed-background-run sweep (agent-chat-plugin.ts) can redispatch + // it. It is never marked errored from this path. + expect(h.deps.updateRunStatusIfRunning).not.toHaveBeenCalledWith( "run-next", "errored", ); - expect(h.deps.setRunTerminalReason).toHaveBeenCalledWith( + expect(h.deps.setRunTerminalReason).not.toHaveBeenCalledWith( "run-next", - "background_continuation_dispatch_failed", + expect.any(String), + ); + // The successor's diag stage records WHY it was left for the sweep (the + // only forensics channel — bg logs are unreadable). + expect(h.deps.recordRunDiagnostic).toHaveBeenCalledWith( + "run-next", + RUN_DIAG_STAGE.workerThrew, + expect.stringContaining("chain_dispatch_deferred"), ); - // …and the finished chunk is errored too, with the failure written as its - // diag stage (the only forensics channel — bg logs are unreadable). + // …the finished chunk DOES go terminal — its own soft-timeout budget is + // genuinely spent — but with the distinct, honest "deferred" reason: the + // TURN is not dead, only this handoff attempt was. expect(h.deps.updateRunStatusIfRunning).toHaveBeenCalledWith( "run-chunk0", "errored", ); expect(h.deps.setRunTerminalReason).toHaveBeenCalledWith( "run-chunk0", - "background_continuation_dispatch_failed", + "background_continuation_dispatch_deferred", ); expect(h.deps.recordRunDiagnostic).toHaveBeenCalledWith( "run-chunk0", RUN_DIAG_STAGE.workerThrew, - expect.stringContaining("chain_dispatch_failed"), + expect.stringContaining("chain_dispatch_deferred"), ); // The chunk is NOT marked as a clean continuation boundary. expect( h.deps.markBackgroundContinuationChunkTerminal, ).not.toHaveBeenCalled(); - // With both rows terminal, the thread slot is free — the client's + // With this chunk terminal, the thread slot is free — the client's // existing auto_continue re-POST (it still receives the terminal event) - // takes over as the fallback. See run-store.foreground-self-chain.spec. + // is a second, faster fallback alongside the sweep. See + // run-store.foreground-self-chain.spec. + }); + + it("still fails LOUD immediately when the pre-insert itself failed — nothing exists for a sweep to recover", async () => { + const dispatchMock = vi.fn().mockRejectedValue(new Error("dispatch down")); + const h = makeHarness({ fireInternalDispatch: dispatchMock as any }); + (h.deps.insertRun as any).mockRejectedValueOnce(new Error("insert failed")); + await runChain(h); + + // No successor row was ever created, so there is nothing to defer to a + // sweep — this is genuinely unrecoverable and must fail loud immediately, + // same as before this change. + expect(h.deps.updateRunStatusIfRunning).toHaveBeenCalledTimes(1); + expect(h.deps.updateRunStatusIfRunning).toHaveBeenCalledWith( + "run-chunk0", + "errored", + ); + expect(h.deps.setRunTerminalReason).toHaveBeenCalledWith( + "run-chunk0", + "background_continuation_dispatch_failed", + ); + expect(h.deps.recordRunDiagnostic).toHaveBeenCalledWith( + "run-chunk0", + RUN_DIAG_STAGE.workerThrew, + expect.stringContaining("chain_dispatch_failed"), + ); }); it("refuses to chain when the SQL per-turn run budget is exhausted (cross-chain loop killer)", async () => { @@ -379,7 +419,7 @@ describe("resolveContinuationDispatchBudget — retry budget matrix", () => { }); describe("chainServerDrivenContinuation — worker proven in background function gets the widened budget", () => { - it("retries up to 5 times at a 15s response timeout, using the capped backoff schedule, before failing loud", async () => { + it("retries up to 5 times at a 15s response timeout, using the capped backoff schedule, before deferring to the sweep", async () => { const dispatchMock = vi.fn().mockRejectedValue(new Error("fetch failed")); const h = makeHarness({ fireInternalDispatch: dispatchMock as any }); await runChain(h, { @@ -398,10 +438,12 @@ describe("chainServerDrivenContinuation — worker proven in background function ); expect(sleepCalls).toEqual([500, 1000, 2000, 4000]); // Dispatch still targets the regular `_process-run` route (unchanged - // target — only the budget widened), and both rows go terminal loudly - // on final exhaustion, same as the foreground exhaustion path. + // target — only the budget widened). This is exactly the case the + // recovery was built for: a background-function worker with NO + // connected-client fallback — the pre-inserted successor is left for the + // sweep instead of being errored immediately. expect(dispatch.path).toBe(AGENT_CHAT_PROCESS_RUN_PATH); - expect(h.deps.updateRunStatusIfRunning).toHaveBeenCalledWith( + expect(h.deps.updateRunStatusIfRunning).not.toHaveBeenCalledWith( "run-next", "errored", ); @@ -411,7 +453,7 @@ describe("chainServerDrivenContinuation — worker proven in background function ); expect(h.deps.setRunTerminalReason).toHaveBeenCalledWith( "run-chunk0", - "background_continuation_dispatch_failed", + "background_continuation_dispatch_deferred", ); }); }); @@ -439,9 +481,216 @@ describe("chainServerDrivenContinuation — durable-background path unchanged", // A Netlify background fn 202s on enqueue, so a failed await IS a dead // handoff — the claim-check shortcut is foreground-only. expect(readClaim).not.toHaveBeenCalled(); + // This chunk still goes terminal loudly, but the recoverable-vs-fatal + // split applies uniformly regardless of dispatch target: the pre-inserted + // successor row exists in SQL either way, so it is left for the sweep + // instead of being errored immediately. + expect(h.deps.updateRunStatusIfRunning).toHaveBeenCalledWith( + "run-chunk0", + "errored", + ); + expect(h.deps.setRunTerminalReason).toHaveBeenCalledWith( + "run-chunk0", + "background_continuation_dispatch_deferred", + ); + expect(h.deps.updateRunStatusIfRunning).not.toHaveBeenCalledWith( + "run-next", + "errored", + ); + }); +}); + +describe("isLoopProtectionDispatchError — classifies Netlify's undocumented loop-protection response", () => { + it("matches the exact message self-dispatch.ts's dispatchResponseError constructs for a 508", () => { + expect( + isLoopProtectionDispatchError( + new Error( + "Self-dispatch to /_agent-native/agent-chat/_process-run returned HTTP 508 Loop Detected", + ), + ), + ).toBe(true); + }); + + it("does not match a generic transient dispatch failure", () => { + expect(isLoopProtectionDispatchError(new Error("fetch failed"))).toBe( + false, + ); + expect( + isLoopProtectionDispatchError( + new Error( + "Self-dispatch to /_agent-native/agent-chat/_process-run returned HTTP 503 Service Unavailable", + ), + ), + ).toBe(false); + }); + + it("does not match a non-Error value", () => { + expect(isLoopProtectionDispatchError("HTTP 508")).toBe(false); + expect(isLoopProtectionDispatchError(undefined)).toBe(false); + }); +}); + +describe("chainServerDrivenContinuation — Netlify loop-protection 508 is classified and DEFERRED, not fatally errored", () => { + it("stops retrying immediately on a 508 instead of burning the full dispatch budget — distinct from a generic 'fetch failed'", async () => { + const dispatchMock = vi + .fn() + .mockRejectedValue( + new Error( + "Self-dispatch to /_agent-native/agent-chat/_process-run returned HTTP 508 Loop Detected", + ), + ); + const h = makeHarness({ fireInternalDispatch: dispatchMock as any }); + await runChain(h); + + // The foreground budget allows 2 attempts, but a 508 is a property of + // this same nested call chain — retrying it will not help, so the loop + // stops after the FIRST attempt instead of exhausting the budget. + expect(dispatchMock).toHaveBeenCalledTimes(1); + + // Still deferred — never the fatal `background_continuation_dispatch_failed`. + expect(h.deps.updateRunStatusIfRunning).toHaveBeenCalledWith( + "run-chunk0", + "errored", + ); + expect(h.deps.setRunTerminalReason).toHaveBeenCalledWith( + "run-chunk0", + "background_continuation_dispatch_deferred", + ); + expect(h.deps.setRunTerminalReason).not.toHaveBeenCalledWith( + "run-chunk0", + "background_continuation_dispatch_failed", + ); + // The successor row itself is left alone for the sweep — never errored. + expect(h.deps.updateRunStatusIfRunning).not.toHaveBeenCalledWith( + "run-next", + "errored", + ); + // Distinctly classified in the diagnostics — greppable apart from a + // generic "dispatch_budget_exhausted" deferral. + expect(h.deps.recordRunDiagnostic).toHaveBeenCalledWith( + "run-chunk0", + RUN_DIAG_STAGE.workerThrew, + expect.stringContaining( + "chain_dispatch_deferred[netlify_loop_protection]", + ), + ); + }); + + it("still burns the full retry budget for a generic transient error (unchanged behavior)", async () => { + const dispatchMock = vi.fn().mockRejectedValue(new Error("fetch failed")); + const h = makeHarness({ fireInternalDispatch: dispatchMock as any }); + await runChain(h); + + expect(dispatchMock).toHaveBeenCalledTimes(2); + expect(h.deps.recordRunDiagnostic).toHaveBeenCalledWith( + "run-chunk0", + RUN_DIAG_STAGE.workerThrew, + expect.stringContaining( + "chain_dispatch_deferred[dispatch_budget_exhausted]", + ), + ); + }); +}); + +describe("chainServerDrivenContinuation — proactive nested-dispatch depth cap", () => { + it("defers WITHOUT ever attempting a dispatch once backgroundContinuationCount reaches MAX_NESTED_SELF_DISPATCH_DEPTH", async () => { + const dispatchMock = vi.fn().mockResolvedValue(undefined); + const h = makeHarness({ fireInternalDispatch: dispatchMock as any }); + await runChain(h, { + backgroundContinuationCount: MAX_NESTED_SELF_DISPATCH_DEPTH, + }); + + // No nested self-dispatch was even attempted — avoided the doomed call + // entirely instead of reacting to it after the fact. + expect(dispatchMock).not.toHaveBeenCalled(); + // The successor row was still pre-inserted (so the sweep has something to + // find) and this chunk is deferred, exactly like an exhausted retry budget. + expect(h.deps.insertRun).toHaveBeenCalled(); + expect(h.deps.updateRunStatusIfRunning).toHaveBeenCalledWith( + "run-chunk0", + "errored", + ); + expect(h.deps.setRunTerminalReason).toHaveBeenCalledWith( + "run-chunk0", + "background_continuation_dispatch_deferred", + ); + expect(h.deps.recordRunDiagnostic).toHaveBeenCalledWith( + "run-chunk0", + RUN_DIAG_STAGE.workerThrew, + expect.stringContaining("chain_dispatch_deferred[proactive_depth_cap]"), + ); + }); + + it("dispatches normally below the depth cap", async () => { + const dispatchMock = vi.fn().mockResolvedValue(undefined); + const h = makeHarness({ fireInternalDispatch: dispatchMock as any }); + await runChain(h, { + backgroundContinuationCount: MAX_NESTED_SELF_DISPATCH_DEPTH - 1, + }); + + expect(dispatchMock).toHaveBeenCalledTimes(1); + expect(h.deps.markBackgroundContinuationChunkTerminal).toHaveBeenCalled(); + expect(h.deps.updateRunStatusIfRunning).not.toHaveBeenCalled(); + }); + + it("applies the SAME depth cap regardless of continuation reason (run_timeout, loop_limit alike) — the cap is about nested self-dispatch mechanics, not turn behavior", async () => { + const loopLimitRun = makeRun([{ type: "loop_limit" }]); + const dispatchMock = vi.fn().mockResolvedValue(undefined); + const h = makeHarness({ fireInternalDispatch: dispatchMock as any }); + await runChain(h, { + backgroundContinuationCount: MAX_NESTED_SELF_DISPATCH_DEPTH, + run: loopLimitRun, + }); + + expect(dispatchMock).not.toHaveBeenCalled(); + expect(h.deps.setRunTerminalReason).toHaveBeenCalledWith( + "run-chunk0", + "background_continuation_dispatch_deferred", + ); + }); + + it("applies uniformly on the durable-background dispatch target too (Background Functions do not escape Netlify's loop protection)", async () => { + process.env.NETLIFY = "true"; + const dispatchMock = vi.fn().mockResolvedValue(undefined); + const h = makeHarness({ fireInternalDispatch: dispatchMock as any }); + await runChain(h, { + chainViaDurableBackground: true, + backgroundContinuationCount: MAX_NESTED_SELF_DISPATCH_DEPTH, + }); + + expect(dispatchMock).not.toHaveBeenCalled(); + expect(h.deps.setRunTerminalReason).toHaveBeenCalledWith( + "run-chunk0", + "background_continuation_dispatch_deferred", + ); + }); +}); + +describe("chainServerDrivenContinuation — the intentional per-turn budget still caps a chain of deferred/redispatched segments", () => { + it("refuses to chain past the SQL per-turn ledger even when backgroundContinuationCount has been reset by sweep-mediated chain breaks", async () => { + // Simulates a turn that has already been through several sweep-mediated + // chain breaks (each resets backgroundContinuationCount to 0 — see the + // "Unclaimed background-run sweep" in agent-chat-plugin.ts) but has + // genuinely consumed far more runs than the intentional budget allows. + // The durable SQL ledger (countRunsForTurn), NOT the in-marker count, is + // what must catch this. + const h = makeHarness({ + countRunsForTurn: vi.fn( + async () => MAX_BACKGROUND_RUN_CONTINUATIONS + 6, + ) as any, + }); + const dispatchMock = h.deps.fireInternalDispatch as any; + await runChain(h, { backgroundContinuationCount: 0 }); + + expect(h.deps.insertRun).not.toHaveBeenCalled(); + expect(dispatchMock).not.toHaveBeenCalled(); expect(h.deps.updateRunStatusIfRunning).toHaveBeenCalledWith( "run-chunk0", "errored", ); + expect(h.deps.setRunTerminalReason).toHaveBeenCalledWith( + "run-chunk0", + "turn_continuation_budget_exhausted", + ); }); }); diff --git a/packages/core/src/agent/production-agent.ts b/packages/core/src/agent/production-agent.ts index d4580aa17c..3ed65d8f7e 100644 --- a/packages/core/src/agent/production-agent.ts +++ b/packages/core/src/agent/production-agent.ts @@ -5038,6 +5038,70 @@ export function resolveContinuationDispatchBudget(opts: { }; } +/** + * True when a `fireInternalDispatch` failure is Netlify's Functions platform + * loop-protection response (`HTTP 508 Loop Detected`), matched against the + * exact message `self-dispatch.ts`'s `dispatchResponseError` constructs + * (`Self-dispatch to ${path} returned HTTP ${res.status} ${res.statusText}...`). + * Matches on the status code alone (not the reason phrase text) so it is + * robust to any wording Netlify's edge puts in `statusText`. + * + * VERIFIED: production `agent_runs` diagnostics show exactly this failure mode + * — 8 successful `chain_dispatch_sent` self-dispatches followed by a 9th/10th + * that dies with `HTTP 508 Loop Detected`, terminal reason + * `background_continuation_dispatch_failed`. UNVERIFIED (Netlify does not + * document the mechanism — checked the Functions overview, Functions API + * reference, Background Functions overview, Status Codes reference, and + * Request Chain troubleshooting docs, none mention it): the ONLY public + * confirmation is a Netlify staff forum reply — "we prevent multiple + * executions after one-another as that's a 'loop' ... I believe we enforce + * this after 9 or 10 self-invocations" + * (https://answers.netlify.com/t/self-invoke-background-function-via-post-requests/146012) + * — which also confirms Background Functions do NOT escape the limit (the + * reporter's case was already a `-background` function self-invoking). See + * `MAX_NESTED_SELF_DISPATCH_DEPTH` for how this classification is used. + */ +export function isLoopProtectionDispatchError(err: unknown): boolean { + if (!(err instanceof Error)) return false; + return /\bHTTP\s*508\b/i.test(err.message); +} + +/** + * Conservative safety margin on NESTED self-dispatch chaining — a + * continuation dispatched directly from within the live invocation that is + * about to finish, as opposed to being picked up later by the + * unclaimed-background-run sweep (`agent-chat-plugin.ts`), which fires its + * redispatch from an unrelated, timer-driven invocation rather than from + * inside the chain's own execution. + * + * Netlify's loop-protection ceiling is undocumented and platform-reported + * only approximately ("I believe... 9 or 10" — see + * `isLoopProtectionDispatchError`), so hard-coding that exact number here + * would be pinning behavior to a number Netlify itself won't commit to, and + * production evidence shows it can trigger by the 9th self-dispatch. Rather + * than only reacting after triggering that undocumented limit, + * `chainServerDrivenContinuation` proactively hands a chunk to the sweep once + * `backgroundContinuationCount` (nested hops since the last chain break) + * reaches this value — comfortably below the observed ~9-10 failure point — + * instead of attempting another nested dispatch. This is the SAME deferred- + * recovery path already used when a dispatch fails outright: the row is left + * `status='running', dispatch_mode='background'` for the sweep to redispatch, + * never silently dropped. + * + * The sweep's redispatch marker intentionally omits `continuationCount` (see + * the "Unclaimed background-run sweep" in `agent-chat-plugin.ts`), so a + * sweep-recovered chunk's own `backgroundContinuationCount` resets to 0 — + * this constant therefore bounds each NESTED segment between chain breaks, + * not the whole turn. The TRUE ceiling on total turn length is unaffected by + * that reset: it is the durable per-turn SQL ledger (`countRunsForTurn`, + * checked above in this function) plus `MAX_BACKGROUND_RUN_CONTINUATIONS` — + * both counted independently of this in-marker value — so a legitimately + * long turn keeps making progress in bounded nested segments, each recovered + * by the sweep, until it genuinely exhausts the intentional overall budget + * and fails loud with `turn_continuation_budget_exhausted`. + */ +export const MAX_NESTED_SELF_DISPATCH_DEPTH = 6; + /** * Server-driven continuation handoff for a chunk that hit its soft-timeout * boundary still unfinished: mint the next chunk's runId, PRE-INSERT its run @@ -5046,12 +5110,26 @@ export function resolveContinuationDispatchBudget(opts: { * `_process-run` self-dispatch carrying ids only (the body is persisted on * the row as `dispatch_payload`), fully AWAIT the dispatch acknowledgment * with retries, and mark this chunk terminal only after the handoff landed. - * On failure every path is loud: the successor row is errored, the failure - * is recorded as the run's diag stage, and this chunk is flipped to errored - * with a terminal reason — never a silent loss. (For a FOREGROUND self-chain - * the client additionally still receives the terminal `auto_continue` event - * — run-manager emits it after this callback — so the existing client - * re-POST path takes over as the fallback.) + * On failure this chunk always goes terminal loudly (diag stage + terminal + * reason recorded — never a silent loss), but the successor row's fate + * depends on WHY the dispatch failed: + * - the pre-insert itself failed (no successor row exists) — nothing for a + * sweep to find, so this is unrecoverable: fail loud immediately with + * `background_continuation_dispatch_failed`. + * - every dispatch attempt failed but the successor row DOES exist with its + * `dispatch_payload` intact — this is RECOVERABLE: the row is left alone + * (`status='running', dispatch_mode='background'`) instead of being + * errored, so the unclaimed-background-run sweep in `agent-chat-plugin.ts` + * can redispatch it once `UNCLAIMED_BACKGROUND_RUN_GRACE_MS` has passed. + * This chunk is flipped to errored with the distinct, honest + * `background_continuation_dispatch_deferred` reason — the TURN is + * deferred, not dead. The sweep still bounds this by + * `UNCLAIMED_BACKGROUND_RUN_REDISPATCH_BOUND_MS`: past that it falls back + * to the existing loud reap, so a genuinely dead handoff never hangs + * silently forever. (For a FOREGROUND self-chain the client additionally + * still receives the terminal `auto_continue` event — run-manager emits + * it after this callback — so the existing client re-POST path is a + * second, faster fallback alongside the sweep.) * * `chainViaDurableBackground` selects the dispatch target: * - true → the durable-background worker chain (unchanged behavior): the @@ -5244,9 +5322,23 @@ export async function chainServerDrivenContinuation(opts: { }; let dispatched = false; let lastDispatchErr: unknown; + // Proactive nested-chain safety margin — see `MAX_NESTED_SELF_DISPATCH_DEPTH`. + // Skip the nested dispatch attempt entirely once this segment's hop count + // reaches the cap; a nested attempt at this depth is expected to trip + // Netlify's loop protection, so there is nothing to gain by trying it and + // burning this worker's remaining wall clock on a doomed call. Falls + // straight into the same deferred-recovery path below as an exhausted + // retry budget. + const nestedDepthExceeded = + opts.backgroundContinuationCount >= MAX_NESTED_SELF_DISPATCH_DEPTH; + if (nestedDepthExceeded) { + lastDispatchErr = new Error( + `proactive nested-dispatch depth cap reached (backgroundContinuationCount=${opts.backgroundContinuationCount} >= MAX_NESTED_SELF_DISPATCH_DEPTH=${MAX_NESTED_SELF_DISPATCH_DEPTH}) — deferring to the unclaimed-background-run sweep instead of risking Netlify loop protection`, + ); + } for ( let attempt = 0; - attempt < maxDispatchAttempts && !dispatched; + !nestedDepthExceeded && attempt < maxDispatchAttempts && !dispatched; attempt++ ) { try { @@ -5313,25 +5405,99 @@ export async function chainServerDrivenContinuation(opts: { `[agent-chat] background continuation dispatch attempt ${attempt + 1} failed:`, dispatchErr instanceof Error ? dispatchErr.message : dispatchErr, ); + // Netlify loop protection (see `isLoopProtectionDispatchError`) is a + // property of this same live, nested call chain — retrying the exact + // same nested self-dispatch within the next few seconds will not + // change that, so further attempts are a guaranteed-doomed use of + // this worker's remaining wall clock. Stop immediately (instead of + // burning the full `maxDispatchAttempts` budget) and fall into the + // same deferred-recovery path below, which hands the successor to the + // sweep — a genuinely different, non-nested invocation. + if (isLoopProtectionDispatchError(dispatchErr)) { + break; + } } } if (!dispatched) { - // The pre-inserted successor row would otherwise sit unclaimed until - // the sweep reaps it — error it now so the failure is immediate and - // truthful. if (nextRowInserted) { - const nextStatusUpdated = await d - .updateRunStatusIfRunning(nextRunId, "errored") + // Classify WHY this handoff is being deferred — distinct, greppable + // tags so production diagnostics (which is all that is readable from + // a background worker) can tell "we proactively avoided Netlify loop + // protection", "we hit loop protection and stopped retrying", and "a + // generic transient dispatch failure exhausted its retry budget" + // apart, instead of lumping every deferred handoff into one bucket. + const deferralClassification = nestedDepthExceeded + ? "proactive_depth_cap" + : isLoopProtectionDispatchError(lastDispatchErr) + ? "netlify_loop_protection" + : "dispatch_budget_exhausted"; + // RECOVERABLE: the successor row already exists in SQL with its + // rehydration payload (`dispatch_payload`) intact and is still + // `status='running', dispatch_mode='background'` — exactly the state + // the unclaimed-background-run sweep (`agent-chat-plugin.ts`) already + // scans for. Do NOT error it here: leave it alone so the sweep can + // redispatch it once `UNCLAIMED_BACKGROUND_RUN_GRACE_MS` has passed, + // bounded by `UNCLAIMED_BACKGROUND_RUN_REDISPATCH_BOUND_MS` before it + // falls back to the existing loud reap + // (`background_worker_never_started`) — so this is deferred, never a + // silent hang. This chunk still goes terminal (its own soft-timeout + // budget is genuinely spent), but with an honest reason: the TURN is + // not dead, only this handoff attempt was. + // + // THREE-SITE INVARIANT (keep in lockstep — a future reader must not + // "fix" one without the others): this deferral only survives because + // the ~1s client poll in `getActiveRunForThreadAsync` + // (run-manager.ts) ALSO skips `reapUnclaimedBackgroundRun` while the + // successor is within `UNCLAIMED_BACKGROUND_RUN_REDISPATCH_BOUND_MS` + // (via `shouldRedispatchUnclaimedBackgroundRun`). Without that guard a + // connected client would reap this row at the 25s grace, before the + // ~2-min sweep, defeating the deferral. The sweep in agent-chat-plugin + // is the recovery actor; run-manager is the guard; this is the + // producer. + await d + .recordRunDiagnostic( + nextRunId, + RUN_DIAG_STAGE.workerThrew, + `chain_dispatch_deferred[${deferralClassification}]: dispatch budget exhausted (${maxDispatchAttempts} attempts) awaiting unclaimed-run sweep redispatch; ${ + lastDispatchErr instanceof Error + ? lastDispatchErr.message + : String(lastDispatchErr) + }`, + ) + .catch(() => {}); + await d + .recordRunDiagnostic( + runId, + RUN_DIAG_STAGE.workerThrew, + `chain_dispatch_deferred[${deferralClassification}] nextRunId=${nextRunId} ${ + lastDispatchErr instanceof Error + ? lastDispatchErr.message + : String(lastDispatchErr) + }`, + ) + .catch(() => {}); + console.error( + `[agent-chat] background continuation dispatch deferred (${deferralClassification}); leaving the pre-inserted successor for the unclaimed-run sweep to redispatch:`, + lastDispatchErr instanceof Error + ? lastDispatchErr.message + : lastDispatchErr, + ); + const statusUpdated = await d + .updateRunStatusIfRunning(runId, "errored") .catch(() => false); - if (nextStatusUpdated) { + if (statusUpdated) { await d .setRunTerminalReason( - nextRunId, - "background_continuation_dispatch_failed", + runId, + "background_continuation_dispatch_deferred", ) .catch(() => {}); } + return; } + // No successor row exists at all (the pre-insert itself failed) — there + // is nothing for the sweep to find and recover, so this really is + // fatal. Fail loud immediately via the shared catch block below. throw lastDispatchErr instanceof Error ? lastDispatchErr : new Error(String(lastDispatchErr)); diff --git a/packages/core/src/agent/run-manager.spec.ts b/packages/core/src/agent/run-manager.spec.ts index 65aace4317..d4864cf394 100644 --- a/packages/core/src/agent/run-manager.spec.ts +++ b/packages/core/src/agent/run-manager.spec.ts @@ -24,6 +24,13 @@ vi.mock("./run-store.js", () => ({ bumpRunProgress: vi.fn(() => Promise.resolve()), reapIfStale: vi.fn(() => Promise.resolve(null)), reapUnclaimedBackgroundRun: vi.fn(() => Promise.resolve(false)), + // Faithful copy of the real pure predicate (5-min redispatch bound) so the + // run-manager client-poll guard can be exercised without the real DB module. + UNCLAIMED_BACKGROUND_RUN_REDISPATCH_BOUND_MS: 5 * 60_000, + shouldRedispatchUnclaimedBackgroundRun: ( + row: { startedAt: number }, + now: number = Date.now(), + ) => now - row.startedAt < 5 * 60_000, reconcileTerminalRunFromEvents: vi.fn(() => Promise.resolve(false)), ensureTerminalRunEvent: vi.fn(() => Promise.resolve()), getLastTerminalRunEvent: vi.fn(() => Promise.resolve(null)), @@ -1831,14 +1838,16 @@ describe("run manager soft timeout", () => { }); // ─── FALLBACK HARDENING: unclaimed background run recovery ────────────────── - it("recovers an unclaimed-stale background run (202 acked, worker never started)", async () => { + it("reaps an unclaimed-stale background run PAST the redispatch bound (202 acked, worker never started, no recovery left)", async () => { // dispatch_mode still 'background' (never flipped to 'background-processing') - // means the bg-fn worker silently died. The read path must recover it. + // means the bg-fn worker silently died. Once the successor is OLDER than the + // redispatch bound the sweep has had its chances, so the client poll reaps it + // loudly — this is the moved-later loud failure. vi.mocked(getRunByThread).mockResolvedValue({ id: "run-unclaimed", threadId: "thread-unclaimed", status: "running", - startedAt: Date.now() - 30_000, + startedAt: Date.now() - (5 * 60_000 + 30_000), // past the 5-min bound heartbeatAt: Date.now() - 30_000, completedAt: null, lastProgressAt: null, @@ -1857,6 +1866,42 @@ describe("run manager soft timeout", () => { expect(reapIfStale).not.toHaveBeenCalled(); }); + it("does NOT reap a deferred background successor while still WITHIN the redispatch bound — leaves it for the sweep", async () => { + // A successor that chainServerDrivenContinuation deferred (dispatch failed, + // row left running+background for the sweep to redispatch). At 30s it is well + // inside the 5-min redispatch bound, so the ~1s client poll must NOT reap it + // at the 25s unclaimed grace — that would convert the silent server-side + // recovery into a user-visible background_worker_never_started manual-retry + // error. reapIfStale (90s → stale_run auto-continue) stays the outer backstop. + vi.mocked(getRunByThread).mockResolvedValue({ + id: "run-deferred", + threadId: "thread-deferred", + status: "running", + startedAt: Date.now() - 30_000, // within the 5-min bound + heartbeatAt: Date.now() - 30_000, + completedAt: null, + lastProgressAt: null, + dispatchMode: "background", + diagStage: null, + }); + vi.mocked(reapUnclaimedBackgroundRun).mockClear(); + // reapIfStale not yet eligible (background 90s window) → returns false, so the + // still-running successor is surfaced as active while it awaits the sweep. + vi.mocked(reapIfStale).mockResolvedValueOnce(false); + + const result = await getActiveRunForThreadAsync("thread-deferred"); + + // The unclaimed reap was skipped — the sweep owns recovery inside the bound. + expect(reapUnclaimedBackgroundRun).not.toHaveBeenCalled(); + // The run is still surfaced as an active background run (client keeps + // following; no premature manual-retry error). + expect(result).toMatchObject({ + runId: "run-deferred", + status: "running", + dispatchMode: "background", + }); + }); + it("does NOT attempt unclaimed recovery for a claimed (background-processing) run", async () => { vi.mocked(getRunByThread).mockResolvedValue({ id: "run-processing", diff --git a/packages/core/src/agent/run-manager.ts b/packages/core/src/agent/run-manager.ts index 86867e5f10..81fccdcbee 100644 --- a/packages/core/src/agent/run-manager.ts +++ b/packages/core/src/agent/run-manager.ts @@ -16,6 +16,7 @@ import { bumpRunProgress, reapIfStale, reapUnclaimedBackgroundRun, + shouldRedispatchUnclaimedBackgroundRun, reconcileTerminalRunFromEvents, ensureTerminalRunEvent, getLastTerminalRunEvent, @@ -1469,10 +1470,36 @@ export async function getActiveRunForThreadAsync(threadId: string): Promise<{ // past the tight grace means the bg-fn worker never started — a silent // async-worker death that the 202-ack inline fallback can't catch. Reap it // early and recoverably (background_worker_never_started) so the run no - // longer hangs for the full 90s window and the client's recoverable-error - // path can re-drive the turn. Only fires when there is provably no live - // worker; a claimed/heartbeating run is left alone by the conditional SQL. - if (sqlRun.dispatchMode === "background") { + // longer hangs for the full 90s window. Only fires when there is provably + // no live worker; a claimed/heartbeating run is left alone by the + // conditional SQL. + // + // REDISPATCH-BOUND GUARD (must be kept in lockstep with the "Unclaimed + // background-run sweep" in agent-chat-plugin.ts and with + // chainServerDrivenContinuation's deferral in production-agent.ts — do NOT + // remove this guard without reading those two sites): + // `chainServerDrivenContinuation` now DEFERS a dispatch-failed successor + // instead of erroring it — it leaves the row status='running', + // dispatch_mode='background' with its dispatch_payload intact so the sweep + // can silently redispatch it. This client poll runs every ~1s while a + // client is connected, so without this guard it would reap that deferred + // successor at the 25s unclaimed grace — long before the ~2-min sweep — + // converting the intended SILENT server-side recovery into a user-visible + // `background_worker_never_started` manual-retry error (that terminal + // reason does NOT auto-continue in the client follow loop; only `stale_run` + // does). While the successor is still inside its redispatch bound we skip + // this reap and leave it for the sweep. The outer backstops still bound it: + // `reapIfStale` below reaps a heartbeat-stale background row at 90s + // (BACKGROUND_RUN_STALE_MS) to the recoverable `stale_run` — which the + // follow loop AUTO-continues — and once the redispatch bound is exceeded + // this reap fires loudly as before. So recovery stays automatic in the + // common case and loud failure is only moved later, never removed. + if ( + sqlRun.dispatchMode === "background" && + !shouldRedispatchUnclaimedBackgroundRun({ + startedAt: sqlRun.startedAt, + }) + ) { const recovered = await reapUnclaimedBackgroundRun(sqlRun.id).catch( () => false, ); diff --git a/packages/core/src/agent/run-store.foreground-self-chain.spec.ts b/packages/core/src/agent/run-store.foreground-self-chain.spec.ts index 930f854e21..ef9c4ac9f4 100644 --- a/packages/core/src/agent/run-store.foreground-self-chain.spec.ts +++ b/packages/core/src/agent/run-store.foreground-self-chain.spec.ts @@ -182,4 +182,47 @@ describe("foreground self-chain — reaper coverage for the handoff window", () expect(await reapUnclaimedBackgroundRun(successor)).toBe(false); expect((await getRunById(successor))?.status).toBe("running"); }); + + // ── Deferred-successor recovery: sweep redispatch vs. reap interleaving ── + // A dispatch-deferred successor can now be recovered by the sweep OR reaped by + // a backstop; these prove the claim CAS keeps the two mutually exclusive so + // there is never a double-run and never a run-forever. + + it("a redispatched worker that ARRIVES AFTER the row was reaped cannot execute (CAS requires status='running')", async () => { + const { successor, thread } = ids(); + await insertRun(successor, thread, "turn-1", { + dispatchMode: "background", + }); + setLiveness(successor, Date.now() - 60_000); + + // A backstop (client-poll past the bound, or reapIfStale) reaps the row + // first: it is now terminal. + expect(await reapUnclaimedBackgroundRun(successor)).toBe(true); + expect((await getRunById(successor))?.status).toBe("errored"); + + // A sweep redispatch that was already in flight lands late; the worker it + // wakes tries to claim — the CAS (status='running' AND + // dispatch_mode='background') rejects the reaped row, so it no-ops instead + // of executing a turn nobody is watching. + expect(await claimBackgroundRun(successor)).toBe(false); + }); + + it("once a redispatched worker CLAIMS the row, a later reap cannot resurrect or double-run it", async () => { + const { successor, thread } = ids(); + await insertRun(successor, thread, "turn-1", { + dispatchMode: "background", + }); + + // The sweep redispatched and a worker won the claim first: the row is now + // dispatch_mode='background-processing', still running. + expect(await claimBackgroundRun(successor)).toBe(true); + + // A concurrent unclaimed-reap can no longer touch it — its WHERE clause + // requires dispatch_mode='background', which the claim already changed. So + // the claimed worker owns the run exclusively; no reap, no second claim. + setLiveness(successor, Date.now() - 60_000); + expect(await reapUnclaimedBackgroundRun(successor)).toBe(false); + expect((await getRunById(successor))?.status).toBe("running"); + expect(await claimBackgroundRun(successor)).toBe(false); + }); }); diff --git a/packages/core/src/agent/run-store.spec.ts b/packages/core/src/agent/run-store.spec.ts index 1531033588..44c11a82ce 100644 --- a/packages/core/src/agent/run-store.spec.ts +++ b/packages/core/src/agent/run-store.spec.ts @@ -29,6 +29,10 @@ let insertEventBehavior: () => void = () => {}; let abortRowsAffected = 1; let dispatchPayloadRows: Array<{ dispatch_payload: string | null }> = []; let unclaimedBackgroundRunRows: Array<{ id: string }> = []; +let unclaimedBackgroundRunRowsWithStartedAt: Array<{ + id: string; + started_at: number; +}> = []; let runCountRows: Array<{ run_count: number }> = []; const mockDb = { @@ -55,6 +59,18 @@ const mockDb = { ) { return { rows: claimSlotRows, rowsAffected: 0 }; } + // listUnclaimedBackgroundRunRows: SELECT id, started_at FROM agent_runs + // WHERE status = 'running' AND dispatch_mode = 'background' AND ... Must + // come before the narrower id-only variant below (both match + // "dispatch_mode = 'background'"). + if ( + /SELECT id, started_at FROM agent_runs\s*WHERE status = 'running'/i.test( + rawSql, + ) && + /dispatch_mode = 'background'/i.test(rawSql) + ) { + return { rows: unclaimedBackgroundRunRowsWithStartedAt, rowsAffected: 0 }; + } // listUnclaimedBackgroundRunIds: SELECT id FROM agent_runs WHERE status = // 'running' AND dispatch_mode = 'background' AND ... Must also come before // the broader stale-run SELECT check below, which matches the same shape. @@ -156,7 +172,10 @@ const { readRunDispatchPayload, clearRunDispatchPayload, listUnclaimedBackgroundRunIds, + listUnclaimedBackgroundRunRows, countRunsForTurn, + UNCLAIMED_BACKGROUND_RUN_REDISPATCH_BOUND_MS, + shouldRedispatchUnclaimedBackgroundRun, } = await import("./run-store.js"); // Mock storage for ledger SELECT responses, keyed by toolKey @@ -177,6 +196,7 @@ describe("run store", () => { ledgerRows = []; dispatchPayloadRows = []; unclaimedBackgroundRunRows = []; + unclaimedBackgroundRunRowsWithStartedAt = []; runCountRows = []; insertEventBehavior = () => {}; abortRowsAffected = 1; @@ -862,4 +882,86 @@ describe("run store", () => { const ids = await listUnclaimedBackgroundRunIds(); expect(ids).toEqual(["run-ok"]); }); + + // ─── listUnclaimedBackgroundRunRows (sweep redispatch bound) ─────────────── + + it("listUnclaimedBackgroundRunRows returns each row's original started_at alongside its id", async () => { + unclaimedBackgroundRunRowsWithStartedAt = [ + { id: "run-lost-1", started_at: 111 }, + { id: "run-lost-2", started_at: 222 }, + ]; + const rows = await listUnclaimedBackgroundRunRows(); + expect(rows).toEqual([ + { id: "run-lost-1", startedAt: 111 }, + { id: "run-lost-2", startedAt: 222 }, + ]); + + const select = execCalls.find((call) => + /SELECT id, started_at FROM agent_runs\s*WHERE status = 'running'/i.test( + call.sql, + ), + ); + expect(select?.sql).toContain("dispatch_mode = 'background'"); + expect(select?.sql).toContain("COALESCE(heartbeat_at, started_at)"); + }); + + it("listUnclaimedBackgroundRunRows ignores rows with a non-string/empty id defensively", async () => { + unclaimedBackgroundRunRowsWithStartedAt = [ + { id: "run-ok", started_at: 100 }, + // @ts-expect-error -- exercising defensive filtering of malformed rows + { id: null, started_at: 200 }, + ]; + const rows = await listUnclaimedBackgroundRunRows(); + expect(rows).toEqual([{ id: "run-ok", startedAt: 100 }]); + }); + + it("UNCLAIMED_BACKGROUND_RUN_REDISPATCH_BOUND_MS is a real bound wider than the grace window — never zero, never infinite", () => { + // The sweep must get more than one redispatch attempt (grace window is + // 25s, sweep tick is ~2min) but the bound must still be finite so a + // permanently-dead handoff eventually fails loud instead of retrying + // forever. + expect(UNCLAIMED_BACKGROUND_RUN_REDISPATCH_BOUND_MS).toBeGreaterThan( + 2 * 60_000, + ); + expect(Number.isFinite(UNCLAIMED_BACKGROUND_RUN_REDISPATCH_BOUND_MS)).toBe( + true, + ); + }); + + // ─── shouldRedispatchUnclaimedBackgroundRun (bounded recovery backstop) ──── + + it("shouldRedispatchUnclaimedBackgroundRun allows redispatch while inside the bound", () => { + const now = 1_000_000; + const row = { + startedAt: now - UNCLAIMED_BACKGROUND_RUN_REDISPATCH_BOUND_MS + 1, + }; + expect(shouldRedispatchUnclaimedBackgroundRun(row, now)).toBe(true); + }); + + it("shouldRedispatchUnclaimedBackgroundRun falls back to the reap once the bound is exceeded — the loud backstop", () => { + const now = 1_000_000; + // Exactly at the bound: no longer "within" it (strict <). + const atBound = { + startedAt: now - UNCLAIMED_BACKGROUND_RUN_REDISPATCH_BOUND_MS, + }; + expect(shouldRedispatchUnclaimedBackgroundRun(atBound, now)).toBe(false); + + // Well past the bound — a genuinely dead handoff must stop being + // redispatched forever and go to the reap instead. + const wayPast = { + startedAt: now - UNCLAIMED_BACKGROUND_RUN_REDISPATCH_BOUND_MS * 10, + }; + expect(shouldRedispatchUnclaimedBackgroundRun(wayPast, now)).toBe(false); + }); + + it("shouldRedispatchUnclaimedBackgroundRun defaults `now` to the real clock", () => { + // A row that just started is always within the bound right now. + expect( + shouldRedispatchUnclaimedBackgroundRun({ startedAt: Date.now() }), + ).toBe(true); + // A row from a very long time ago is not. + expect(shouldRedispatchUnclaimedBackgroundRun({ startedAt: 0 })).toBe( + false, + ); + }); }); diff --git a/packages/core/src/agent/run-store.ts b/packages/core/src/agent/run-store.ts index 29db920308..6f6f125142 100644 --- a/packages/core/src/agent/run-store.ts +++ b/packages/core/src/agent/run-store.ts @@ -99,6 +99,21 @@ export const CLAIMED_BACKGROUND_WORKER_FAILED_ERROR_EVENT = { */ export const UNCLAIMED_BACKGROUND_RUN_GRACE_MS = 25_000; +/** + * Backstop ceiling — measured from the row's ORIGINAL `started_at`, which never + * changes — after which the unclaimed-background-run sweep stops attempting to + * redispatch a lost handoff and instead reaps it via `reapUnclaimedBackgroundRun` + * (loud, attributable `errored`). This is what keeps redispatch recoverable + * WITHOUT becoming a silent hang: a handoff that cannot be delivered within this + * window (a genuinely dead platform, not a transient blip) still fails loudly, + * it just gets a few sweep-cycle chances first. 5 minutes comfortably allows + * multiple 2-minute sweep ticks (see `agent-chat-plugin.ts`'s + * "Unclaimed background-run sweep") while staying well inside both the 40s + * foreground chunk clamp and the ~13min background soft-timeout ceiling that + * bound how long a real user turn is worth waiting on before failing loud. + */ +export const UNCLAIMED_BACKGROUND_RUN_REDISPATCH_BOUND_MS = 5 * 60_000; + async function ensureRunTables(): Promise { if (!_initPromise) { _initPromise = (async () => { @@ -648,6 +663,71 @@ export async function listUnclaimedBackgroundRunIds(): Promise { return ids; } +/** A row returned by `listUnclaimedBackgroundRunRows`. */ +export interface UnclaimedBackgroundRunRow { + id: string; + /** The row's ORIGINAL `started_at` (never bumped by heartbeats), so a + * caller can measure total elapsed time since the handoff was first + * pre-inserted — independent of any liveness bump a redispatch attempt + * makes along the way. */ + startedAt: number; +} + +/** + * Same eligibility as `listUnclaimedBackgroundRunIds`, but also returns each + * row's original `started_at` so a caller can bound total redispatch time + * (see `UNCLAIMED_BACKGROUND_RUN_REDISPATCH_BOUND_MS`) independent of the + * liveness bumps a redispatch attempt makes along the way. Used by the + * unclaimed-background-run sweep's redispatch pass; `listUnclaimedBackgroundRunIds` + * is kept as the simpler, pre-existing surface for callers that only need ids. + */ +export async function listUnclaimedBackgroundRunRows(): Promise< + UnclaimedBackgroundRunRow[] +> { + await ensureRunTables(); + const client = getDbExec(); + const { rows } = await client.execute({ + // CAST keeps the ms-epoch param 64-bit on Postgres (see + // backgroundAwareStaleCutoffSql for the int4-inference failure mode). + sql: `SELECT id, started_at FROM agent_runs + WHERE status = 'running' + AND dispatch_mode = 'background' + AND COALESCE(heartbeat_at, started_at) < (CAST(? AS BIGINT) - ${UNCLAIMED_BACKGROUND_RUN_GRACE_MS})`, + args: [Date.now()], + }); + const result: UnclaimedBackgroundRunRow[] = []; + for (const row of rows ?? []) { + const id = (row as { id?: unknown }).id; + const startedAt = (row as { started_at?: unknown }).started_at; + if (typeof id === "string" && id) { + result.push({ + id, + startedAt: + typeof startedAt === "number" ? startedAt : Number(startedAt) || 0, + }); + } + } + return result; +} + +/** + * Pure decision for the unclaimed-background-run sweep: should THIS row get + * another redispatch attempt, or has it exceeded + * `UNCLAIMED_BACKGROUND_RUN_REDISPATCH_BOUND_MS` and must fall back to the + * loud reap (`reapUnclaimedBackgroundRun`)? Measured from the row's ORIGINAL + * `started_at` (never bumped by a redispatch's heartbeat write), so this is + * the total-elapsed-time backstop that keeps recovery bounded — a handoff + * that cannot be delivered within the window is not spinning forever, it + * fails loud. Exported as a pure function (no DB access) so the bound is unit + * -testable independent of the sweep's setInterval wiring. + */ +export function shouldRedispatchUnclaimedBackgroundRun( + row: { startedAt: number }, + now: number = Date.now(), +): boolean { + return now - row.startedAt < UNCLAIMED_BACKGROUND_RUN_REDISPATCH_BOUND_MS; +} + /** * Count how many runs (chunks) a logical turn has consumed so far. This is the * durable per-turn recovery ledger: unlike the in-marker `continuationCount` diff --git a/packages/core/src/cli/design-connect-bridge-filters.spec.ts b/packages/core/src/cli/design-connect-bridge-filters.spec.ts index 7513df4891..1334ec9bff 100644 --- a/packages/core/src/cli/design-connect-bridge-filters.spec.ts +++ b/packages/core/src/cli/design-connect-bridge-filters.spec.ts @@ -484,6 +484,43 @@ describe("design connect bridge version conflict handling", () => { ); expect(result.status).toBe(200); expect(typeof result.body["versionHash"]).toBe("string"); + expect(result.body["versionHash"]).toMatch(/^[a-f0-9]{64}$/); + } finally { + await new Promise((resolve) => + bridge.server.close(() => resolve()), + ); + } + }); + + it("uses content hashes when size and mtime are unchanged", async () => { + const root = tmpDir(); + const file = path.join(root, "same-size.tsx"); + const fixedTime = new Date("2025-01-01T00:00:00.000Z"); + fs.writeFileSync(file, "AAAA"); + fs.utimesSync(file, fixedTime, fixedTime); + const port = await freePort(); + const manifest = await prepareDesignConnectManifest({ + root, + url: "http://localhost:5173", + port, + }); + const bridge = await startDesignConnectBridge(manifest); + const auth = { "x-bridge-token": bridge.bridgeToken }; + try { + const base = `http://127.0.0.1:${port}`; + const before = await postJson( + `${base}/read-file`, + { relPath: "same-size.tsx" }, + auth, + ); + fs.writeFileSync(file, "BBBB"); + fs.utimesSync(file, fixedTime, fixedTime); + const after = await postJson( + `${base}/read-file`, + { relPath: "same-size.tsx" }, + auth, + ); + expect(before.body["versionHash"]).not.toBe(after.body["versionHash"]); } finally { await new Promise((resolve) => bridge.server.close(() => resolve()), @@ -517,6 +554,88 @@ describe("design connect bridge version conflict handling", () => { } }); + it("supports an exact-hash contract without changing legacy optional writes", async () => { + const root = tmpDir(); + fs.writeFileSync(path.join(root, "component.tsx"), "export const v = 0;\n"); + const port = await freePort(); + const manifest = await prepareDesignConnectManifest({ + root, + url: "http://localhost:5173", + port, + }); + const bridge = await startDesignConnectBridge(manifest); + const auth = { "x-bridge-token": bridge.bridgeToken }; + try { + const base = `http://127.0.0.1:${port}`; + const guarded = await postJson( + `${base}/write-file`, + { + relPath: "component.tsx", + content: "export const v = 1;\n", + requireExpectedVersionHash: true, + }, + auth, + ); + expect(guarded.status).toBe(428); + expect(guarded.body["error"]).toBe("expectedVersionHash is required"); + + const legacy = await postJson( + `${base}/write-file`, + { relPath: "component.tsx", content: "export const v = 2;\n" }, + auth, + ); + expect(legacy.status).toBe(200); + } finally { + await new Promise((resolve) => + bridge.server.close(() => resolve()), + ); + } + }); + + it("treats deletion as a conflict under the exact-hash contract", async () => { + const root = tmpDir(); + const file = path.join(root, "component.tsx"); + fs.writeFileSync(file, "export const v = 0;\n"); + const port = await freePort(); + const manifest = await prepareDesignConnectManifest({ + root, + url: "http://localhost:5173", + port, + }); + const bridge = await startDesignConnectBridge(manifest); + const auth = { "x-bridge-token": bridge.bridgeToken }; + try { + const base = `http://127.0.0.1:${port}`; + const read = await postJson( + `${base}/read-file`, + { relPath: "component.tsx" }, + auth, + ); + fs.rmSync(file); + const result = await postJson( + `${base}/write-file`, + { + relPath: "component.tsx", + content: "export const v = 1;\n", + expectedVersionHash: read.body["versionHash"], + requireExpectedVersionHash: true, + }, + auth, + ); + expect(result.status).toBe(409); + expect(result.body).toMatchObject({ + ok: false, + error: "version conflict", + }); + expect(result.body).not.toHaveProperty("content"); + expect(fs.existsSync(file)).toBe(false); + } finally { + await new Promise((resolve) => + bridge.server.close(() => resolve()), + ); + } + }); + it("write-file returns 409 when expectedVersionHash is stale", async () => { const root = tmpDir(); fs.writeFileSync(path.join(root, "index.html"), "

hi

"); @@ -605,6 +724,138 @@ describe("design connect bridge version conflict handling", () => { } }); + it("serializes concurrent compare-and-swap writes to one canonical path", async () => { + const root = tmpDir(); + fs.mkdirSync(path.join(root, "real")); + fs.writeFileSync( + path.join(root, "real/component.tsx"), + "export const v = 0;\n", + ); + fs.symlinkSync(path.join(root, "real"), path.join(root, "alias"), "dir"); + const port = await freePort(); + const manifest = await prepareDesignConnectManifest({ + root, + url: "http://localhost:5173", + port, + }); + const bridge = await startDesignConnectBridge(manifest); + const auth = { "x-bridge-token": bridge.bridgeToken }; + try { + const base = `http://127.0.0.1:${port}`; + const read = await postJson( + `${base}/read-file`, + { relPath: "real/component.tsx" }, + auth, + ); + const expectedVersionHash = read.body["versionHash"] as string; + const writes = await Promise.all( + [ + { value: 1, relPath: "real/component.tsx" }, + { value: 2, relPath: "alias/component.tsx" }, + ].map(({ value, relPath }) => + postJson( + `${base}/write-file`, + { + relPath, + content: `export const v = ${value};\n`, + expectedVersionHash, + }, + auth, + ), + ), + ); + expect(writes.map((result) => result.status).sort()).toEqual([200, 409]); + const winner = writes.find((result) => result.status === 200)!; + const finalRead = await postJson( + `${base}/read-file`, + { relPath: "real/component.tsx" }, + auth, + ); + expect(finalRead.body["versionHash"]).toBe(winner.body["versionHash"]); + expect( + fs + .readdirSync(path.join(root, "real")) + .filter((name) => name.includes(".agent-native-")), + ).toEqual([]); + } finally { + await new Promise((resolve) => + bridge.server.close(() => resolve()), + ); + } + }); + + it("rejects a leaf symlink swapped in after read without touching its target", async () => { + const root = tmpDir(); + const outside = tmpDir(); + const relPath = "component.tsx"; + const localPath = path.join(root, relPath); + const outsidePath = path.join(outside, "outside.tsx"); + fs.writeFileSync(localPath, "export const local = true;\n"); + fs.writeFileSync(outsidePath, "export const secret = true;\n"); + const port = await freePort(); + const manifest = await prepareDesignConnectManifest({ + root, + url: "http://localhost:5173", + port, + }); + const bridge = await startDesignConnectBridge(manifest); + const auth = { "x-bridge-token": bridge.bridgeToken }; + try { + const base = `http://127.0.0.1:${port}`; + const read = await postJson(`${base}/read-file`, { relPath }, auth); + fs.rmSync(localPath); + fs.symlinkSync(outsidePath, localPath); + const result = await postJson( + `${base}/write-file`, + { + relPath, + content: "export const overwritten = true;\n", + expectedVersionHash: read.body["versionHash"], + }, + auth, + ); + expect(result.status).not.toBe(200); + expect(fs.readFileSync(outsidePath, "utf8")).toBe( + "export const secret = true;\n", + ); + } finally { + await new Promise((resolve) => + bridge.server.close(() => resolve()), + ); + } + }); + + it("atomically creates nested files and removes temp siblings", async () => { + const root = tmpDir(); + const port = await freePort(); + const manifest = await prepareDesignConnectManifest({ + root, + url: "http://localhost:5173", + port, + }); + const bridge = await startDesignConnectBridge(manifest); + try { + const result = await postJson( + `http://127.0.0.1:${port}/write-file`, + { relPath: "src/new.tsx", content: "export default
;\n" }, + { "x-bridge-token": bridge.bridgeToken }, + ); + expect(result.status).toBe(200); + expect(fs.readFileSync(path.join(root, "src/new.tsx"), "utf8")).toBe( + "export default
;\n", + ); + expect( + fs + .readdirSync(path.join(root, "src")) + .filter((name) => name.includes(".agent-native-")), + ).toEqual([]); + } finally { + await new Promise((resolve) => + bridge.server.close(() => resolve()), + ); + } + }); + it("apply-edit returns 409 when expectedVersionHash is stale", async () => { const root = tmpDir(); fs.writeFileSync(path.join(root, "style.css"), "body { color: red; }"); diff --git a/packages/core/src/cli/design-connect.spec.ts b/packages/core/src/cli/design-connect.spec.ts index e0de73f5f6..e5f06509ca 100644 --- a/packages/core/src/cli/design-connect.spec.ts +++ b/packages/core/src/cli/design-connect.spec.ts @@ -564,6 +564,106 @@ describe("design connect bridge endpoints", () => { } }); + it("signals an unregistered bridgeKey with a machine-readable code and the process's bridgeInstanceId, so a client can tell a restarted bridge apart from a real bug", async () => { + const root = tmpDir(); + const devPort = await freePort(); + const devServer = http.createServer((_req, res) => { + res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); + res.end("
Screen
"); + }); + await new Promise((resolve, reject) => { + devServer.once("error", reject); + devServer.listen(devPort, "127.0.0.1", () => { + devServer.off("error", reject); + resolve(); + }); + }); + const port = await freePort(); + const manifest = await prepareDesignConnectManifest({ + root, + url: `http://127.0.0.1:${devPort}`, + port, + }); + const bridge = await startDesignConnectBridge(manifest); + try { + const base = `http://127.0.0.1:${port}`; + + // A bridgeKey that was never registered against THIS bridge process — + // e.g. the client remembers registering it before the bridge process + // restarted (crash, machine sleep/wake, manual restart), which silently + // empties the in-memory liveEditBridgeScripts map. + const unregistered = await getJson( + `${base}/live-edit?path=/a&bridgeKey=never-registered&previewToken=${bridge.previewToken}`, + ); + expect(unregistered.status).toBe(409); + expect(unregistered.body.code).toBe("unknown-bridge-key"); + expect(unregistered.body.bridgeKey).toBe("never-registered"); + expect(unregistered.body.bridgeInstanceId).toBe(bridge.bridgeInstanceId); + + // The registration endpoint echoes the same instance id, so a client + // that registers, then later hits the 409 above with a DIFFERENT + // bridgeInstanceId than what it got back here, knows the process + // restarted (safe to silently re-register) rather than distrust its + // own bridgeKey. + const registration = await postJson( + `${base}/live-edit-bridge`, + { + script: + '', + bridgeKey: "screen-a", + }, + { "x-design-preview-token": bridge.previewToken }, + ); + expect(registration.body.bridgeInstanceId).toBe(bridge.bridgeInstanceId); + + // /health exposes the same id for a lightweight out-of-band check. + const health = await getJson(`${base}/health`); + expect(health.body.bridgeInstanceId).toBe(bridge.bridgeInstanceId); + } finally { + await new Promise((resolve) => + bridge.server.close(() => resolve()), + ); + await new Promise((resolve) => devServer.close(() => resolve())); + } + }); + + it("mints a fresh bridgeInstanceId per bridge process, so a restart is distinguishable", async () => { + const root = tmpDir(); + const devPort = await freePort(); + const devServer = http.createServer((_req, res) => { + res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); + res.end("
Screen
"); + }); + await new Promise((resolve, reject) => { + devServer.once("error", reject); + devServer.listen(devPort, "127.0.0.1", () => { + devServer.off("error", reject); + resolve(); + }); + }); + const port = await freePort(); + const manifest = await prepareDesignConnectManifest({ + root, + url: `http://127.0.0.1:${devPort}`, + port, + }); + const firstBridge = await startDesignConnectBridge(manifest); + await new Promise((resolve) => + firstBridge.server.close(() => resolve()), + ); + const secondBridge = await startDesignConnectBridge(manifest); + try { + expect(secondBridge.bridgeInstanceId).not.toBe( + firstBridge.bridgeInstanceId, + ); + } finally { + await new Promise((resolve) => + secondBridge.server.close(() => resolve()), + ); + await new Promise((resolve) => devServer.close(() => resolve())); + } + }); + it("returns read-only HTML snapshots from the connected dev server", async () => { const root = tmpDir(); const devPort = await freePort(); @@ -608,6 +708,16 @@ describe("design connect bridge endpoints", () => { const devPort = await freePort(); const devServer = http.createServer((req, res) => { if (req.url?.startsWith("/src/main.ts")) { + if (req.headers.cookie || req.headers.authorization) { + res.writeHead(400, { "content-type": "text/plain" }); + res.end("sensitive request headers leaked"); + return; + } + if (req.headers["sec-fetch-dest"] !== "script") { + res.writeHead(404, { "content-type": "text/plain" }); + res.end("missing script destination"); + return; + } res.writeHead(200, { "content-type": "application/javascript; charset=utf-8", "cache-control": "no-store", @@ -684,11 +794,17 @@ describe("design connect bridge endpoints", () => { const module = await getText(`${base}/src/main.ts`, { "sec-fetch-site": "same-origin", + "sec-fetch-dest": "script", + cookie: "pilot_session=must-not-forward", + authorization: "Bearer example-must-not-forward", }); expect(module.status).toBe(200); expect(module.headers["content-type"]).toContain( "application/javascript", ); + expect(module.headers["content-length"]).toBe( + String(Buffer.byteLength(module.body)), + ); expect(module.body).toContain("CSR booted"); const panOnlyRegistration = await postJson( diff --git a/packages/core/src/cli/design-connect.ts b/packages/core/src/cli/design-connect.ts index 31bfe9b716..bc527165e4 100644 --- a/packages/core/src/cli/design-connect.ts +++ b/packages/core/src/cli/design-connect.ts @@ -2,6 +2,7 @@ import { spawn } from "node:child_process"; import crypto from "node:crypto"; import fsSync from "node:fs"; import fs from "node:fs/promises"; +import type { FileHandle } from "node:fs/promises"; import http, { type IncomingMessage, type Server, @@ -123,6 +124,12 @@ export interface DesignConnectBridge { * bridge-registration endpoints. Safe to hand to the Design browser, but * never accepted by filesystem endpoints. */ previewToken: string; + /** Random id minted fresh each time this bridge process boots. Also + * returned by `/health`, `/live-edit-bridge`, and the "unknown bridge key" + * 409 from `/live-edit` — a client can compare it across those responses + * to tell a restarted bridge process apart from a genuine registration + * bug. See the `bridgeInstanceId` doc comment in startDesignConnectBridge. */ + bridgeInstanceId: string; } export interface DesignConnectBridgeOptions { @@ -679,11 +686,11 @@ export async function prepareDesignConnectManifest( reason: operation === "resolveNodeToFile" ? // resolveNodeToFile maps a runtime DOM node id (from the editor's - // 'select' payload) to { file, line, component } provenance. The - // bridge endpoint exists; per-element provenance data must be - // emitted by the connected app at build time — see the provenance - // note in the help text below. - "Requires the connected app to emit data-source-file / data-source-line / data-component-name attributes (e.g. via @vitejs/plugin-react jsxDEV or a Babel source plugin)." + // 'select' payload) to { file, line, component } provenance. + // React development builds expose jsxDEV call sites through the + // Fiber debug stack; other runtimes/builds can emit explicit DOM + // provenance attributes — see the help text below. + "React development builds resolve jsxDEV call sites automatically; other runtimes can emit data-source-file / data-source-line / data-component-name attributes." : undefined, })), }; @@ -786,9 +793,11 @@ function sendBytes( statusCode: number, body: Buffer, headers: Headers, + contentLength = body.length, ) { const responseHeaders: Record = { ...bridgeCorsHeaders(res), + "content-length": String(contentLength), }; for (const name of [ "content-type", @@ -833,6 +842,25 @@ function readHeader(req: IncomingMessage, name: string): string { return typeof value === "string" ? value : ""; } +/** + * Preserve only the browser request metadata a local dev server needs to + * classify Vite source-module and stylesheet requests. Agent Native's dev + * gateway intentionally varies source-file handling by `Sec-Fetch-Dest`; if + * the bridge drops it, React Router module URLs such as `/app/root.tsx` fall + * through to Nitro and 404. Keep this allowlist narrow: cookies, authorization, + * bridge tokens, origins, and referrers must never be forwarded upstream. + */ +function previewProxyRequestHeaders( + req: IncomingMessage, +): Record { + const headers: Record = {}; + for (const name of ["accept", "sec-fetch-dest"] as const) { + const value = readHeader(req, name); + if (value) headers[name] = value; + } + return headers; +} + function resolvePreviewSnapshotUrl( devServerUrl: string, rawUrl: string | null, @@ -917,6 +945,7 @@ async function fetchPreviewSnapshot( async function fetchPreviewProxyResource( devServerUrl: string, targetUrl: string, + requestHeaders: Record = {}, redirects = 0, ): Promise<{ url: string; @@ -934,6 +963,7 @@ async function fetchPreviewProxyResource( method: "GET", redirect: "manual", signal: controller.signal, + headers: requestHeaders, }); const location = response.headers.get("location"); if (location && response.status >= 300 && response.status < 400) { @@ -945,6 +975,7 @@ async function fetchPreviewProxyResource( return fetchPreviewProxyResource( devServerUrl, redirected.toString(), + requestHeaders, redirects + 1, ); } @@ -1101,6 +1132,236 @@ async function assertPathInside( } } +interface SafeBridgeFileTarget { + absolutePath: string; + canonicalPath: string; +} + +/** + * Resolve a bridge file to a stable lock key without following a leaf + * symlink. Missing parent directories are represented beneath their nearest + * existing real ancestor, so aliases through in-root directory symlinks share + * one mutex while first-time file creation remains supported. + */ +async function resolveSafeBridgeFileTarget( + rootPath: string, + targetPath: string, +): Promise { + await assertPathInside(rootPath, targetPath); + const resolvedRoot = await fs.realpath(rootPath); + const absolutePath = path.resolve(rootPath, targetPath); + let existingAncestor = path.dirname(absolutePath); + let resolvedAncestor: string | null = null; + for (let depth = 0; depth < 32; depth += 1) { + try { + resolvedAncestor = await fs.realpath(existingAncestor); + break; + } catch { + const parent = path.dirname(existingAncestor); + if (parent === existingAncestor) break; + existingAncestor = parent; + } + } + if (!resolvedAncestor) { + throw new Error(`Cannot resolve parent directory: ${absolutePath}`); + } + // Existing regular files get their own realpath as the lock key. Besides + // resolving in-root directory aliases, this canonicalizes case on the + // default macOS filesystem so `Button.tsx` and `button.tsx` cannot acquire + // separate mutexes for the same inode. assertPathInside already rejected a + // symlink leaf before this point. + const canonicalPath = + (await fs.realpath(absolutePath).catch(() => null)) ?? + path.resolve( + resolvedAncestor, + path.relative(existingAncestor, absolutePath), + ); + if ( + canonicalPath !== resolvedRoot && + !canonicalPath.startsWith(resolvedRoot + path.sep) + ) { + throw new Error("Path traversal detected: resolved target is outside root"); + } + return { absolutePath, canonicalPath }; +} + +const bridgeWriteLocks = new Map>(); + +/** Serialize read-check-write sequences for one canonical local file. */ +async function withBridgeWriteLock( + canonicalPath: string, + work: () => Promise, +): Promise { + const previous = bridgeWriteLocks.get(canonicalPath) ?? Promise.resolve(); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const queued = previous.catch(() => undefined).then(() => gate); + bridgeWriteLocks.set(canonicalPath, queued); + await previous.catch(() => undefined); + try { + return await work(); + } finally { + release(); + if (bridgeWriteLocks.get(canonicalPath) === queued) { + bridgeWriteLocks.delete(canonicalPath); + } + } +} + +interface BridgeFileSnapshot { + content: string; + versionHash: string; + mode: number; +} + +function contentVersionHash(content: string | Buffer): string { + return crypto.createHash("sha256").update(content).digest("hex"); +} + +/** Read an existing regular file without ever following a leaf symlink. */ +async function readBridgeFileSnapshot( + absolutePath: string, +): Promise { + const noFollow = fsSync.constants.O_NOFOLLOW ?? 0; + let handle: FileHandle | null = null; + try { + handle = await fs.open(absolutePath, fsSync.constants.O_RDONLY | noFollow); + const stat = await handle.stat(); + if (!stat.isFile()) { + throw new Error(`Bridge target is not a regular file: ${absolutePath}`); + } + const bytes = await handle.readFile(); + return { + content: bytes.toString("utf8"), + versionHash: contentVersionHash(bytes), + mode: stat.mode & 0o777, + }; + } catch (error: unknown) { + const code = + error instanceof Error && "code" in error + ? (error as NodeJS.ErrnoException).code + : undefined; + if (code === "ENOENT") return null; + throw error; + } finally { + await handle?.close().catch(() => undefined); + } +} + +class BridgeVersionConflictError extends Error { + constructor(readonly currentVersionHash?: string) { + super("version conflict"); + } +} + +class BridgePreconditionRequiredError extends Error { + constructor() { + super("expectedVersionHash is required"); + } +} + +function assertExpectedBridgeVersion( + expectedVersionHash: string | undefined, + currentVersionHash: string | undefined, + requireExpectedVersionHash = false, +): void { + // Preserve compatibility: callers that omit a hash retain the existing + // last-write-wins behavior, including creation. Compiled-source callers opt + // into exact compare-and-swap by sending the hash returned by read-file. + if (requireExpectedVersionHash && expectedVersionHash === undefined) { + throw new BridgePreconditionRequiredError(); + } + if ( + expectedVersionHash !== undefined && + (currentVersionHash !== undefined + ? currentVersionHash !== expectedVersionHash + : requireExpectedVersionHash) + ) { + throw new BridgeVersionConflictError(currentVersionHash); + } +} + +/** + * Replace a file durably without exposing a partial write: create an + * O_EXCL/O_NOFOLLOW temp sibling, fsync it, revalidate confinement and the + * expected content hash, rename atomically, then fsync the parent directory. + */ +async function atomicWriteBridgeFile(args: { + rootPath: string; + relPath: string; + lockedCanonicalPath: string; + content: string; + expectedVersionHash?: string; + requireExpectedVersionHash?: boolean; + originalMode?: number; +}): Promise { + const parent = path.dirname(path.resolve(args.rootPath, args.relPath)); + await fs.mkdir(parent, { recursive: true }); + const revalidated = await resolveSafeBridgeFileTarget( + args.rootPath, + args.relPath, + ); + if (revalidated.canonicalPath !== args.lockedCanonicalPath) { + throw new Error("Bridge target changed while waiting for the write lock"); + } + + const basename = path.basename(revalidated.absolutePath); + const tempPath = path.join( + parent, + `.${basename}.agent-native-${process.pid}-${crypto.randomBytes(8).toString("hex")}.tmp`, + ); + const noFollow = fsSync.constants.O_NOFOLLOW ?? 0; + let tempHandle: FileHandle | null = null; + try { + tempHandle = await fs.open( + tempPath, + fsSync.constants.O_CREAT | + fsSync.constants.O_EXCL | + fsSync.constants.O_WRONLY | + noFollow, + args.originalMode ?? 0o666, + ); + await tempHandle.writeFile(args.content, "utf8"); + await tempHandle.sync(); + await tempHandle.close(); + tempHandle = null; + + // Re-check after the potentially slow temp write/fsync. This catches a + // parent/leaf symlink swap and an external content edit before rename. + const beforeRenameTarget = await resolveSafeBridgeFileTarget( + args.rootPath, + args.relPath, + ); + if (beforeRenameTarget.canonicalPath !== args.lockedCanonicalPath) { + throw new Error("Bridge target changed during atomic write"); + } + const current = await readBridgeFileSnapshot( + beforeRenameTarget.absolutePath, + ); + assertExpectedBridgeVersion( + args.expectedVersionHash, + current?.versionHash, + args.requireExpectedVersionHash, + ); + + await fs.rename(tempPath, beforeRenameTarget.absolutePath); + const directoryHandle = await fs.open(parent, "r").catch(() => null); + if (directoryHandle) { + try { + await directoryHandle.sync(); + } finally { + await directoryHandle.close(); + } + } + return contentVersionHash(args.content); + } finally { + await tempHandle?.close().catch(() => undefined); + await fs.rm(tempPath, { force: true }).catch(() => undefined); + } +} + /** Allowed file extensions for write/apply-edit operations. */ const ALLOWED_WRITE_EXTENSIONS = new Set([ ".html", @@ -1166,21 +1427,6 @@ function assertNotBlockedSecretPath(relPath: string): void { } } -/** - * Compute a cheap, stable version identifier for a file from its stat: mtime - * plus size. Not a content hash — it is only meant to detect "did this file - * change on disk since the caller last read it", the same tradeoff the inline - * (SQL-backed) workspace provider's versionHash already makes. Returns - * undefined when the file does not exist (new-file case). - */ -async function computeVersionHash( - absolutePath: string, -): Promise { - const stat = await fs.stat(absolutePath).catch(() => null); - if (!stat) return undefined; - return `${stat.mtimeMs}-${stat.size}`; -} - function countOccurrences(haystack: string, needle: string): number { if (!needle) return 0; let count = 0; @@ -1516,6 +1762,18 @@ export async function startDesignConnectBridge( // other and boot a frame with another frame's identity. Keep keyed scripts // for modern clients while retaining the unkeyed slot for older clients. const liveEditBridgeScripts = new Map(); + // Identifies THIS bridge process's in-memory registry, minted fresh every + // time the bridge boots. `liveEditBridgeScripts` above only lives in + // process memory, so a bridge restart (crash, machine sleep/wake, manual + // restart) silently empties it: any screen that registered a bridgeKey + // before the restart now gets a 409 "unknown bridge key" from `/live-edit` + // even though nothing about that screen actually changed. Echoing this id + // on both the registration response and the 409 lets a client tell the two + // cases apart — "this exact process never saw my key" (stale/typo, id + // matches what it already has cached) vs. "the process restarted since I + // registered" (id changed, safe to transparently re-POST `/live-edit-bridge` + // and retry) — instead of guessing from the error text or retrying forever. + const bridgeInstanceId = crypto.randomBytes(16).toString("hex"); const server = http.createServer( (req: IncomingMessage, res: ServerResponse) => { @@ -1574,6 +1832,7 @@ export async function startDesignConnectBridge( ok: true, source: manifest.source, appFingerprint: designConnectAppFingerprint(manifest), + bridgeInstanceId, }); return; } @@ -1628,6 +1887,7 @@ export async function startDesignConnectBridge( } sendJson(res, 200, { ok: true, + bridgeInstanceId, ...(bridgeKey ? { bridgeKey } : {}), }); } catch (err: unknown) { @@ -1668,8 +1928,17 @@ export async function startDesignConnectBridge( requestedBridgeKey && !editorBridgeScript ) { + // Machine-readable `code` + echoed `bridgeKey`/`bridgeInstanceId` + // let a client distinguish "this bridge process restarted since + // I last registered — safe to silently re-POST + // /live-edit-bridge and retry" from a genuine caller bug, + // instead of string-matching `error` (see bridgeInstanceId's + // doc comment above for the full rationale). sendJson(res, 409, { ok: false, + code: "unknown-bridge-key", + bridgeKey: requestedBridgeKey, + bridgeInstanceId, error: "The requested live-edit bridge script is not registered. Reload the Design frame to register it again.", }); @@ -1794,174 +2063,185 @@ export async function startDesignConnectBridge( return; } - await assertPathInside(manifest.rootPath, relPath); assertNotBlockedSecretPath(relPath); - const absolutePath = path.resolve(manifest.rootPath, relPath); + const initialTarget = await resolveSafeBridgeFileTarget( + manifest.rootPath, + relPath, + ); if (pathname === "/read-file") { // Read-file: no extension restriction (agents need to read any // non-secret file), but the secret-path blocklist above still // applies to .env*, *.pem, *.key, id_rsa*, and anything under .git/. - let content: string; - try { - content = await fs.readFile(absolutePath, "utf8"); - } catch (err: unknown) { - const code = - err instanceof Error && - "code" in err && - (err as NodeJS.ErrnoException).code; - if (code === "ENOENT") { - sendJson(res, 404, { ok: false, error: "file not found" }); - } else { - sendJson(res, 500, { - ok: false, - error: `read failed: ${err instanceof Error ? err.message : String(err)}`, - }); - } + const snapshot = await readBridgeFileSnapshot( + initialTarget.absolutePath, + ); + if (!snapshot) { + sendJson(res, 404, { ok: false, error: "file not found" }); return; } - const versionHash = await computeVersionHash(absolutePath); - sendJson(res, 200, { ok: true, content, versionHash }); + sendJson(res, 200, { + ok: true, + content: snapshot.content, + versionHash: snapshot.versionHash, + }); return; } // write-file and apply-edit only allow known text/code extensions. assertAllowedExtension(relPath); - // Optional optimistic-concurrency check: when the caller supplies - // expectedVersionHash, compare it against the file's CURRENT hash - // before writing. A missing file is treated as no-conflict (new - // file case) so first-time writes always succeed. const expectedVersionHash = typeof body["expectedVersionHash"] === "string" ? body["expectedVersionHash"] : undefined; - if (expectedVersionHash !== undefined) { - const currentVersionHash = await computeVersionHash(absolutePath); - if ( - currentVersionHash !== undefined && - currentVersionHash !== expectedVersionHash - ) { - sendJson(res, 409, { - ok: false, - error: "version conflict", - currentVersionHash, + const requireExpectedVersionHash = + body["requireExpectedVersionHash"] === true; + await withBridgeWriteLock(initialTarget.canonicalPath, async () => { + const lockedTarget = await resolveSafeBridgeFileTarget( + manifest.rootPath, + relPath, + ); + if (lockedTarget.canonicalPath !== initialTarget.canonicalPath) { + throw new Error( + "Bridge target changed while waiting for the write lock", + ); + } + const existing = await readBridgeFileSnapshot( + lockedTarget.absolutePath, + ); + assertExpectedBridgeVersion( + expectedVersionHash, + existing?.versionHash, + requireExpectedVersionHash, + ); + + if (pathname === "/write-file") { + const content = + typeof body["content"] === "string" + ? body["content"] + : undefined; + if (content === undefined) { + sendJson(res, 400, { + ok: false, + error: "content is required for write-file", + }); + return; + } + const versionHash = await atomicWriteBridgeFile({ + rootPath: manifest.rootPath, + relPath, + lockedCanonicalPath: initialTarget.canonicalPath, + content, + expectedVersionHash, + requireExpectedVersionHash, + originalMode: existing?.mode, + }); + sendJson(res, 200, { ok: true, relPath, versionHash }); + return; + } + + // /apply-edit supports either full replace ({content}) or one + // exact search-and-replace. Both stay within this file's lock. + if (typeof body["content"] === "string") { + const versionHash = await atomicWriteBridgeFile({ + rootPath: manifest.rootPath, + relPath, + lockedCanonicalPath: initialTarget.canonicalPath, + content: body["content"], + expectedVersionHash, + requireExpectedVersionHash, + originalMode: existing?.mode, + }); + sendJson(res, 200, { + ok: true, + relPath, + method: "replace", + versionHash, }); return; } - } - if (pathname === "/write-file") { - const content = - typeof body["content"] === "string" - ? body["content"] + const search = + typeof body["search"] === "string" ? body["search"] : undefined; + const replace = + typeof body["replace"] === "string" + ? body["replace"] : undefined; - if (content === undefined) { + if (search === undefined || replace === undefined) { sendJson(res, 400, { ok: false, - error: "content is required for write-file", + error: + "apply-edit requires either {content} for a full replace, or {search, replace} for a patch", }); return; } - await fs.mkdir(path.dirname(absolutePath), { recursive: true }); - await fs.writeFile(absolutePath, content, "utf8"); - const versionHash = await computeVersionHash(absolutePath); - sendJson(res, 200, { ok: true, relPath, versionHash }); - return; - } - - // /apply-edit: supports either full replace ({content}) or - // search-and-replace ({search, replace}). - if (typeof body["content"] === "string") { - // Full-file replace via apply-edit — same as write-file but keeps - // the endpoint semantically separate for callers that want to - // distinguish intent. - await fs.mkdir(path.dirname(absolutePath), { recursive: true }); - await fs.writeFile( - absolutePath, - body["content"] as string, - "utf8", - ); - const versionHash = await computeVersionHash(absolutePath); - sendJson(res, 200, { - ok: true, - relPath, - method: "replace", - versionHash, - }); - return; - } - - const search = - typeof body["search"] === "string" ? body["search"] : undefined; - const replace = - typeof body["replace"] === "string" ? body["replace"] : undefined; - if (search === undefined || replace === undefined) { - sendJson(res, 400, { - ok: false, - error: - "apply-edit requires either {content} for a full replace, or {search, replace} for a patch", - }); - return; - } - - let existing: string; - try { - existing = await fs.readFile(absolutePath, "utf8"); - } catch (err: unknown) { - const code = - err instanceof Error && - "code" in err && - (err as NodeJS.ErrnoException).code; - if (code === "ENOENT") { + if (!existing) { sendJson(res, 404, { ok: false, error: "file not found — use write-file to create new files", }); - } else { - sendJson(res, 500, { + return; + } + if (search.length === 0) { + sendJson(res, 400, { ok: false, - error: `read failed: ${err instanceof Error ? err.message : String(err)}`, + error: "search string must not be empty", }); + return; } - return; - } - - if (search.length === 0) { - sendJson(res, 400, { - ok: false, - error: "search string must not be empty", + const occurrenceCount = countOccurrences( + existing.content, + search, + ); + if (occurrenceCount === 0) { + sendJson(res, 422, { + ok: false, + error: "search string not found in file", + }); + return; + } + if (occurrenceCount > 1) { + sendJson(res, 422, { + ok: false, + error: + "search string is ambiguous; it appears more than once in the file", + }); + return; + } + const updated = existing.content.replace(search, replace); + const versionHash = await atomicWriteBridgeFile({ + rootPath: manifest.rootPath, + relPath, + lockedCanonicalPath: initialTarget.canonicalPath, + content: updated, + expectedVersionHash, + requireExpectedVersionHash, + originalMode: existing.mode, }); - return; - } - - const occurrenceCount = countOccurrences(existing, search); - if (occurrenceCount === 0) { - sendJson(res, 422, { + sendJson(res, 200, { + ok: true, + relPath, + method: "patch", + versionHash, + }); + }); + } catch (err: unknown) { + if (err instanceof BridgePreconditionRequiredError) { + sendJson(res, 428, { ok: false, - error: "search string not found in file", + error: "expectedVersionHash is required", }); return; } - if (occurrenceCount > 1) { - sendJson(res, 422, { + if (err instanceof BridgeVersionConflictError) { + sendJson(res, 409, { ok: false, - error: - "search string is ambiguous; it appears more than once in the file", + error: "version conflict", + currentVersionHash: err.currentVersionHash, }); return; } - - const updated = existing.replace(search, replace); - await fs.writeFile(absolutePath, updated, "utf8"); - const versionHash = await computeVersionHash(absolutePath); - sendJson(res, 200, { - ok: true, - relPath, - method: "patch", - versionHash, - }); - } catch (err: unknown) { sendJson(res, 500, { ok: false, error: err instanceof Error ? err.message : String(err), @@ -1990,12 +2270,14 @@ export async function startDesignConnectBridge( const proxied = await fetchPreviewProxyResource( manifest.devServerUrl, targetUrl, + previewProxyRequestHeaders(req), ); sendBytes( res, proxied.status >= 400 ? proxied.status : 200, req.method === "HEAD" ? Buffer.alloc(0) : proxied.body, proxied.headers, + proxied.body.length, ); } catch (err: unknown) { sendJson(res, 400, { @@ -2019,7 +2301,7 @@ export async function startDesignConnectBridge( }); }); - return { server, manifest, bridgeToken, previewToken }; + return { server, manifest, bridgeToken, previewToken, bridgeInstanceId }; } /** @@ -2163,12 +2445,11 @@ Options: Element provenance (resolveNodeToFile): The design editor can map a selected DOM element back to its source file, - line, and React component name when the connected app emits provenance - attributes at build time. Add one of the following to your app's build: + line, and component name using one of these provenance sources: - • @vitejs/plugin-react with jsxDEV enabled (development mode default): - Sets data-source-file and data-source-line on each JSX element - automatically when using the Babel transform. + • React development builds with jsxDEV enabled (the default): + Design reads the selected element's development-only Fiber debug stack. + React does not emit data-source-* DOM attributes automatically. • A Babel source plugin (e.g. babel-plugin-react-source or a custom plugin): Emits data-source-file="src/Button.tsx" data-source-line="12" @@ -2177,7 +2458,8 @@ Element provenance (resolveNodeToFile): • data-loc="src/Button.tsx:12:4" shorthand attribute (Babel source convention): The bridge parses this as { sourceFile, line, column } automatically. - Without these attributes the editor still works; provenance is simply absent. + In production React builds and other runtimes without explicit attributes, + the editor still works but exact element provenance may be absent. Cross-origin localhost iframes cannot be read regardless of attributes (CSP).`); } diff --git a/packages/core/src/cli/recap.io.spec.ts b/packages/core/src/cli/recap.io.spec.ts index 309bfc82a9..f733bc168b 100644 --- a/packages/core/src/cli/recap.io.spec.ts +++ b/packages/core/src/cli/recap.io.spec.ts @@ -543,6 +543,12 @@ describe("runShot — playwright not available", () => { const fakePage = { goto: vi.fn(async (nextUrl: string) => { gotoUrls.push(nextUrl); + return { + ok: () => true, + status: () => 200, + url: () => nextUrl, + headers: () => ({ "content-type": "text/html; charset=utf-8" }), + }; }), waitForLoadState: vi.fn(async () => {}), waitForSelector: vi.fn(async () => {}), diff --git a/packages/core/src/cli/recap.spec.ts b/packages/core/src/cli/recap.spec.ts index 5de7bafa51..28ed7d459e 100644 --- a/packages/core/src/cli/recap.spec.ts +++ b/packages/core/src/cli/recap.spec.ts @@ -1277,7 +1277,12 @@ describe("recap screenshot browser launch", () => { describe("recap screenshot capture", () => { function createShotPlaywright(screenshotBytes: Buffer[]) { const page = { - goto: vi.fn(async () => undefined), + goto: vi.fn(async () => ({ + ok: () => true, + status: () => 200, + url: () => "https://plan.agent-native.com/recaps/plan-abc123", + headers: () => ({ "content-type": "text/html; charset=utf-8" }), + })), waitForLoadState: vi.fn(async () => undefined), waitForSelector: vi.fn(async () => undefined), waitForTimeout: vi.fn(async () => undefined), @@ -1386,6 +1391,47 @@ describe("recap screenshot capture", () => { } }); + it("does not upload a screenshot when the recap page returns an HTTP error", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "an-recap-shot-")); + const out = path.join(dir, "recap.png"); + const { page, importPlaywright } = createShotPlaywright([ + Buffer.from("png"), + ]); + page.goto.mockResolvedValueOnce({ + ok: () => false, + status: () => 500, + url: () => "https://plan.agent-native.com/recaps/recap-broken", + headers: () => ({ "content-type": "text/plain" }), + }); + const writes: string[] = []; + const stdout = vi + .spyOn(process.stdout, "write") + .mockImplementation((chunk: string | Uint8Array) => { + writes.push(String(chunk)); + return true; + }); + + try { + await runShot( + { + url: "https://plan.agent-native.com/recaps/recap-broken", + out, + }, + importPlaywright, + ); + + expect(page.screenshot).not.toHaveBeenCalled(); + expect(JSON.parse(writes.join("").trim())).toMatchObject({ + ok: false, + reason: + "recap page returned HTTP 500 while loading https://plan.agent-native.com/recaps/recap-broken", + }); + } finally { + stdout.mockRestore(); + fs.rmSync(dir, { force: true, recursive: true }); + } + }); + it("retries oversized screenshots at CSS-pixel scale", async () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "an-recap-shot-")); const out = path.join(dir, "recap.png"); diff --git a/packages/core/src/cli/recap.ts b/packages/core/src/cli/recap.ts index 8c8d572870..f6b61cd553 100644 --- a/packages/core/src/cli/recap.ts +++ b/packages/core/src/cli/recap.ts @@ -2987,10 +2987,24 @@ export async function runShot( }); } const page = await context.newPage(); - await page.goto(captureUrl, { + const navigationResponse = await page.goto(captureUrl, { waitUntil: "domcontentloaded", timeout: 45_000, }); + if (!navigationResponse) { + throw new Error("recap page did not return an HTTP response"); + } + if (!navigationResponse.ok()) { + throw new Error( + `recap page returned HTTP ${navigationResponse.status()} while loading ${navigationResponse.url()}`, + ); + } + const contentType = navigationResponse.headers()["content-type"] ?? ""; + if (contentType && !/\btext\/html\b/i.test(contentType)) { + throw new Error( + `recap page returned unexpected content type ${contentType} while loading ${navigationResponse.url()}`, + ); + } await page.waitForLoadState("load", { timeout: 15_000 }).catch(() => { // The selectors below are the real readiness signal for screenshots. // Some recap pages keep long-lived/background requests open. diff --git a/packages/core/src/cli/skills.ts b/packages/core/src/cli/skills.ts index 6f60f00650..2ebf02de01 100644 --- a/packages/core/src/cli/skills.ts +++ b/packages/core/src/cli/skills.ts @@ -631,6 +631,20 @@ replace them with copied \`srcdoc\` HTML unless the user explicitly asks for a frozen snapshot. To change a state, rerun \`add-localhost-screens\` with the new path/query or duplicate the screen and update the copy's URL metadata. +## React Source Writeback + +- Use compiler/debug provenance (project-relative file, line, column, + component, and runtime multiplicity) to locate React/TSX source. Treat it as + evidence, not as permission for a generic AST structural transform. +- Reparenting, grouping/ungrouping, wrappers, dynamic expressions, repeated + \`.map()\` instances, shared components, and cross-file changes go through the + coding agent with exact subject/target anchors and their runtime + relationship. +- Before each write, read the file and pass its exact \`versionHash\` to + \`write-local-file\` with \`requireExpectedVersionHash: true\`; on conflict, + re-read and re-plan. Keep the optimistic preview until HMR/runtime confirms + the result. Human write consent remains mandatory and agents cannot grant it. + ## Verification - \`list-localhost-connections\` returns the expected connection and routes. diff --git a/packages/core/src/client/AssistantChat.tsx b/packages/core/src/client/AssistantChat.tsx index a5f518b119..9dff67c322 100644 --- a/packages/core/src/client/AssistantChat.tsx +++ b/packages/core/src/client/AssistantChat.tsx @@ -55,9 +55,11 @@ import { appendAgentChatContextToMessage, formatAgentChatContextItemsForPrompt, getAgentChatContextState, + isAgentChatSubmitCancelled, normalizeAgentChatContextItem, publishAgentChatContextItems, refreshAgentChatContext, + reportAgentChatSubmitResult, subscribeAgentChatContext, type AgentChatContextItem, } from "./agent-chat.js"; @@ -197,6 +199,8 @@ export type AgentRecoveryAction = "continue" | "retry"; export interface AssistantChatSendOptions { trackInRunsTray?: boolean; requestMode?: AgentRequestMode; + /** Correlates with `AGENT_CHAT_SUBMIT_RESULT_EVENT` — see agent-chat.ts. */ + submitMessageId?: string; } function createUserMessageRunConfig( @@ -3651,9 +3655,12 @@ const AssistantChatInner = forwardRef< trackInRunsTray = false, preserveReconnectAutoRecoveryBudget = false, hideUserMessage = false, + submitMessageId?: string, ) => { + if (isAgentChatSubmitCancelled(submitMessageId)) return; if (agentEngineConfigured.state === "missing") { void ensureAgentEngineReadyForSubmit(); + reportAgentChatSubmitResult(submitMessageId, false, "missing-engine"); return; } if (!preserveReconnectAutoRecoveryBudget) { @@ -3687,8 +3694,10 @@ const AssistantChatInner = forwardRef< ? err.message : "Attachment could not be processed."; setComposerError(msg); + reportAgentChatSubmitResult(submitMessageId, false, "attachment-error"); return; } + if (isAgentChatSubmitCancelled(submitMessageId)) return; const imageAttachments = createAgentImageAttachments(images); const allAttachments = [ ...(queuedAttachments ?? []), @@ -3782,6 +3791,11 @@ const AssistantChatInner = forwardRef< setComposerError( `"${rejected.name}" makes the message too large to send (combined attachments must be under ${Math.round(MAX_ESTIMATED_BODY_BYTES / 1024 / 1024)} MB). Remove it or use a smaller image.`, ); + reportAgentChatSubmitResult( + submitMessageId, + false, + "attachment-too-large", + ); return; } } @@ -3789,6 +3803,7 @@ const AssistantChatInner = forwardRef< } } // ── End body-size guard ────────────────────────────────────────── + if (isAgentChatSubmitCancelled(submitMessageId)) return; // Snapshot the exec mode at enqueue time when the caller didn't // pass an explicit override. Without this, a plan-mode message that // sits in the queue runs as 'act' if the user flips the global toggle @@ -3863,9 +3878,16 @@ const AssistantChatInner = forwardRef< } as Parameters[0]); } catch (error) { setOptimisticRunning(false); + reportAgentChatSubmitResult(submitMessageId, false, "append-failed"); throw error; } } + // The turn is now either queued behind the active run or already a + // visible message — either way it has reached the chat. This is + // intentionally reported before the agent's response resolves: a + // caller like sendToAgentChatAndConfirm only needs to know the submit + // wasn't silently dropped, not whether the run itself later succeeds. + reportAgentChatSubmitResult(submitMessageId, true); if (submitted.includesContext) { updateComposerContextItems(() => []); } @@ -3927,6 +3949,9 @@ const AssistantChatInner = forwardRef< undefined, false, options?.trackInRunsTray === true, + false, + false, + options?.submitMessageId, ); }, prefillMessage(text: string) { diff --git a/packages/core/src/client/MultiTabAssistantChat.spec.tsx b/packages/core/src/client/MultiTabAssistantChat.spec.tsx index 72cd98966b..10969df3db 100644 --- a/packages/core/src/client/MultiTabAssistantChat.spec.tsx +++ b/packages/core/src/client/MultiTabAssistantChat.spec.tsx @@ -5,6 +5,7 @@ import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { + cancelAgentChatSubmit, requestAgentChatThreadOpen, requestAgentTaskOpen, sendToAgentChat, @@ -917,6 +918,7 @@ describe("MultiTabAssistantChat cold-start delivery (Mode B)", () => { expect(chatHandleMocks.sendMessage).toHaveBeenCalledWith( "Sent before mount", undefined, + { submitMessageId: expect.any(String) }, ); }); @@ -939,9 +941,33 @@ describe("MultiTabAssistantChat cold-start delivery (Mode B)", () => { expect(chatHandleMocks.sendMessage).toHaveBeenCalledWith( "Once only", undefined, + { submitMessageId: "dup-1" }, ); }); + it("drops a pending delivery after its confirmation times out", async () => { + threadMocks.activeThreadId = ""; + await act(async () => { + root.render(); + }); + + act(() => { + dispatchSubmitChat({ + message: "Never deliver late", + submitMessageId: "cancelled-submit", + }); + }); + cancelAgentChatSubmit("cancelled-submit"); + + threadMocks.activeThreadId = "thread-1"; + await act(async () => { + root.render(); + await new Promise((resolve) => setTimeout(resolve, 80)); + }); + + expect(chatHandleMocks.sendMessage).not.toHaveBeenCalled(); + }); + it("replays an open-thread request sent before the lazy panel mounted", async () => { threadMocks.threads = [ ...threadMocks.threads, diff --git a/packages/core/src/client/MultiTabAssistantChat.tsx b/packages/core/src/client/MultiTabAssistantChat.tsx index bccf0434b8..f23a842589 100644 --- a/packages/core/src/client/MultiTabAssistantChat.tsx +++ b/packages/core/src/client/MultiTabAssistantChat.tsx @@ -30,8 +30,10 @@ import { claimAgentChatSubmit, drainBufferedAgentChatOpenRequests, drainBufferedAgentChatSubmits, + isAgentChatSubmitCancelled, normalizeAgentChatContextItem, parseSubmitChatMessage, + reportAgentChatSubmitResult, type AgentChatContextItem, } from "./agent-chat.js"; import { agentNativePath, appPath } from "./api-path.js"; @@ -79,6 +81,8 @@ interface PendingSend { submit: boolean; trackInRunsTray?: boolean; requestMode?: "act" | "plan"; + /** Correlates with `AGENT_CHAT_SUBMIT_RESULT_EVENT` — see agent-chat.ts. */ + submitMessageId?: string; } /** @@ -93,14 +97,18 @@ interface PendingDelivery { /** The single path that hands a queued send to a mounted chat ref. */ function deliverPendingSend(ref: AssistantChatHandle, send: PendingSend): void { + if (isAgentChatSubmitCancelled(send.submitMessageId)) return; if (!send.submit) { ref.prefillMessage(send.message); return; } - if (send.trackInRunsTray || send.requestMode) { + if (send.trackInRunsTray || send.requestMode || send.submitMessageId) { ref.sendMessage(send.message, send.images, { ...(send.trackInRunsTray ? { trackInRunsTray: true } : {}), ...(send.requestMode ? { requestMode: send.requestMode } : {}), + ...(send.submitMessageId + ? { submitMessageId: send.submitMessageId } + : {}), }); } else { ref.sendMessage(send.message, send.images); @@ -1767,6 +1775,7 @@ export function MultiTabAssistantChat({ background, submit, images, + submitMessageId, } = parsed; const requestedTabId = parsed.tabId; const requestMode = @@ -1777,7 +1786,14 @@ export function MultiTabAssistantChat({ if (openSidebar !== false && !background) { window.dispatchEvent(new CustomEvent("agent-panel:open")); } - if (postMessageSubmissionsDisabled) return; + if (postMessageSubmissionsDisabled) { + reportAgentChatSubmitResult( + submitMessageId, + false, + "composer-disabled", + ); + return; + } // Plan mode is sent as request metadata by the chat adapter. Keep the // user-visible message clean so mode instructions never enter history. @@ -1791,9 +1807,11 @@ export function MultiTabAssistantChat({ submit, ...(background ? { trackInRunsTray: true } : {}), ...(requestMode ? { requestMode } : {}), + ...(submitMessageId ? { submitMessageId } : {}), }; const sendToTab = (threadId: string) => { + if (isAgentChatSubmitCancelled(submitMessageId)) return; // If a model override was specified, apply it only if we recognize it if (model) { const matchedGroup = availableModels.find((g) => @@ -1826,8 +1844,17 @@ export function MultiTabAssistantChat({ if (newTab) { const previousTabId = activeThreadIdRef.current; - createThread(requestedTabId).then((newId) => { - if (newId) { + createThread(requestedTabId) + .then((newId) => { + if (isAgentChatSubmitCancelled(submitMessageId)) return; + if (!newId) { + reportAgentChatSubmitResult( + submitMessageId, + false, + "thread-create-failed", + ); + return; + } newThreadIds.current.add(newId); if (background) { mountedTabsRef.current.add(newId); @@ -1842,8 +1869,14 @@ export function MultiTabAssistantChat({ if (background && previousTabId) { switchThreadState(previousTabId); } - } - }); + }) + .catch(() => { + reportAgentChatSubmitResult( + submitMessageId, + false, + "thread-create-failed", + ); + }); } else { const currentTabId = activeThreadIdRef.current; if (currentTabId) { @@ -1901,6 +1934,7 @@ export function MultiTabAssistantChat({ const active = activeThreadIdRef.current; const remaining: PendingDelivery[] = []; for (const delivery of pendingDeliveries.current) { + if (isAgentChatSubmitCancelled(delivery.send.submitMessageId)) continue; const threadId = delivery.threadId ?? active ?? null; const ref = threadId ? chatRefs.current.get(threadId) : null; if (threadId && ref) { diff --git a/packages/core/src/client/agent-chat.spec.ts b/packages/core/src/client/agent-chat.spec.ts index ea1577dc6c..53cc9c58e8 100644 --- a/packages/core/src/client/agent-chat.spec.ts +++ b/packages/core/src/client/agent-chat.spec.ts @@ -3,7 +3,29 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; // We need to set up a minimal window/postMessage before importing const parentPostMessageSpy = vi.fn(); const selfPostMessageSpy = vi.fn(); -const dispatchEventSpy = vi.fn(); +const windowListeners = new Map< + string, + Set +>(); +const addEventListenerSpy = vi.fn( + (type: string, listener: EventListenerOrEventListenerObject) => { + const listeners = windowListeners.get(type) ?? new Set(); + listeners.add(listener); + windowListeners.set(type, listeners); + }, +); +const removeEventListenerSpy = vi.fn( + (type: string, listener: EventListenerOrEventListenerObject) => { + windowListeners.get(type)?.delete(listener); + }, +); +const dispatchEventSpy = vi.fn((event: Event) => { + for (const listener of windowListeners.get(event.type) ?? []) { + if (typeof listener === "function") listener(event); + else listener.handleEvent(event); + } + return true; +}); const fetchSpy = vi.fn(() => Promise.resolve({ ok: true, status: 200, text: () => Promise.resolve("") }), ); @@ -21,30 +43,39 @@ vi.mock("./mcp-app-host.js", () => ({ sendMcpAppHostMessage: sendMcpAppHostMessageMock, })); -vi.stubGlobal("window", { +const windowStub = { parent: { postMessage: parentPostMessageSpy }, - addEventListener: vi.fn(), + addEventListener: addEventListenerSpy, + removeEventListener: removeEventListenerSpy, dispatchEvent: dispatchEventSpy, postMessage: selfPostMessageSpy, + setTimeout: (...args: Parameters) => setTimeout(...args), + clearTimeout: (timer: ReturnType) => clearTimeout(timer), location: { origin: "http://localhost:3000", pathname: "/", search: "", }, -}); +}; +vi.stubGlobal("window", windowStub); vi.stubGlobal("fetch", fetchSpy); const { _resetAgentChatContextForTests, + _resetAgentChatSubmitBufferForTests, addContextToAgentChat, + claimAgentChatSubmit, clearAgentChatContext, + drainBufferedAgentChatSubmits, formatAgentChatContextItemsForPrompt, generateTabId, insertAgentComposerReference, listAgentChatContext, normalizeAgentComposerReference, removeAgentChatContextItem, + reportAgentChatSubmitResult, sendToAgentChat, + sendToAgentChatAndConfirm, setAgentChatContextItem, setContextToAgentChat, } = await import("./agent-chat.js"); @@ -75,6 +106,7 @@ function createMemoryStorage(): Storage { describe("sendToAgentChat", () => { beforeEach(() => { + windowListeners.clear(); frameState.inBuilderFrame = false; (window as unknown as { parent: unknown }).parent = { postMessage: parentPostMessageSpy, @@ -99,6 +131,7 @@ describe("sendToAgentChat", () => { window.sessionStorage?.clear(); _resetEmbedAuthForTests(); _resetAgentChatContextForTests(); + _resetAgentChatSubmitBufferForTests(); }); afterEach(() => { @@ -479,6 +512,115 @@ describe("sendToAgentChat", () => { expect(id1).not.toBe(id2); }); + it("confirms a local submit after the receiving chat accepts it", async () => { + vi.useFakeTimers(); + const resultPromise = sendToAgentChatAndConfirm({ + message: "apply these annotations", + submit: true, + chatTarget: "local", + }); + + vi.advanceTimersByTime(0); + const payload = selfPostMessageSpy.mock.calls.at(-1)?.[0]; + expect(payload?.data?.submitMessageId).toEqual(expect.any(String)); + reportAgentChatSubmitResult(payload.data.submitMessageId, true); + + await expect(resultPromise).resolves.toMatchObject({ delivered: true }); + }); + + it("preserves an explicit local rejection reason", async () => { + vi.useFakeTimers(); + const resultPromise = sendToAgentChatAndConfirm({ + message: "apply these annotations", + submit: true, + chatTarget: "local", + }); + vi.advanceTimersByTime(0); + const submitMessageId = selfPostMessageSpy.mock.calls.at(-1)?.[0]?.data + ?.submitMessageId as string; + reportAgentChatSubmitResult(submitMessageId, false, "missing-engine"); + + await expect(resultPromise).resolves.toMatchObject({ + delivered: false, + reason: "missing-engine", + }); + }); + + it("rejects non-local confirmation targets without sending", async () => { + const result = await sendToAgentChatAndConfirm({ + message: "route to a parent chat", + submit: true, + }); + + expect(result).toMatchObject({ + delivered: false, + reason: "unsupported-target", + }); + expect(parentPostMessageSpy).not.toHaveBeenCalled(); + expect(selfPostMessageSpy).not.toHaveBeenCalled(); + }); + + it("short-circuits safely without window", async () => { + vi.stubGlobal("window", undefined); + const result = await sendToAgentChatAndConfirm({ + message: "server render", + submit: true, + chatTarget: "local", + }); + vi.stubGlobal("window", windowStub); + + expect(result).toMatchObject({ + delivered: false, + reason: "no-window", + }); + expect(parentPostMessageSpy).not.toHaveBeenCalled(); + expect(selfPostMessageSpy).not.toHaveBeenCalled(); + }); + + it("tombstones a timed-out submit so a late receiver cannot claim it", async () => { + vi.useFakeTimers(); + const resultPromise = sendToAgentChatAndConfirm( + { + message: "do not arrive late", + submit: true, + chatTarget: "local", + }, + { timeoutMs: 5 }, + ); + vi.advanceTimersByTime(0); + const submitMessageId = selfPostMessageSpy.mock.calls.at(-1)?.[0]?.data + ?.submitMessageId as string; + + vi.advanceTimersByTime(5); + await expect(resultPromise).resolves.toMatchObject({ + delivered: false, + reason: "timeout", + }); + expect(drainBufferedAgentChatSubmits()).toEqual([]); + expect(claimAgentChatSubmit(submitMessageId)).toBe(false); + }); + + it("keeps the default confirmation alive beyond the replay buffer TTL", async () => { + vi.useFakeTimers(); + let settled = false; + const resultPromise = sendToAgentChatAndConfirm({ + message: "wait for the lazy panel", + submit: true, + chatTarget: "local", + }).then((result) => { + settled = true; + return result; + }); + vi.advanceTimersByTime(8001); + await flushMicrotasks(); + expect(settled).toBe(false); + + const submitMessageId = selfPostMessageSpy.mock.calls.at(-1)?.[0]?.data + ?.submitMessageId as string; + reportAgentChatSubmitResult(submitMessageId, true); + await expect(resultPromise).resolves.toMatchObject({ delivered: true }); + }); + it("keeps legacy context helper names as aliases", () => { expect(setContextToAgentChat).toBe(setAgentChatContextItem); expect(addContextToAgentChat).toBe(setAgentChatContextItem); diff --git a/packages/core/src/client/agent-chat.ts b/packages/core/src/client/agent-chat.ts index 31213de79f..7b85149069 100644 --- a/packages/core/src/client/agent-chat.ts +++ b/packages/core/src/client/agent-chat.ts @@ -96,6 +96,11 @@ export interface AgentChatMessage { * focusing it or opening the sidebar. The message runs silently. */ background?: boolean; + /** + * Stable id used to deduplicate a submit and correlate it with + * {@link AGENT_CHAT_SUBMIT_RESULT_EVENT}. Auto-generated if omitted. + */ + submitMessageId?: string; } export interface AgentChatContextItem { @@ -210,6 +215,45 @@ export const AGENT_CHAT_INSERT_REFERENCE_EVENT = "agentNative:insert-composer-reference"; const AGENT_PANEL_PREPARE_EVENT = "agent-panel:prepare"; +/** + * Fired once a submitted turn's fate is known: `delivered: true` once the + * receiving AssistantChat has actually committed the turn (added it to the + * visible thread, independent of whether the agent's response later + * succeeds), or `delivered: false` when it was rejected before ever + * appearing — e.g. no LLM/agent engine configured. Callers that must know + * whether their submit truly landed (rather than fire-and-forget) should use + * {@link sendToAgentChatAndConfirm} instead of listening for this directly. + */ +export const AGENT_CHAT_SUBMIT_RESULT_EVENT = "agentNative.chatSubmitResult"; + +export interface AgentChatSubmitResult { + submitMessageId: string; + delivered: boolean; + reason?: string; +} + +/** Report a submit's definitive outcome so `sendToAgentChatAndConfirm` (or any + * other correlated listener) can resolve. No-ops without a submitMessageId or + * a window (SSR). */ +export function reportAgentChatSubmitResult( + submitMessageId: string | undefined, + delivered: boolean, + reason?: string, +): void { + if ( + !submitMessageId || + cancelledSubmitIds.has(submitMessageId) || + typeof window === "undefined" + ) { + return; + } + window.dispatchEvent( + new CustomEvent(AGENT_CHAT_SUBMIT_RESULT_EVENT, { + detail: { submitMessageId, delivered, reason }, + }), + ); +} + let agentChatContextState: AgentChatContextState = { items: [], updatedAt: 0, @@ -262,6 +306,7 @@ interface BufferedSelfSubmit { const SELF_SUBMIT_BUFFER_TTL_MS = 8000; const bufferedSelfSubmits: BufferedSelfSubmit[] = []; const claimedSubmitIds = new Set(); +const cancelledSubmitIds = new Set(); interface BufferedOpenRequest extends BufferedAgentChatOpenRequest { at: number; @@ -289,6 +334,28 @@ function bufferSelfSubmit(data: Record): void { bufferedSelfSubmits.push({ id, data, at: now }); } +/** + * Permanently tombstone a submit for this page lifetime and remove its + * cold-start replay. A confirmation timeout must be terminal: callers may + * retry while preserving their own state, so the original submit cannot be + * allowed to appear later when a lazy panel or thread ref finally mounts. + */ +export function cancelAgentChatSubmit(id: string | undefined): void { + if (!id) return; + cancelledSubmitIds.add(id); + claimedSubmitIds.add(id); + for (let index = bufferedSelfSubmits.length - 1; index >= 0; index -= 1) { + if (bufferedSelfSubmits[index]?.id === id) { + bufferedSelfSubmits.splice(index, 1); + } + } +} + +/** Whether a confirmed-submit timeout has made this delivery terminal. */ +export function isAgentChatSubmitCancelled(id: string | undefined): boolean { + return Boolean(id && cancelledSubmitIds.has(id)); +} + function pruneOpenRequestBuffer(now: number): void { for (let i = bufferedOpenRequests.length - 1; i >= 0; i -= 1) { if (now - bufferedOpenRequests[i].at > OPEN_REQUEST_BUFFER_TTL_MS) { @@ -328,6 +395,7 @@ export function drainBufferedAgentChatSubmits(): Array< /** Claim a submit; false if already handled. Idless submits always pass. */ export function claimAgentChatSubmit(id: string | undefined): boolean { if (!id) return true; + if (cancelledSubmitIds.has(id)) return false; if (claimedSubmitIds.has(id)) return false; claimedSubmitIds.add(id); return true; @@ -353,6 +421,7 @@ export function claimAgentChatOpenRequest(id: unknown): boolean { export function _resetAgentChatSubmitBufferForTests(): void { bufferedSelfSubmits.length = 0; claimedSubmitIds.clear(); + cancelledSubmitIds.clear(); bufferedOpenRequests.length = 0; claimedOpenRequestIds.clear(); } @@ -896,7 +965,7 @@ export function sendToAgentChat(opts: AgentChatMessage): string { return tabId; } - const submitMessageId = generateSubmitMessageId(); + const submitMessageId = opts.submitMessageId ?? generateSubmitMessageId(); const payload = { type: AGENT_CHAT_MESSAGE_TYPE, data: { @@ -977,6 +1046,101 @@ export function sendToAgentChat(opts: AgentChatMessage): string { return tabId; } +// Must exceed SELF_SUBMIT_BUFFER_TTL_MS. Otherwise confirmation can time out +// while its replay is still eligible to mount and deliver later. +const DEFAULT_SUBMIT_CONFIRM_TIMEOUT_MS = SELF_SUBMIT_BUFFER_TTL_MS + 2000; + +export interface SendToAgentChatAndConfirmResult { + tabId: string; + /** True once the message actually became a visible turn in the chat. */ + delivered: boolean; + /** Set when `delivered` is false: "missing-engine", "timeout", etc. */ + reason?: string; +} + +/** + * Like {@link sendToAgentChat}, but resolves once the submit's fate is known + * instead of firing and forgetting. Use this whenever the caller must decide + * whether to keep/restore its own state on failure (e.g. a draw/annotate + * overlay that should not discard the user's work unless the message actually + * reached the chat) — see the `AGENT_CHAT_SUBMIT_RESULT_EVENT` contract. + * This acknowledgement is intentionally limited to submitted, non-code + * messages with `chatTarget: "local"`; parent-frame and MCP host chats use + * different delivery protocols and return `unsupported-target` here. + * + * Resolves `delivered: false` if the receiving chat explicitly rejects the + * submit (e.g. no LLM/agent engine configured) OR if no result arrives within + * `timeoutMs` — a silently-stuck submit (panel never mounts, thread never + * gets a ref, message type not handled by this build) must fail the same way + * an explicit rejection does, since the caller cannot otherwise tell the + * difference between "still in flight" and "dropped." + */ +export function sendToAgentChatAndConfirm( + opts: Omit, + options?: { timeoutMs?: number }, +): Promise { + const tabId = opts.tabId ?? generateTabId(); + if (typeof window === "undefined") { + return Promise.resolve({ tabId, delivered: false, reason: "no-window" }); + } + // Confirmation is deliberately a same-window/local-chat contract. Parent + // frames, Builder code chat, and MCP host relays have independent protocols + // and cannot answer this window-local CustomEvent acknowledgement. + if ( + opts.chatTarget !== "local" || + opts.type === "code" || + opts.requiresCode === true || + opts.submit === false + ) { + return Promise.resolve({ + tabId, + delivered: false, + reason: "unsupported-target", + }); + } + + const submitMessageId = generateSubmitMessageId(); + const timeoutMs = Math.max( + 0, + options?.timeoutMs ?? DEFAULT_SUBMIT_CONFIRM_TIMEOUT_MS, + ); + + return new Promise((resolve) => { + let settled = false; + let timer: number | undefined; + const cleanup = () => { + window.removeEventListener( + AGENT_CHAT_SUBMIT_RESULT_EVENT, + onResult as EventListener, + ); + if (timer !== undefined) window.clearTimeout(timer); + }; + const finish = (delivered: boolean, reason?: string, cancel = false) => { + if (settled) return; + settled = true; + if (cancel) cancelAgentChatSubmit(submitMessageId); + cleanup(); + resolve({ tabId, delivered, reason }); + }; + const onResult = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (!detail || detail.submitMessageId !== submitMessageId) return; + finish(detail.delivered, detail.reason); + }; + + window.addEventListener( + AGENT_CHAT_SUBMIT_RESULT_EVENT, + onResult as EventListener, + ); + timer = window.setTimeout(() => finish(false, "timeout", true), timeoutMs); + try { + sendToAgentChat({ ...opts, tabId, submitMessageId }); + } catch { + finish(false, "send-failed", true); + } + }); +} + /** * Add or replace a keyed context nugget in the active agent chat composer. * The context is not submitted until the user sends the prompt. diff --git a/packages/core/src/client/chat/index.ts b/packages/core/src/client/chat/index.ts index 305371e68e..6653da2943 100644 --- a/packages/core/src/client/chat/index.ts +++ b/packages/core/src/client/chat/index.ts @@ -59,7 +59,15 @@ export { } from "../code-agent-chat-adapter.js"; export * from "./connectors.js"; export * from "./runtime.js"; -export { sendToAgentChat, type AgentChatMessage } from "../agent-chat.js"; +export { + sendToAgentChat, + sendToAgentChatAndConfirm, + reportAgentChatSubmitResult, + AGENT_CHAT_SUBMIT_RESULT_EVENT, + type AgentChatMessage, + type AgentChatSubmitResult, + type SendToAgentChatAndConfirmResult, +} from "../agent-chat.js"; export { useAgentChatGenerating } from "../use-agent-chat.js"; export { useSendToAgentChat } from "../use-send-to-agent-chat.js"; export { diff --git a/packages/core/src/client/extensions/EmbeddedExtension.spec.ts b/packages/core/src/client/extensions/EmbeddedExtension.spec.ts new file mode 100644 index 0000000000..ac8bdc7dca --- /dev/null +++ b/packages/core/src/client/extensions/EmbeddedExtension.spec.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; + +import { sanitizeSlotContextForPostMessage } from "./EmbeddedExtension.js"; + +/** + * Regression test for a DataCloneError bug: EmbeddedExtension's onLoad + * handler and its "context changed" effect both posted the raw slot + * `context` object straight into `window.postMessage`. Real slot contexts + * (e.g. Design's DesignExtensionSlotContext) carry live callback functions + * the host uses internally (onShaderFillPreview, onShaderFillApplied, ...). + * `postMessage` uses the structured clone algorithm, which throws + * `DataCloneError: ... could not be cloned` on any function-valued + * property — so every extension iframe load logged a console error and + * never actually received the context update. The fix round-trips the + * context through JSON (matching the `contextJson` string already computed + * for the effect's dependency array) before posting it. + */ +describe("sanitizeSlotContextForPostMessage", () => { + it("drops function-valued properties", () => { + const sanitized = sanitizeSlotContextForPostMessage({ + designId: "abc123", + onShaderFillPreview: (_descriptor: unknown, _css: string) => {}, + onShaderFillPreviewClear: () => {}, + }); + expect(sanitized).toEqual({ designId: "abc123" }); + expect("onShaderFillPreview" in sanitized).toBe(false); + }); + + it("preserves plain nested data untouched", () => { + const sanitized = sanitizeSlotContextForPostMessage({ + designId: "abc123", + screens: [{ id: "f1", filename: "index.html" }], + selectedElement: { selector: "#hero", nodeId: "n1" }, + zoom: 1.5, + }); + expect(sanitized).toEqual({ + designId: "abc123", + screens: [{ id: "f1", filename: "index.html" }], + selectedElement: { selector: "#hero", nodeId: "n1" }, + zoom: 1.5, + }); + }); + + it("returns an empty object for null/undefined context", () => { + expect(sanitizeSlotContextForPostMessage(null)).toEqual({}); + expect(sanitizeSlotContextForPostMessage(undefined)).toEqual({}); + }); + + it("fails safe to an empty object on a circular reference", () => { + const circular: Record = { designId: "abc123" }; + circular.self = circular; + expect(sanitizeSlotContextForPostMessage(circular)).toEqual({}); + }); +}); diff --git a/packages/core/src/client/extensions/EmbeddedExtension.tsx b/packages/core/src/client/extensions/EmbeddedExtension.tsx index 03102dbed2..7f164c895f 100644 --- a/packages/core/src/client/extensions/EmbeddedExtension.tsx +++ b/packages/core/src/client/extensions/EmbeddedExtension.tsx @@ -85,6 +85,31 @@ function serializeChatValue(value: unknown): string | undefined { } } +/** + * Slot contexts (e.g. Design's `DesignExtensionSlotContext`) commonly carry + * live callback functions alongside plain data — the host component uses + * those callbacks itself, but `window.postMessage` uses the structured clone + * algorithm, which throws a `DataCloneError` on any function-valued property + * (see MDN's postMessage docs). Round-tripping through JSON drops functions + * (and other non-cloneable values like symbols) the same way + * `JSON.stringify` already silently omits them, producing a payload that's + * safe to post. Exported so a test can verify functions never reach the + * iframe instead of only reading the code. + */ +export function sanitizeSlotContextForPostMessage( + context: Record | null | undefined, +): Record { + try { + return JSON.parse(JSON.stringify(context ?? {})); + } catch { + // Circular reference or other non-serializable shape — fail safe to an + // empty context rather than letting postMessage throw and skip every + // other message this handler sends in the same tick (theme update, + // ready signal). + return {}; + } +} + export interface EmbeddedExtensionProps { extensionId: string; /** Slot identifier passed via the iframe URL so the extension runtime knows it's @@ -243,8 +268,16 @@ export function EmbeddedExtension({ useEffect(() => { const win = iframeRef.current?.contentWindow; if (!win) return; + // Post the JSON-round-tripped context, not the raw `context` object — + // slot contexts (e.g. Design's DesignExtensionSlotContext) carry live + // callback functions the host uses internally, and postMessage's + // structured clone throws a DataCloneError on any function-valued + // property. See sanitizeSlotContextForPostMessage's docblock. win.postMessage( - { type: "agent-native-slot-context", context: context ?? {} }, + { + type: "agent-native-slot-context", + context: sanitizeSlotContextForPostMessage(context), + }, "*", ); }, [contextJson]); @@ -400,7 +433,10 @@ export function EmbeddedExtension({ style={{ width: "100%", border: 0, height, display: "block" }} onLoad={() => { iframeRef.current?.contentWindow?.postMessage( - { type: "agent-native-slot-context", context: context ?? {} }, + { + type: "agent-native-slot-context", + context: sanitizeSlotContextForPostMessage(context), + }, "*", ); // Re-assert theme once the iframe document is live. The src bakes in diff --git a/packages/core/src/client/index.ts b/packages/core/src/client/index.ts index 6b136bca52..413b43f1d5 100644 --- a/packages/core/src/client/index.ts +++ b/packages/core/src/client/index.ts @@ -19,6 +19,9 @@ export { requestAgentChatThreadOpen, requestAgentTaskOpen, sendToAgentChat, + sendToAgentChatAndConfirm, + reportAgentChatSubmitResult, + AGENT_CHAT_SUBMIT_RESULT_EVENT, parseSubmitChatMessage, setAgentChatContextItem, setContextToAgentChat, @@ -33,6 +36,8 @@ export { type AgentChatContextSetOptions, type AgentChatContextState, type AgentChatMessage, + type AgentChatSubmitResult, + type SendToAgentChatAndConfirmResult, type AgentComposerReference, type AgentComposerReferenceInsertOptions, type AgentComposerReferenceInsertPayload, diff --git a/packages/core/src/client/rich-markdown-editor/useCollabReconcile.concurrent.spec.ts b/packages/core/src/client/rich-markdown-editor/useCollabReconcile.concurrent.spec.ts index 8113951b55..cdcef92dca 100644 --- a/packages/core/src/client/rich-markdown-editor/useCollabReconcile.concurrent.spec.ts +++ b/packages/core/src/client/rich-markdown-editor/useCollabReconcile.concurrent.spec.ts @@ -55,6 +55,7 @@ interface Captured { editor: Editor | null; emitted: string[]; setContentCalls: number; + registerEmitted?: (markdown: string) => boolean; } function makeHarness() { @@ -100,6 +101,7 @@ function makeHarness() { }, }); guardsRef.current = guards; + captured.registerEmitted = guards.registerEmitted; return React.createElement("div", null); } @@ -270,6 +272,64 @@ describe("useCollabReconcile — concurrent edit / lost-update guards", () => { expect(getEditorMarkdown(captured.editor!)).toBe("# V1 content"); }); + it("applies a newer authoritative revert that matches a prior mount-time emission", async () => { + const { captured, Harness } = makeHarness(); + + render(root, Harness, { + value: "# V1 content", + contentUpdatedAt: "2024-01-01T00:00:01.000Z", + }); + await flush(); + render(root, Harness, { + value: "# V2 content", + contentUpdatedAt: "2024-01-01T00:00:02.000Z", + }); + await flush(); + expect(getEditorMarkdown(captured.editor!)).toBe("# V2 content"); + + // A collab mount/schema-normalization transaction can emit the old V1 + // bytes even though the authoritative apply has already moved the editor + // to V2. Record that echo without focusing/typing in the editor. + expect(captured.registerEmitted?.("# V1 content")).toBe(true); + + render(root, Harness, { + value: "# V1 content", + contentUpdatedAt: "2024-01-01T00:00:03.000Z", + }); + await flush(); + + expect(getEditorMarkdown(captured.editor!)).toBe("# V1 content"); + }); + + it("ignores local-looking editor updates until collaborative seeding completes", async () => { + const results: boolean[] = []; + + function Probe() { + const editor = useEditor({ + extensions: createRichMarkdownExtensions({ dialect: "gfm" }), + content: "", + }); + const fakeYdoc = { clientID: 1, getXmlFragment: () => ({ length: 0 }) }; + const guards = useCollabReconcile({ + editor, + ydoc: fakeYdoc as never, + collabSynced: false, + value: "authoritative content", + contentUpdatedAt: "2024-01-01T00:00:01.000Z", + editable: true, + }); + if (editor && results.length === 0) { + results.push(guards.shouldIgnoreUpdate(editor.state.tr)); + } + return React.createElement("div", null); + } + + act(() => root.render(React.createElement(Probe))); + await flush(); + + expect(results).toEqual([true]); + }); + it("refuses to persist an empty doc in collab mode (registerEmitted guard)", async () => { // Directly exercise the guard contract: in collab mode an empty markdown // string must not be registered/persisted (would clobber stored content diff --git a/packages/core/src/client/rich-markdown-editor/useCollabReconcile.ts b/packages/core/src/client/rich-markdown-editor/useCollabReconcile.ts index d9a389e123..2d1d19f3e2 100644 --- a/packages/core/src/client/rich-markdown-editor/useCollabReconcile.ts +++ b/packages/core/src/client/rich-markdown-editor/useCollabReconcile.ts @@ -385,6 +385,8 @@ export function useCollabReconcile({ const editorUnchangedSinceApply = lastAppliedSerializedRef.current !== null && currentMarkdown === lastAppliedSerializedRef.current; + const typingRecently = + editor.isFocused && Date.now() - lastTypedAtRef.current < 1500; // Doc-equivalence skip. Never re-apply content the editor already // represents — comparing by DOC EQUIVALENCE, not raw strings/timestamps: @@ -407,11 +409,15 @@ export function useCollabReconcile({ // `

` → `<p>` → `&lt;p&gt;` escalation. if ( currentMarkdown === normalizedValue || - value === lastEmittedRef.current || - // A stale-but-recent echo of our own (possibly partial) save — applying - // it would clobber the freshly-typed tail. External edits never match. - recentEmittedRef.current.includes(value) || - recentEmittedRef.current.includes(normalizedValue) || + (typingRecently && + // A stale echo of our own (possibly partial) save while the user is + // actively typing would clobber the fresh tail. Outside active + // typing, the same bytes can be a deliberate newer external revert + // (history restore / provider conflict choice) and must be allowed + // through even if mount-time normalization emitted them. + (value === lastEmittedRef.current || + recentEmittedRef.current.includes(value) || + recentEmittedRef.current.includes(normalizedValue))) || (editorUnchangedSinceApply && (value === lastAppliedValueRef.current || normalizedValue === lastAppliedSerializedRef.current)) @@ -442,8 +448,6 @@ export function useCollabReconcile({ // for non-idempotent input, fight every keystroke. Newer external content // retries so it still lands once they pause; older-or-equal content is a // stale poll and is dropped outright while focused. - const typingRecently = - editor.isFocused && Date.now() - lastTypedAtRef.current < 1500; if (typingRecently) { if (externalNewer) { retry = setTimeout(() => apply(deferred), 700); @@ -549,6 +553,13 @@ export function useCollabReconcile({ const shouldIgnoreUpdate = (transaction: Transaction): boolean => { if (!editable || isSettingContentRef.current) return true; + // Collaboration can emit schema/default-paragraph transactions while the + // persisted Y.Doc is still loading and before the authoritative value has + // seeded/reconciled. Never let those transient pre-seed updates reach an + // app's local mirror or autosave path (Content serializes an empty paragraph + // as ``, which is non-empty text and bypasses the generic + // registerEmitted empty-string guard). + if (collab && !seededRef.current) return true; if (transaction.getMeta(RICH_MARKDOWN_PROGRAMMATIC_TRANSACTION)) { return true; } diff --git a/packages/core/src/collab/awareness-store.spec.ts b/packages/core/src/collab/awareness-store.spec.ts index e5d36ea3ee..ad00e79bd8 100644 --- a/packages/core/src/collab/awareness-store.spec.ts +++ b/packages/core/src/collab/awareness-store.spec.ts @@ -89,6 +89,7 @@ import { _resetAwarenessStoreForTests, deleteAwarenessRow, loadAwarenessRows, + loadAwarenessRowsStrict, upsertAwarenessRow, } from "./awareness-store.js"; @@ -167,5 +168,8 @@ describe("awareness-store", () => { ).resolves.toBeUndefined(); await expect(deleteAwarenessRow("doc-6", 1)).resolves.toBeUndefined(); await expect(loadAwarenessRows("doc-6")).resolves.toEqual([]); + await expect(loadAwarenessRowsStrict("doc-6")).rejects.toThrow( + "db not configured", + ); }); }); diff --git a/packages/core/src/collab/awareness-store.ts b/packages/core/src/collab/awareness-store.ts index 3f1c350596..5d1f71c3fb 100644 --- a/packages/core/src/collab/awareness-store.ts +++ b/packages/core/src/collab/awareness-store.ts @@ -149,23 +149,34 @@ export async function loadAwarenessRows( now: number = Date.now(), ): Promise { try { - await ensureTable(); - const client = getDbExec(); - const { rows } = await client.execute({ - sql: `SELECT client_id, state, last_seen FROM _collab_awareness - WHERE doc_id = ? AND last_seen >= ?`, - args: [docId, now - ROW_TTL_MS], - }); - return rows.map((row: any) => ({ - clientId: Number(row.client_id), - state: String(row.state), - lastSeen: Number(row.last_seen), - })); + return await loadAwarenessRowsStrict(docId, now); } catch { return []; } } +/** + * Strict awareness read for safety-critical callers that must distinguish + * "no active participants" from "presence storage could not be read." + */ +export async function loadAwarenessRowsStrict( + docId: string, + now: number = Date.now(), +): Promise { + await ensureTable(); + const client = getDbExec(); + const { rows } = await client.execute({ + sql: `SELECT client_id, state, last_seen FROM _collab_awareness + WHERE doc_id = ? AND last_seen >= ?`, + args: [docId, now - ROW_TTL_MS], + }); + return rows.map((row: any) => ({ + clientId: Number(row.client_id), + state: String(row.state), + lastSeen: Number(row.last_seen), + })); +} + async function maybePurge(docId: string, now: number): Promise { const last = _lastPurges.get(docId) ?? 0; if (now - last < PURGE_INTERVAL_MS) return; diff --git a/packages/core/src/collab/client.registry.spec.tsx b/packages/core/src/collab/client.registry.spec.tsx index e320269897..1485656c94 100644 --- a/packages/core/src/collab/client.registry.spec.tsx +++ b/packages/core/src/collab/client.registry.spec.tsx @@ -26,16 +26,23 @@ import { type UseCollaborativeDocResult, } from "./client.js"; -/** Minimal EventSource stand-in so the shared transport never opens SSE. */ +/** + * Minimal EventSource stand-in so the shared transport never opens a real + * SSE connection. Tracks every constructed instance so tests can push + * synthetic push events without going through a real EventSource. + */ class FakeEventSource { static readonly CONNECTING = 0; static readonly OPEN = 1; static readonly CLOSED = 2; + static instances: FakeEventSource[] = []; readyState = FakeEventSource.CONNECTING; onopen: (() => void) | null = null; onerror: (() => void) | null = null; onmessage: ((message: { data: string }) => void) | null = null; - constructor(readonly url: string) {} + constructor(readonly url: string) { + FakeEventSource.instances.push(this); + } close(): void { this.readyState = FakeEventSource.CLOSED; } @@ -64,11 +71,13 @@ function makeFetchMock() { function Probe({ docId, onResult, + user, }: { docId: string | null; onResult: (result: UseCollaborativeDocResult) => void; + user?: { name: string; email: string; color: string }; }) { - const result = useCollaborativeDoc({ docId }); + const result = useCollaborativeDoc({ docId, user }); onResult(result); return null; } @@ -92,6 +101,7 @@ describe("useCollaborativeDoc connection registry", () => { beforeEach(() => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); vi.stubGlobal("EventSource", FakeEventSource); + FakeEventSource.instances = []; vi.useFakeTimers(); _resetCollabDocRegistryForTests(); _resetSyncTransportRegistryForTests(); @@ -252,4 +262,73 @@ describe("useCollaborativeDoc connection registry", () => { expect(_collabDocRegistrySizeForTests()).toBe(1); expect(result?.ydoc?.isDestroyed).toBe(false); }); + + it("does not re-broadcast local awareness state when a REMOTE awareness event arrives (no storm)", async () => { + const { mock } = makeFetchMock(); + vi.stubGlobal("fetch", mock); + + mount( + {}} + user={{ name: "Local", email: "local@example.com", color: "#111" }} + />, + ); + // Flush the initial state fetch + first poll cycle, then let the local + // `setUser` awareness push (origin "local") land. + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(200); + }); + + const source = FakeEventSource.instances.at(-1); + expect(source).toBeTruthy(); + source!.onopen?.(); + + const awarenessPostsBefore = mock.mock.calls.filter( + ([input, init]) => + String(input).includes("/awareness") && + (init as RequestInit | undefined)?.method === "POST", + ).length; + + // Simulate a REMOTE peer's cursor move arriving over the shared SSE + // transport — this is what `applyAwarenessEvent` receives, and it emits + // `awareness.emit("change", [changes, "remote"])` after reconciling. + await act(async () => { + source!.onmessage?.({ + data: JSON.stringify({ + source: "awareness", + type: "awareness-change", + docId: "doc-1", + states: [ + { + clientId: 999_001, + state: JSON.stringify({ + user: { + name: "Remote", + email: "remote@example.com", + color: "#222", + }, + cursor: { x: 0.5, y: 0.5 }, + }), + }, + ], + }), + }); + // Past the 150ms fast-awareness-push throttle window. + await vi.advanceTimersByTimeAsync(200); + }); + + const awarenessPostsAfter = mock.mock.calls.filter( + ([input, init]) => + String(input).includes("/awareness") && + (init as RequestInit | undefined)?.method === "POST", + ).length; + + // A remote-originated awareness change must not cause THIS client to + // re-broadcast its own (unchanged) state — otherwise every peer's cursor + // move would fan out into an extra POST from every other connected + // client (an awareness storm that gets worse as more people join). + expect(awarenessPostsAfter).toBe(awarenessPostsBefore); + }); }); diff --git a/packages/core/src/collab/client.ts b/packages/core/src/collab/client.ts index c79772a63a..9a4f8f09e5 100644 --- a/packages/core/src/collab/client.ts +++ b/packages/core/src/collab/client.ts @@ -579,7 +579,10 @@ class CollabDocConnection { this.awareness.setLocalStateField("visible", !isDocumentHidden()); } - private handleAwarenessChange = (): void => { + private handleAwarenessChange = ( + _changes?: { added: number[]; updated: number[]; removed: number[] }, + origin?: unknown, + ): void => { const users: CollabUser[] = []; let hasAgent = false; this.awareness.getStates().forEach((state, clientId) => { @@ -616,12 +619,20 @@ class CollabDocConnection { this.setSnapshot({ activeUsers, agentPresent: hasAgent }); } - // Fast awareness push: whenever awareness changes (e.g. cursor moves, - // setPresence() calls), schedule a throttled POST so peers receive updates - // at ~150ms instead of waiting for the next poll cycle. Only active once a + // Fast awareness push: whenever OUR OWN awareness state changes (e.g. + // cursor moves, setPresence() calls), schedule a throttled POST so peers + // receive updates at ~150ms instead of waiting for the next poll cycle. + // Gated on origin === "local": this listener also fires for remote + // awareness changes (poll/SSE call `awareness.emit("change", [changes, + // "remote"])` after reconciling incoming states — see + // applyAwarenessEvent/poll above). Without the origin check, every + // peer's cursor move would also re-trigger THIS client to re-broadcast + // its own unchanged state, turning one person moving their mouse into + // O(n) redundant POSTs from every other connected client (an awareness + // storm that gets worse as more people join the doc). Only active once a // local user identity has been published (matches the previous per-hook // gating on `user`). The poll cycle remains the authoritative baseline. - if (this.lastSetUser && !this.disposed) { + if (this.lastSetUser && origin === "local" && !this.disposed) { scheduleAwarenessPush( this.baseUrl, this.docId, diff --git a/packages/core/src/collab/index.ts b/packages/core/src/collab/index.ts index 6f1f408fd0..74dd334dc1 100644 --- a/packages/core/src/collab/index.ts +++ b/packages/core/src/collab/index.ts @@ -126,6 +126,10 @@ export { type AwarenessEntry, type AwarenessChangeEvent, } from "./awareness.js"; +export { + loadAwarenessRows, + loadAwarenessRowsStrict, +} from "./awareness-store.js"; // Presence kit export { diff --git a/packages/core/src/collab/recent-edits.spec.ts b/packages/core/src/collab/recent-edits.spec.ts index 5ab975b039..1109e91b59 100644 --- a/packages/core/src/collab/recent-edits.spec.ts +++ b/packages/core/src/collab/recent-edits.spec.ts @@ -50,6 +50,57 @@ describe("appendRecentEdit", () => { appendRecentEdit(original, { descriptor: { kind: "doc" }, at: 2 }); expect(original).toHaveLength(1); }); + + it("truncates an oversized quote so a caller forgetting to trim can't blow up the awareness payload", () => { + const hugeQuote = "x".repeat(50_000); + const [entry] = appendRecentEdit(undefined, { + descriptor: { kind: "text", quote: hugeQuote }, + at: 1, + }); + expect( + (entry.descriptor as { kind: "text"; quote: string }).quote.length, + ).toBe(500); + }); + + it("truncates an oversized selector and each oversized path entry", () => { + const huge = "a".repeat(10_000); + const [selectorEntry] = appendRecentEdit(undefined, { + descriptor: { kind: "selector", selector: huge }, + at: 1, + }); + expect( + (selectorEntry.descriptor as { kind: "selector"; selector: string }) + .selector.length, + ).toBe(500); + + const [pathsEntry] = appendRecentEdit(undefined, { + descriptor: { kind: "paths", paths: [huge, "short.path"] }, + at: 1, + }); + const paths = (pathsEntry.descriptor as { kind: "paths"; paths: string[] }) + .paths; + expect(paths[0]!.length).toBe(500); + expect(paths[1]).toBe("short.path"); + }); + + it("truncates an oversized label", () => { + const [entry] = appendRecentEdit(undefined, { + descriptor: { kind: "doc" }, + label: "y".repeat(2_000), + at: 1, + }); + expect(entry.label!.length).toBe(500); + }); + + it("leaves short descriptors and labels untouched", () => { + const [entry] = appendRecentEdit(undefined, { + descriptor: { kind: "text", quote: "hello world" }, + label: "Edited", + at: 1, + }); + expect(entry.descriptor).toEqual({ kind: "text", quote: "hello world" }); + expect(entry.label).toBe("Edited"); + }); }); describe("collectRecentEdits", () => { diff --git a/packages/core/src/collab/recent-edits.ts b/packages/core/src/collab/recent-edits.ts index b379cf85e5..9b73d53c18 100644 --- a/packages/core/src/collab/recent-edits.ts +++ b/packages/core/src/collab/recent-edits.ts @@ -21,6 +21,7 @@ import { RECENT_EDIT_TTL_MS, type AttributedRecentEdit, type RecentEdit, + type RecentEditDescriptor, } from "@agent-native/toolkit/collab-ui"; import { useEffect, useRef, useState } from "react"; @@ -34,16 +35,78 @@ export { type RecentEditDescriptor, } from "@agent-native/toolkit/collab-ui"; +/** + * Hard cap on any single string carried in a recentEdits entry (quote, + * selector, path segment, label). Callers are expected to pass short + * excerpts already (existing call sites trim to 80–120 chars for a "what + * changed" snippet), but this is the ring's own size ceiling — a caller that + * forgets to trim (e.g. passing a whole paragraph/document as `quote`) must + * not blow up the awareness payload every connected client receives on the + * fast-push path, nor the `_collab_awareness` SQL row it gets mirrored into. + */ +const RECENT_EDIT_STRING_MAX = 500; + +function truncateString(value: string): string { + return value.length > RECENT_EDIT_STRING_MAX + ? value.slice(0, RECENT_EDIT_STRING_MAX) + : value; +} + +function truncateDescriptor( + descriptor: RecentEditDescriptor, +): RecentEditDescriptor { + // The union's last member (`{ kind: string; [key: string]: unknown }`) is an + // open-ended catch-all whose `kind` is a plain `string`, so a `switch` on + // `descriptor.kind` can't discriminate it away from the literal-kind + // members for the compiler — every property still type-checks as + // `unknown`. Read/write through an untyped view instead and lean on + // runtime checks; the return value is cast back to the real type below. + const d = descriptor as Record & { kind: string }; + if (d.kind === "text" && typeof d.quote === "string") { + return { ...d, quote: truncateString(d.quote) } as RecentEditDescriptor; + } + if (d.kind === "selector" && typeof d.selector === "string") { + return { + ...d, + selector: truncateString(d.selector), + } as RecentEditDescriptor; + } + if (d.kind === "paths" && Array.isArray(d.paths)) { + return { + ...d, + paths: d.paths.map((p) => + typeof p === "string" ? truncateString(p) : p, + ), + } as RecentEditDescriptor; + } + if (d.kind === "doc") { + return descriptor; + } + // Open-ended shape — trim any string-valued fields defensively without + // knowing their names. + const trimmed: Record = {}; + for (const [key, value] of Object.entries(d)) { + trimmed[key] = typeof value === "string" ? truncateString(value) : value; + } + return trimmed as RecentEditDescriptor; +} + /** * Append an edit to a recentEdits ring, keeping the newest - * {@link RECENT_EDITS_MAX} entries. Pure — returns a new array. + * {@link RECENT_EDITS_MAX} entries. Pure — returns a new array. Descriptor + * strings and the label are truncated to {@link RECENT_EDIT_STRING_MAX} + * characters as a defensive cap on the awareness payload size. */ export function appendRecentEdit( existing: RecentEdit[] | undefined, edit: RecentEdit, ): RecentEdit[] { const ring = Array.isArray(existing) ? existing.slice() : []; - ring.push(edit); + ring.push({ + ...edit, + descriptor: truncateDescriptor(edit.descriptor), + label: edit.label ? truncateString(edit.label) : edit.label, + }); if (ring.length > RECENT_EDITS_MAX) { ring.splice(0, ring.length - RECENT_EDITS_MAX); } diff --git a/packages/core/src/index.browser.ts b/packages/core/src/index.browser.ts index ad3c0421f9..298f2b1227 100644 --- a/packages/core/src/index.browser.ts +++ b/packages/core/src/index.browser.ts @@ -10,6 +10,7 @@ export { refreshAgentChatContext, removeAgentChatContextItem, sendToAgentChat, + sendToAgentChatAndConfirm, setAgentChatContextItem, setContextToAgentChat, isEmbedMcpChatBridgeActive, diff --git a/packages/core/src/server/agent-chat-plugin.ts b/packages/core/src/server/agent-chat-plugin.ts index d17f4e7655..2b5165b244 100644 --- a/packages/core/src/server/agent-chat-plugin.ts +++ b/packages/core/src/server/agent-chat-plugin.ts @@ -8561,10 +8561,34 @@ Non-code requests are still fine on this surface: read data, navigate the UI, su // covers the initial dispatch (a connected client is polling the claim), // but a server-chained CONTINUATION handoff has no foreground watching // it: if the dispatch is lost after the successor row was inserted, the - // row would sit at dispatch_mode='background' forever and the turn hangs - // silently. This sweep reaps such rows into a loud, attributable error - // (`background_worker_never_started`) that the client renders, instead - // of an idle spinner. Cheap: one indexed-ish query per 2-min window. + // row would otherwise sit at dispatch_mode='background' forever and the + // turn hangs silently. `chainServerDrivenContinuation` (production-agent.ts) + // leaves exactly such a row behind — status='running', dispatch_mode= + // 'background', `dispatch_payload` intact — when it exhausts its own + // dispatch retry budget, instead of erroring it immediately. This sweep + // gives that row a real chance to RECOVER: within + // `UNCLAIMED_BACKGROUND_RUN_REDISPATCH_BOUND_MS` of its original + // `started_at` it redispatches the handoff (same `_process-run` + // processor, same persisted payload); past that bound it falls back to + // the existing loud reap (`background_worker_never_started`) so a + // genuinely dead handoff still fails loud, never silently. A + // redispatch is always safe to attempt — even a duplicate or + // late-arriving one — because the worker's `claimBackgroundRun` atomic + // CAS (status='running' AND dispatch_mode='background' -> 'background- + // processing') is the sole gate on actual execution; a row that was + // already claimed or already reaped by a concurrent path just loses the + // CAS and no-ops. Cheap: one indexed-ish query per 2-min window. + // + // THREE-SITE INVARIANT (keep in lockstep): this sweep only ever sees the + // deferred successor because the ~1s client poll in + // `getActiveRunForThreadAsync` (run-manager.ts) skips its own + // `reapUnclaimedBackgroundRun` while the row is within the redispatch + // bound (`shouldRedispatchUnclaimedBackgroundRun`). If a future change + // makes that client poll reap deferred successors at the 25s grace + // again, this sweep will almost never win the race for connected clients. + // Do not edit one site without the others (producer: + // chainServerDrivenContinuation in production-agent.ts; guard: + // run-manager.ts; recovery actor: here). (() => { let lastSweep = 0; const SWEEP_INTERVAL_MS = 2 * 60 * 1000; @@ -8577,22 +8601,85 @@ Non-code requests are still fine on this surface: read data, navigate the UI, su (async () => { const { - listUnclaimedBackgroundRunIds, + listUnclaimedBackgroundRunRows, reapUnclaimedBackgroundRun, + updateRunHeartbeat, + shouldRedispatchUnclaimedBackgroundRun, } = await import("../agent/run-store.js"); - let runIds: string[]; + const { resolveAgentChatProcessRunDispatchPath } = + await import("../agent/durable-background.js"); + const { fireInternalDispatch } = + await import("./self-dispatch.js"); + let rows: { id: string; startedAt: number }[]; try { - runIds = await listUnclaimedBackgroundRunIds(); + rows = await listUnclaimedBackgroundRunRows(); } catch { return; // Table may not exist yet on first boot } - for (const staleRunId of runIds) { + for (const row of rows) { try { - const reaped = await reapUnclaimedBackgroundRun(staleRunId); + if (shouldRedispatchUnclaimedBackgroundRun(row)) { + // Bump liveness BEFORE attempting the redispatch so the + // row doesn't look freshly-stale again the instant this + // tick returns — best-effort, the CAS is what actually + // matters for correctness, not this timing. + await updateRunHeartbeat(row.id).catch(() => {}); + try { + // DELIBERATE: this marker omits `continuationCount`. + // `chainServerDrivenContinuation` (production-agent.ts) + // reads `backgroundRunMarker.continuationCount` to + // compute `backgroundContinuationCount`, defaulting to 0 + // when absent — so a chunk recovered here always starts + // a fresh nested-dispatch segment at depth 0, regardless + // of how deep the chain was before this sweep picked it + // up. This is what makes the sweep a genuine CHAIN + // BREAK, not just a retry: this redispatch fires from an + // unrelated, timer-driven invocation rather than from + // inside the prior chain's own live execution, so + // starting its nested-depth count over at 0 here is + // correct — see `MAX_NESTED_SELF_DISPATCH_DEPTH` in + // production-agent.ts for why nested depth is bounded + // and how this reset keeps a long turn progressing past + // Netlify's undocumented self-invocation loop-protection + // limit instead of dying at it. Do not "fix" this by + // adding `continuationCount` back without re-reading + // that constant's doc comment. + await fireInternalDispatch({ + path: resolveAgentChatProcessRunDispatchPath(), + taskId: row.id, + body: { + internalContinuation: true, + [AGENT_CHAT_BACKGROUND_RUN_FIELD]: { + runId: row.id, + payloadRef: true, + }, + }, + awaitResponse: true, + responseTimeoutMs: 15_000, + }); + console.error( + "[agent-chat] redispatched unclaimed background run (handoff recovery):", + row.id, + ); + } catch (redispatchErr) { + console.error( + "[agent-chat] unclaimed background run redispatch attempt failed (retrying until the redispatch bound, then reaping):", + row.id, + redispatchErr instanceof Error + ? redispatchErr.message + : redispatchErr, + ); + } + continue; + } + // Redispatch bound exceeded — this handoff is not + // recovering. Fall back to the pre-existing loud reap so + // the turn fails loud instead of retrying forever. + const reaped = await reapUnclaimedBackgroundRun(row.id); if (reaped) { console.error( - "[agent-chat] swept unclaimed background run (handoff lost):", - staleRunId, + "[agent-chat] swept unclaimed background run (handoff lost, redispatch bound exceeded):", + row.id, ); } } catch { diff --git a/packages/core/src/server/builder-design-systems.spec.ts b/packages/core/src/server/builder-design-systems.spec.ts index 57f856f7b5..6d3226f575 100644 --- a/packages/core/src/server/builder-design-systems.spec.ts +++ b/packages/core/src/server/builder-design-systems.spec.ts @@ -53,6 +53,41 @@ describe("Builder design-system helpers", () => { expect(files.map((file) => file.name)).toEqual(["ok.css", "also-ok.css"]); }); + it("base64-decodes a binary .fig file instead of UTF-8-mangling it (regression: .fig upload silently corrupted binary bytes)", () => { + // A real .fig is a zip container -- PK\x03\x04 magic, per the fig-writer + // spike's own README -- and its bytes are NOT valid UTF-8 (many bytes + // are >= 0x80 with no valid continuation sequence). Round-tripping + // arbitrary binary through TextEncoder().encode() (the old + // default-and-only path) corrupts it; through base64 + Buffer.from it + // must come back byte-identical. + const binaryBytes = new Uint8Array([ + 0x50, 0x4b, 0x03, 0x04, 0x00, 0xff, 0x80, 0x81, 0xfe, 0x7f, 0x10, 0x20, + ]); + const base64Content = Buffer.from(binaryBytes).toString("base64"); + + const files = buildBuilderDesignSystemIndexFiles({ + codeFiles: [ + { + filename: "spike-output.fig", + content: base64Content, + encoding: "base64", + }, + ], + }); + + expect(files).toHaveLength(1); + expect(files[0].name).toBe("spike-output.fig"); + expect(files[0].mimeType).toBe("application/octet-stream"); + expect(Array.from(files[0].data)).toEqual(Array.from(binaryBytes)); + }); + + it("still treats codeFiles as UTF-8 text by default when encoding is omitted (no behavior change for existing text callers)", () => { + const files = buildBuilderDesignSystemIndexFiles({ + codeFiles: [{ filename: "tokens.css", content: ":root{--x:1}" }], + }); + expect(new TextDecoder().decode(files[0].data)).toBe(":root{--x:1}"); + }); + it("creates a local proxy that preserves the Builder DSI reference", () => { const fields = createBuilderDesignSystemProxyFields({ result: { diff --git a/packages/core/src/server/builder-design-systems.ts b/packages/core/src/server/builder-design-systems.ts index 3fc01af452..eeb3bcc943 100644 --- a/packages/core/src/server/builder-design-systems.ts +++ b/packages/core/src/server/builder-design-systems.ts @@ -16,6 +16,21 @@ export interface BuilderDesignSystemCodeFileInput { filename: string; content: string; mimeType?: string; + /** + * How `content` is encoded. Defaults to `"utf8"` (existing behavior, + * unchanged for every current text-file caller). Pass `"base64"` for + * binary files -- most importantly `.fig` (a zip/kiwi binary container, + * never valid UTF-8 text). Without this, a `.fig` upload silently + * corrupts: `mimeTypeForBuilderDesignSystemFilename` already special-cases + * `.fig` as `application/octet-stream`, but the actual byte pipeline ran + * every file through `TextEncoder().encode()` regardless, which mangles + * any byte >= 0x80 in a binary-as-string payload (or, if the caller + * base64-encoded first with no decode step here, stores the literal + * base64 text instead of the decoded binary). Callers sending `.fig`/PDF/ + * other binary bytes must base64-encode `content` and set this to + * `"base64"`. + */ + encoding?: "utf8" | "base64"; } export interface BuildBuilderDesignSystemIndexFilesOptions { @@ -184,9 +199,20 @@ export function buildBuilderDesignSystemIndexFiles({ const files: BuilderDesignSystemIndexFile[] = []; let totalBytes = 0; - function pushTextFile(filename: string, content: string, mimeType?: string) { + function pushFile( + filename: string, + content: string, + mimeType?: string, + encoding?: "utf8" | "base64", + ) { const normalizedName = filename.replace(/^\/+/, "") || "code.txt"; - const data = encoder.encode(content); + // `.fig`/PDF/other binary payloads must round-trip through base64, not + // UTF-8 -- TextEncoder().encode() on a binary-as-string payload mangles + // any byte >= 0x80. See BuilderDesignSystemCodeFileInput.encoding. + const data = + encoding === "base64" + ? new Uint8Array(Buffer.from(content, "base64")) + : encoder.encode(content); if (data.byteLength === 0) return; if (totalBytes + data.byteLength > maxTotalCodeBytes) return; totalBytes += data.byteLength; @@ -201,7 +227,7 @@ export function buildBuilderDesignSystemIndexFiles({ } if (designMd?.trim()) { - pushTextFile( + pushFile( designMdFilename?.trim() || "design.md", designMd, "text/markdown", @@ -209,7 +235,7 @@ export function buildBuilderDesignSystemIndexFiles({ } for (const file of (codeFiles ?? []).slice(0, maxCodeFiles)) { - pushTextFile(file.filename, file.content, file.mimeType); + pushFile(file.filename, file.content, file.mimeType, file.encoding); } return files; diff --git a/packages/core/src/server/ssr-handler.spec.ts b/packages/core/src/server/ssr-handler.spec.ts index 572cf0baee..82fdce6c72 100644 --- a/packages/core/src/server/ssr-handler.spec.ts +++ b/packages/core/src/server/ssr-handler.spec.ts @@ -9,6 +9,7 @@ import { AGENT_NATIVE_SOCIAL_IMAGE_CACHE_BUSTER, AGENT_NATIVE_SOCIAL_IMAGE_PATH, } from "../shared/social-meta.js"; +import { registerErrorCaptureProvider } from "./capture-error.js"; import { getRequestUserEmail } from "./request-context.js"; import { createH3SSRHandler, @@ -100,6 +101,37 @@ describe("createH3SSRHandler", () => { expect(mocks.requestHandler).toHaveBeenCalledTimes(1); }); + it("captures SSR exceptions with request context before returning a safe 500", async () => { + const error = new Error("render failed"); + const provider = vi.fn(); + const unregister = registerErrorCaptureProvider( + "ssr-handler-test", + provider, + ); + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + mocks.requestHandler.mockImplementationOnce(async () => { + throw error; + }); + const handler = createH3SSRHandler(() => ({})) as any; + + try { + const response = await handler(createEvent("/recaps/recap_test")); + + expect(response.status).toBe(500); + expect(provider).toHaveBeenCalledWith(error, { + route: "/recaps/recap_test", + method: "GET", + userAgent: undefined, + tags: { renderMode: "anonymous-public", surface: "ssr" }, + }); + } finally { + consoleError.mockRestore(); + unregister(); + } + }); + it("strips APP_BASE_PATH from React Router lazy route manifest paths", async () => { process.env.APP_BASE_PATH = "/dispatch"; const handler = createH3SSRHandler(() => ({})) as any; diff --git a/packages/core/src/server/ssr-handler.ts b/packages/core/src/server/ssr-handler.ts index 251392cc23..4c42122bd2 100644 --- a/packages/core/src/server/ssr-handler.ts +++ b/packages/core/src/server/ssr-handler.ts @@ -34,6 +34,7 @@ import { getAppBasePathFromViteEnv, stripAppBasePath as canonicalStripAppBasePath, } from "./app-base-path.js"; +import { captureError } from "./capture-error.js"; import { runWithRequestContext } from "./request-context.js"; import { getSentryClientConfigScript } from "./sentry-config.js"; @@ -391,6 +392,12 @@ export function createH3SSRHandler(getBuild: () => Promise | unknown) { // that aid reconnaissance attacks. In dev we surface the message text // so devtools shows something useful; in prod we return a bare 500. console.error("[ssr-handler] SSR error:", err); + captureError(err, { + route: p, + method: event.req.method, + userAgent: event.req.headers.get("user-agent") ?? undefined, + tags: { renderMode: "anonymous-public", surface: "ssr" }, + }); const isProd = process.env.NODE_ENV === "production"; const body = isProd ? "Internal Server Error" diff --git a/scripts/i18n-raw-literal-baseline.txt b/scripts/i18n-raw-literal-baseline.txt index 632ffe48a8..422ab84a85 100644 --- a/scripts/i18n-raw-literal-baseline.txt +++ b/scripts/i18n-raw-literal-baseline.txt @@ -9,6 +9,7 @@ templates/clips/app/components/workspace/notifications-list.tsx|["formatDate"], templates/design/app/components/design/DesignCanvas.tsx|handle ? [ templates/design/app/components/design/FusionAppBanner.tsx|Publish failed templates/design/app/components/design/FusionAppBanner.tsx|Push failed +templates/design/app/components/design/bridge/editor-chrome.bridge.ts|parentRect.right || clientY templates/design/app/components/design/code-workbench/commands.ts|Command failed templates/design/app/components/design/code-workbench/commands.ts|Could not save file templates/design/app/components/design/code-workbench/editor/EditorTabs.tsx|Could not save file @@ -20,6 +21,7 @@ templates/design/app/components/design/code-workbench/search/search-engine.ts|In templates/design/app/components/design/code-workbench/store.tsx|Could not read file templates/design/app/components/design/code-workbench/workspace/localhost-provider.ts|File changed on disk since it was last read templates/design/app/components/design/code-workbench/workspace/types.ts|` or `localhost: +templates/design/app/components/design/edit-panel/effects-properties.tsx|, elementKey: string, nextLayers: readonly Pick templates/design/app/components/design/multi-screen/screen-content-cache.ts|, existingScreenIds: ReadonlySet templates/design/app/components/design/multi-screen/screen-content-cache.ts|, liveScreenIds: ReadonlySet templates/design/app/pages/design-editor/pending-edits.ts|, primaryInfo?: Pick diff --git a/skills/visual-edit/SKILL.md b/skills/visual-edit/SKILL.md index 79437cb8f0..9f2875e166 100644 --- a/skills/visual-edit/SKILL.md +++ b/skills/visual-edit/SKILL.md @@ -205,6 +205,20 @@ replace them with copied `srcdoc` HTML unless the user explicitly asks for a frozen snapshot. To change a state, rerun `add-localhost-screens` with the new path/query or duplicate the screen and update the copy's URL metadata. +## React Source Writeback + +- Use compiler/debug provenance (project-relative file, line, column, + component, and runtime multiplicity) to locate React/TSX source. Treat it as + evidence, not as permission for a generic AST structural transform. +- Reparenting, grouping/ungrouping, wrappers, dynamic expressions, repeated + `.map()` instances, shared components, and cross-file changes go through the + coding agent with exact subject/target anchors and their runtime + relationship. +- Before each write, read the file and pass its exact `versionHash` to + `write-local-file` with `requireExpectedVersionHash: true`; on conflict, + re-read and re-plan. Keep the optimistic preview until HMR/runtime confirms + the result. Human write consent remains mandatory and agents cannot grant it. + ## Verification - `list-localhost-connections` returns the expected connection and routes. diff --git a/templates/clips/.agents/skills/ai-video-tools/SKILL.md b/templates/clips/.agents/skills/ai-video-tools/SKILL.md index 1423ea415e..dd30c013c0 100644 --- a/templates/clips/.agents/skills/ai-video-tools/SKILL.md +++ b/templates/clips/.agents/skills/ai-video-tools/SKILL.md @@ -126,7 +126,7 @@ Transcription takes an audio file and returns text + segments. That's not a prom **Provider priority:** 1. **Native (highest priority).** The browser's Web Speech API, desktop local Whisper/macOS SFSpeech when available, and desktop Web Speech fallback on non-mac run during recording and save results via `save-browser-transcript`. This gives an instant transcript with no API key when the local/browser recognizer is available. When `request-transcript` runs afterward, it preserves the ready native transcript and only falls back to a cloud provider if native text is missing. -2. **Cloud fallback — Builder.io managed (Gemini).** When a Builder account is connected (`BUILDER_PRIVATE_KEY` or per-user OAuth, no separate API key needed), `request-transcript` calls `transcribeWithBuilder()`, which routes audio to Builder.io's managed Gemini transcription. Returns text plus timestamped segments. +2. **Cloud fallback — Builder.io managed (Gemini).** When a Builder account is connected (`BUILDER_PRIVATE_KEY` or per-user OAuth, no separate API key needed), `request-transcript` calls `transcribeWithBuilder()`, which routes audio to Builder.io's managed Gemini transcription. Returns text plus timestamped segments. Retry a failed transcript with `force: true`; pass `regenerate: true` to replace an existing ready transcript from the stored recording media. Regeneration keeps the prior ready transcript available if the new provider attempt fails. 3. **Cloud fallback — Groq Whisper.** `GROQ_API_KEY` → `https://api.groq.com/openai/v1/audio/transcriptions`, model `whisper-large-v3-turbo`. Fast (~$0.04/hour of audio) Whisper-compatible speech-to-text used when Builder is unavailable. Clips never routes recording/meeting audio to OpenAI for transcription. (Groq's endpoint is OpenAI-_compatible_ in request shape only — the audio goes to Groq, not OpenAI.) diff --git a/templates/clips/.agents/skills/video-sharing/SKILL.md b/templates/clips/.agents/skills/video-sharing/SKILL.md index 63e9eedb44..861625a988 100644 --- a/templates/clips/.agents/skills/video-sharing/SKILL.md +++ b/templates/clips/.agents/skills/video-sharing/SKILL.md @@ -14,6 +14,12 @@ description: >- Recording sharing uses the framework `sharing` system — not a custom share table. Recordings are registered via `registerShareableResource({ type: "recording", ... })` in `server/db/index.ts`. The `share-resource`, `unshare-resource`, `list-resource-shares`, and `set-resource-visibility` actions are auto-mounted and handle per-user grants, per-org grants, and the three visibility levels (`private` / `org` / `public`). +Unlike the framework-wide private default, normal Clips recordings and uploaded +videos default to **public** so their copied share links work immediately. +Embedded bug-report recordings are the exception and default to organization +visibility. Callers can still explicitly create a private or organization-only +recording, and owners/admins can change visibility from the Share dialog. + Clips **adds two things** on top of the framework system: 1. **Password** — an optional bcrypt'd string on the `recordings` row. When set, all non-owner viewers must enter it to play the recording. @@ -62,7 +68,7 @@ import { ShareDialog } from "@agent-native/core/client"; } embedTabContent={} -/> +/>; ``` - `shareUrl` / `embedUrl` — the copy-link and embed URLs the framework renders in its tabs. @@ -163,11 +169,11 @@ Required Slack app setup: Recordings can expose URLs meant for external agents without handing over raw video bytes: -| Endpoint | Meaning | -| -------- | ------- | -| `/api/agent-context.json?id=` | Clip metadata, transcript summary, recommended frames, and API discovery links | -| `/api/agent-transcript.json?id=` | Timestamped transcript segments with `startMs`, `endMs`, `timestamp`, `range`, `text`, and optional `source` | -| `/api/agent-frame.jpg?id=&atMs=` | JPEG frame extracted from the video at the requested original-video timestamp | +| Endpoint | Meaning | +| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| `/api/agent-context.json?id=` | Clip metadata, transcript summary, recommended frames, and API discovery links | +| `/api/agent-transcript.json?id=` | Timestamped transcript segments with `startMs`, `endMs`, `timestamp`, `range`, `text`, and optional `source` | +| `/api/agent-frame.jpg?id=&atMs=` | JPEG frame extracted from the video at the requested original-video timestamp | These endpoints follow the same access model as `/api/public-recording`, plus a temporary agent-link path: diff --git a/templates/clips/AGENTS.md b/templates/clips/AGENTS.md index 26087b0831..273dbc82b9 100644 --- a/templates/clips/AGENTS.md +++ b/templates/clips/AGENTS.md @@ -34,6 +34,10 @@ Detailed media, meeting, dictation, editing, and sharing rules live in download the original from Loom and use "Upload video". - Native transcript first. Cleanup and title generation can run in the background; do not hide a usable native transcript behind a failed cleanup. +- Use `request-transcript --recordingId= --force=true` to retry a failed + transcript. Pass `--regenerate=true` to replace an existing ready transcript + from the stored recording media; if regeneration fails, keep the prior ready + transcript available. - Dictation cleanup, Clip title/cleanup, and meeting summaries should pass bounded `voiceContext` to the shared cleanup/transcription path when active app context, learned vocabulary, user notes, or AGENTS.md preferences are diff --git a/templates/clips/actions/create-recording.test.ts b/templates/clips/actions/create-recording.test.ts index 98877d1830..ad1b0a74db 100644 --- a/templates/clips/actions/create-recording.test.ts +++ b/templates/clips/actions/create-recording.test.ts @@ -3,6 +3,15 @@ import { describe, expect, it } from "vitest"; import { createRecordingSchema } from "./lib/create-recording-schema"; describe("create-recording schema", () => { + it("defaults new recordings to public visibility", () => { + const parsed = createRecordingSchema.parse({ + title: "Uploaded demo", + titleSource: "upload", + }); + + expect(parsed.visibility).toBe("public"); + }); + it("does not require spaceIds for recorder clients", () => { const parsed = createRecordingSchema.safeParse({ title: "Screen recording - 12 May 2026", diff --git a/templates/clips/actions/create-recording.ts b/templates/clips/actions/create-recording.ts index ccef44ddac..bb911f2c03 100644 --- a/templates/clips/actions/create-recording.ts +++ b/templates/clips/actions/create-recording.ts @@ -24,6 +24,7 @@ import { } from "../server/lib/recordings.js"; import { setResumableSession } from "../server/lib/resumable-session.js"; import { shouldEnableStreamingUpload } from "../server/lib/streaming-upload-mode.js"; +import { allowsSqlRecordingChunkScratch } from "../server/lib/video-storage.js"; import { createRecordingSchema } from "./lib/create-recording-schema.js"; import { DEFAULT_RECORDING_TITLE } from "./lib/title-source.js"; @@ -92,6 +93,7 @@ export default defineAction({ shouldEnableStreamingUpload({ client: args.streamingUploadClient, mimeType: args.mimeType, + bufferedFallbackAvailable: allowsSqlRecordingChunkScratch(), }) && uploadProvider?.resumable ) { diff --git a/templates/clips/actions/lib/create-recording-schema.ts b/templates/clips/actions/lib/create-recording-schema.ts index 8f9792d4d2..e4e46a5fa6 100644 --- a/templates/clips/actions/lib/create-recording-schema.ts +++ b/templates/clips/actions/lib/create-recording-schema.ts @@ -62,8 +62,10 @@ export const createRecordingSchema = z.object({ .describe("Height of the recording in pixels (may be 0 until finalized)"), visibility: z .enum(["private", "org", "public"]) - .optional() - .describe("Initial share visibility for the recording"), + .default("public") + .describe( + "Initial share visibility for the recording (defaults to public)", + ), mimeType: z .string() .optional() @@ -74,7 +76,7 @@ export const createRecordingSchema = z.object({ .boolean() .optional() .describe( - "Request the resumable streaming upload path. Deployments must also set CLIPS_ENABLE_STREAMING_UPLOAD; otherwise recordings use the buffered fallback.", + "Request the resumable streaming upload path. Hosted deployments use it automatically because SQL chunk buffering is unavailable; local deployments can opt in with CLIPS_ENABLE_STREAMING_UPLOAD.", ), streamingUploadClient: z .enum(["desktop-native"]) diff --git a/templates/clips/actions/request-transcript.test.ts b/templates/clips/actions/request-transcript.test.ts index 6d10b4e427..544e8bd3cd 100644 --- a/templates/clips/actions/request-transcript.test.ts +++ b/templates/clips/actions/request-transcript.test.ts @@ -31,6 +31,11 @@ const mockExportToBrainRun = vi.hoisted(() => vi.fn()); const mockCleanupTranscriptRun = vi.hoisted(() => vi.fn()); const mockRegenerateTitleRun = vi.hoisted(() => vi.fn()); const mockQueueTitleRegenerationRequest = vi.hoisted(() => vi.fn()); +const mockResolveHasBuilderPrivateKey = vi.hoisted(() => vi.fn()); +const mockTranscribeWithBuilder = vi.hoisted(() => vi.fn()); +const mockSsrfSafeFetch = vi.hoisted(() => vi.fn()); +const mockPrepareAudioOnlyTranscriptionMedia = vi.hoisted(() => vi.fn()); +const mockAssertAccess = vi.hoisted(() => vi.fn()); vi.mock("@agent-native/core", () => ({ defineAction: (options: unknown) => options, @@ -51,11 +56,11 @@ vi.mock("@agent-native/core/credentials", () => ({ })); vi.mock("@agent-native/core/extensions/url-safety", () => ({ - ssrfSafeFetch: vi.fn(), + ssrfSafeFetch: (...args: unknown[]) => mockSsrfSafeFetch(...args), })); vi.mock("@agent-native/core/secrets", () => ({ - readAppSecret: vi.fn(), + readAppSecret: vi.fn(async () => null), })); vi.mock("@agent-native/core/server/request-context", () => ({ @@ -64,11 +69,17 @@ vi.mock("@agent-native/core/server/request-context", () => ({ })); vi.mock("@agent-native/core/server", () => ({ - resolveHasBuilderPrivateKey: vi.fn(async () => false), + resolveHasBuilderPrivateKey: (...args: unknown[]) => + mockResolveHasBuilderPrivateKey(...args), })); vi.mock("@agent-native/core/transcription/builder", () => ({ - transcribeWithBuilder: vi.fn(), + transcribeWithBuilder: (...args: unknown[]) => + mockTranscribeWithBuilder(...args), +})); + +vi.mock("@agent-native/core/sharing", () => ({ + assertAccess: (...args: unknown[]) => mockAssertAccess(...args), })); vi.mock("drizzle-orm", () => ({ @@ -87,6 +98,7 @@ vi.mock("../server/db/index.js", () => ({ durationMs: "recordings.durationMs", videoUrl: "recordings.videoUrl", videoFormat: "recordings.videoFormat", + videoSizeBytes: "recordings.videoSizeBytes", hasAudio: "recordings.hasAudio", sourceAppName: "recordings.sourceAppName", sourceWindowTitle: "recordings.sourceWindowTitle", @@ -98,6 +110,7 @@ vi.mock("../server/db/index.js", () => ({ segmentsJson: "recordingTranscripts.segmentsJson", updatedAt: "recordingTranscripts.updatedAt", language: "recordingTranscripts.language", + retryCount: "recordingTranscripts.retryCount", }, }, })); @@ -133,7 +146,9 @@ vi.mock("./lib/audio-only-transcription.js", () => ({ AudioOnlyExtractionError: class AudioOnlyExtractionError extends Error {}, assertAudioHasAudibleSignal: vi.fn(), isNoExtractableAudioError: vi.fn(() => false), - prepareAudioOnlyTranscriptionMedia: vi.fn(), + isTransientExtractionError: vi.fn(() => false), + prepareAudioOnlyTranscriptionMedia: (...args: unknown[]) => + mockPrepareAudioOnlyTranscriptionMedia(...args), })); vi.mock("./lib/loom-transcript.js", () => ({ @@ -144,7 +159,9 @@ vi.mock("./lib/loom-transcript.js", () => ({ import { builderTranscriptionTimeoutMs, importLoomTranscriptForRecording, + recordingMediaFetchTimeoutMs, } from "./request-transcript"; +import requestTranscript from "./request-transcript"; const existingSegments = JSON.stringify([ { startMs: 0, endMs: 1200, text: "Saved transcript." }, @@ -173,6 +190,158 @@ describe("builderTranscriptionTimeoutMs", () => { }); }); +describe("recordingMediaFetchTimeoutMs", () => { + it("gives long recordings enough time to download before extraction", () => { + expect(recordingMediaFetchTimeoutMs(null, null)).toBe(45_000); + expect(recordingMediaFetchTimeoutMs(250 * 1024 * 1024, null)).toBe(80_000); + expect(recordingMediaFetchTimeoutMs(null, 55 * 60_000)).toBe(90_000); + }); + + it("allows a bounded operator override", () => { + vi.stubEnv("CLIPS_TRANSCRIPTION_MEDIA_FETCH_TIMEOUT_MS", "100000"); + expect(recordingMediaFetchTimeoutMs(null, null)).toBe(100_000); + + vi.stubEnv("CLIPS_TRANSCRIPTION_MEDIA_FETCH_TIMEOUT_MS", "300000"); + expect(recordingMediaFetchTimeoutMs(null, null)).toBe(120_000); + }); +}); + +describe("requestTranscript regeneration", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockSelectRows.queue = []; + mockResolveHasBuilderPrivateKey.mockResolvedValue(true); + mockAssertAccess.mockResolvedValue({ role: "editor" }); + mockSsrfSafeFetch.mockResolvedValue( + new Response(new Blob(["recording"], { type: "video/webm" })), + ); + mockPrepareAudioOnlyTranscriptionMedia.mockResolvedValue({ + audioBytes: new Uint8Array([1, 2, 3]), + mimeType: "audio/webm", + filename: "recording.webm", + }); + mockTranscribeWithBuilder.mockResolvedValue({ + text: "Fresh transcript.", + language: "en", + segments: [{ startMs: 0, endMs: 1200, text: "Fresh transcript." }], + }); + mockExportToBrainRun.mockResolvedValue({ status: "skipped" }); + }); + + it("replaces a ready transcript when regeneration is explicitly requested", async () => { + mockSelectRows.queue = [ + [ + { + status: "ready", + fullText: "Old transcript.", + segmentsJson: JSON.stringify([ + { startMs: 0, endMs: 1200, text: "Old transcript." }, + ]), + updatedAt: "2026-07-09T00:00:00.000Z", + language: "en", + retryCount: 0, + }, + ], + [ + { + videoUrl: "https://cdn.example.com/recording.webm", + videoFormat: "webm", + hasAudio: true, + durationMs: 1200, + title: "Human title", + }, + ], + [{ recordingId: "rec_ready" }], + [{ title: "Human title", titleSource: "manual" }], + ]; + + const result = await requestTranscript.run({ + recordingId: "rec_ready", + force: true, + regenerate: true, + }); + + expect(mockAssertAccess).toHaveBeenCalledWith( + "recording", + "rec_ready", + "editor", + ); + expect(result).toMatchObject({ + recordingId: "rec_ready", + status: "ready", + provider: "builder", + }); + expect(mockUpdateSet).toHaveBeenCalledWith( + expect.objectContaining({ + status: "ready", + fullText: "Fresh transcript.", + failureReason: null, + }), + ); + expect( + mockUpdateSet.mock.calls.some( + ([patch]) => (patch as { status?: string }).status === "pending", + ), + ).toBe(false); + }); + + it("keeps the ready transcript when regeneration fails", async () => { + mockTranscribeWithBuilder.mockRejectedValue( + new Error("Builder transcription failed (503 Service Unavailable)"), + ); + mockSelectRows.queue = [ + [ + { + status: "ready", + fullText: "Saved transcript.", + segmentsJson: existingSegments, + updatedAt: "2026-07-09T00:00:00.000Z", + language: "en", + retryCount: 0, + }, + ], + [ + { + videoUrl: "https://cdn.example.com/recording.webm", + videoFormat: "webm", + hasAudio: true, + durationMs: 1200, + title: "Human title", + }, + ], + [ + { + status: "ready", + fullText: "Saved transcript.", + segmentsJson: existingSegments, + language: "en", + }, + ], + [ + { + title: "Human title", + titleSource: "manual", + durationMs: 1200, + }, + ], + ]; + + const result = await requestTranscript.run({ + recordingId: "rec_ready", + force: true, + regenerate: true, + }); + + expect(result).toMatchObject({ + recordingId: "rec_ready", + status: "ready", + provider: "existing", + preserved: true, + }); + expect(mockUpdateSet).not.toHaveBeenCalled(); + }); +}); + describe("importLoomTranscriptForRecording", () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/templates/clips/actions/request-transcript.ts b/templates/clips/actions/request-transcript.ts index b137f5dcae..c3e5481451 100644 --- a/templates/clips/actions/request-transcript.ts +++ b/templates/clips/actions/request-transcript.ts @@ -45,6 +45,7 @@ import { getCredentialContext, } from "@agent-native/core/server/request-context"; import { getSetting, getUserSetting } from "@agent-native/core/settings"; +import { assertAccess } from "@agent-native/core/sharing"; import { transcribeWithBuilder } from "@agent-native/core/transcription/builder"; import { and, eq } from "drizzle-orm"; import { z } from "zod"; @@ -110,6 +111,7 @@ type TranscriptionProvider = { type RecordingMediaRow = { videoUrl: string | null; videoFormat?: "webm" | "mp4" | null; + videoSizeBytes?: number | null; hasAudio?: boolean | null; sourceAppName?: string | null; sourceWindowTitle?: string | null; @@ -127,6 +129,11 @@ const BUILDER_TRANSCRIPTION_MIN_TIMEOUT_MS = 45_000; const BUILDER_TRANSCRIPTION_MAX_TIMEOUT_MS = 65_000; const BUILDER_TRANSCRIPTION_BASE_TIMEOUT_MS = 30_000; const BUILDER_TRANSCRIPTION_PER_MINUTE_MS = 3_000; +const MEDIA_FETCH_MIN_TIMEOUT_MS = 45_000; +const MEDIA_FETCH_MAX_TIMEOUT_MS = 120_000; +const MEDIA_FETCH_BASE_TIMEOUT_MS = 30_000; +const MEDIA_FETCH_PER_50MB_MS = 10_000; +const ESTIMATED_VIDEO_BYTES_PER_MINUTE = 5 * 1024 * 1024; function clampTimeoutMs(value: number): number { return Math.max( @@ -154,6 +161,38 @@ export function builderTranscriptionTimeoutMs( ); } +export function recordingMediaFetchTimeoutMs( + videoSizeBytes: number | null | undefined, + durationMs: number | null | undefined, +): number { + const override = Number( + process.env.CLIPS_TRANSCRIPTION_MEDIA_FETCH_TIMEOUT_MS, + ); + if (Number.isFinite(override) && override > 0) { + return Math.max( + MEDIA_FETCH_MIN_TIMEOUT_MS, + Math.min(MEDIA_FETCH_MAX_TIMEOUT_MS, Math.floor(override)), + ); + } + + const estimatedBytes = + videoSizeBytes && Number.isFinite(videoSizeBytes) && videoSizeBytes > 0 + ? videoSizeBytes + : durationMs && Number.isFinite(durationMs) && durationMs > 0 + ? Math.ceil(durationMs / 60_000) * ESTIMATED_VIDEO_BYTES_PER_MINUTE + : 0; + if (!estimatedBytes) return MEDIA_FETCH_MIN_TIMEOUT_MS; + + const fiftyMbUnits = Math.ceil(estimatedBytes / (50 * 1024 * 1024)); + return Math.max( + MEDIA_FETCH_MIN_TIMEOUT_MS, + Math.min( + MEDIA_FETCH_MAX_TIMEOUT_MS, + MEDIA_FETCH_BASE_TIMEOUT_MS + fiftyMbUnits * MEDIA_FETCH_PER_50MB_MS, + ), + ); +} + // Bounded automatic retry for transient failures (ffmpeg timeout, transient // provider network/5xx errors) — NOT for permanent failures like "no audio // track" or a missing/rejected API key. Mirrors the fire-and-forget @@ -301,10 +340,12 @@ async function loadRecordingMediaBlob({ recordingId, videoUrl, fallbackMimeType, + timeoutMs, }: { recordingId: string; videoUrl: string; fallbackMimeType: string; + timeoutMs: number; }): Promise<{ blob: Blob; sourceMimeType: string }> { const isLocalBlob = videoUrl.startsWith("/api/video/") || @@ -334,10 +375,10 @@ async function loadRecordingMediaBlob({ resolvedVideoUrl = `${origin}${resolvedVideoUrl}`; } const vidRes = isAppRelativeUrl - ? await fetch(resolvedVideoUrl, { signal: AbortSignal.timeout(30_000) }) + ? await fetch(resolvedVideoUrl, { signal: AbortSignal.timeout(timeoutMs) }) : await ssrfSafeFetch( resolvedVideoUrl, - { signal: AbortSignal.timeout(30_000) }, + { signal: AbortSignal.timeout(timeoutMs) }, { maxRedirects: 3 }, ); if (!vidRes.ok) { @@ -899,7 +940,7 @@ async function pickProvider( const requestTranscriptAction = defineAction({ description: - "Ensure a recording has a transcript. Preserves native Web Speech/macOS Speech transcripts first, then uses configured backup transcription only when needed.", + "Ensure a recording has a transcript, or explicitly regenerate it from the recording media. Preserves native Web Speech/macOS Speech transcripts unless regenerate is true, then uses Builder.io managed transcription or the configured Groq fallback.", schema: z.object({ recordingId: z.string().describe("Recording ID"), force: z @@ -908,6 +949,12 @@ const requestTranscriptAction = defineAction({ .describe( "Bypass the recent pending guard for explicit retries or the finalize-recording background worker.", ), + regenerate: z + .boolean() + .optional() + .describe( + "Generate a fresh transcript from the recording media even when a ready transcript already exists. The existing ready transcript is kept if regeneration fails.", + ), retryAttempt: z .number() .int() @@ -918,6 +965,8 @@ const requestTranscriptAction = defineAction({ ), }), run: async (args) => { + await assertAccess("recording", args.recordingId, "editor"); + const db = getDb(); const ownerEmail = getCurrentOwnerEmail(); const now = new Date().toISOString(); @@ -944,6 +993,10 @@ const requestTranscriptAction = defineAction({ recordingId: args.recordingId, videoUrl, fallbackMimeType, + timeoutMs: recordingMediaFetchTimeoutMs( + rec.videoSizeBytes, + rec.durationMs, + ), }); return prepareAudioOnlyTranscriptionMedia({ blob: media.blob, @@ -980,6 +1033,11 @@ const requestTranscriptAction = defineAction({ // manual retry can still top the budget back up for one more bounded // automatic pass if it fails transiently again. const currentRetryCount = existingNativeTranscript?.retryCount ?? 0; + const regeneratingReadyTranscript = Boolean( + args.regenerate && + existingNativeTranscript?.status === "ready" && + existingNativeTranscript.fullText?.trim(), + ); if (args.retryAttempt !== undefined) { console.log( `[clips] auto-retry transcription attempt ${args.retryAttempt} for ${args.recordingId}`, @@ -987,6 +1045,7 @@ const requestTranscriptAction = defineAction({ } if ( + !args.regenerate && existingNativeTranscript?.status === "ready" && existingNativeTranscript.fullText?.trim() ) { @@ -1032,19 +1091,22 @@ const requestTranscriptAction = defineAction({ // is set at the deployment level. Use the per-user-aware resolver so // a sidebar OAuth connection actually wires through to transcription. if (await resolveHasBuilderPrivateKey()) { - await upsertTranscriptRow(db, { - recordingId: args.recordingId, - ownerEmail, - status: "pending", - failureReason: null, - now, - }); - await writeAppState("refresh-signal", { ts: Date.now() }); + if (!regeneratingReadyTranscript) { + await upsertTranscriptRow(db, { + recordingId: args.recordingId, + ownerEmail, + status: "pending", + failureReason: null, + now, + }); + await writeAppState("refresh-signal", { ts: Date.now() }); + } const [rec] = await db .select({ videoUrl: schema.recordings.videoUrl, videoFormat: schema.recordings.videoFormat, + videoSizeBytes: schema.recordings.videoSizeBytes, hasAudio: schema.recordings.hasAudio, sourceAppName: schema.recordings.sourceAppName, sourceWindowTitle: schema.recordings.sourceWindowTitle, @@ -1121,13 +1183,15 @@ const requestTranscriptAction = defineAction({ ); const fullText = normalizedTranscript.fullText; - const preserved = await preserveReadyTranscriptIfAvailable({ - db, - recordingId: args.recordingId, - ownerEmail, - allowLikelyLanguageMismatch: false, - }); - if (preserved) return preserved; + if (!regeneratingReadyTranscript) { + const preserved = await preserveReadyTranscriptIfAvailable({ + db, + recordingId: args.recordingId, + ownerEmail, + allowLikelyLanguageMismatch: false, + }); + if (preserved) return preserved; + } if (!fullText) { return failEmptyProviderTranscript({ @@ -1251,15 +1315,17 @@ const requestTranscriptAction = defineAction({ } // Upsert a pending row so the UI can show "Transcribing…". - await upsertTranscriptRow(db, { - recordingId: args.recordingId, - ownerEmail, - status: "pending", - failureReason: null, - now, - }); + if (!regeneratingReadyTranscript) { + await upsertTranscriptRow(db, { + recordingId: args.recordingId, + ownerEmail, + status: "pending", + failureReason: null, + now, + }); - await writeAppState("refresh-signal", { ts: Date.now() }); + await writeAppState("refresh-signal", { ts: Date.now() }); + } // Load the recording's media URL and prepare audio-only bytes. We never // send video frames to a transcription provider; screen-only recordings @@ -1269,6 +1335,7 @@ const requestTranscriptAction = defineAction({ .select({ videoUrl: schema.recordings.videoUrl, videoFormat: schema.recordings.videoFormat, + videoSizeBytes: schema.recordings.videoSizeBytes, hasAudio: schema.recordings.hasAudio, sourceAppName: schema.recordings.sourceAppName, sourceWindowTitle: schema.recordings.sourceWindowTitle, @@ -1369,13 +1436,15 @@ const requestTranscriptAction = defineAction({ ); const fullText = normalizedTranscript.fullText; - const preserved = await preserveReadyTranscriptIfAvailable({ - db, - recordingId: args.recordingId, - ownerEmail, - allowLikelyLanguageMismatch: false, - }); - if (preserved) return preserved; + if (!regeneratingReadyTranscript) { + const preserved = await preserveReadyTranscriptIfAvailable({ + db, + recordingId: args.recordingId, + ownerEmail, + allowLikelyLanguageMismatch: false, + }); + if (preserved) return preserved; + } if (!fullText) { return failEmptyProviderTranscript({ diff --git a/templates/clips/app/components/library/library-grid.tsx b/templates/clips/app/components/library/library-grid.tsx index e6edc6d2cf..c77478bd62 100644 --- a/templates/clips/app/components/library/library-grid.tsx +++ b/templates/clips/app/components/library/library-grid.tsx @@ -253,6 +253,7 @@ export function LibraryGrid({ { if (!open) setSharingRec(null); diff --git a/templates/clips/app/components/player/share-dialog.test.ts b/templates/clips/app/components/player/share-dialog.test.ts index ae3dd18af5..d578f01f96 100644 --- a/templates/clips/app/components/player/share-dialog.test.ts +++ b/templates/clips/app/components/player/share-dialog.test.ts @@ -19,8 +19,16 @@ describe("recording share popover", () => { it("keeps agent sharing private-only and lazy", () => { const shareDialogSource = readSource("./share-dialog.tsx"); - expect(shareDialogSource).toContain("{!isPublic ? ("); + expect(shareDialogSource).toContain("{sharesLoaded && !isPublic ? ("); expect(shareDialogSource).toContain("if (!isPublic && agentShareOpen)"); expect(shareDialogSource).toContain("{isPublic ? { + const shareDialogSource = readSource("./share-dialog.tsx"); + + expect(shareDialogSource).toContain("data?.role ?? initialRole"); + expect(shareDialogSource).toContain("initialVisibility ??"); + expect(shareDialogSource).not.toContain('?? "private"'); + }); }); diff --git a/templates/clips/app/components/player/share-dialog.tsx b/templates/clips/app/components/player/share-dialog.tsx index 19407a54c3..c78e69ab80 100644 --- a/templates/clips/app/components/player/share-dialog.tsx +++ b/templates/clips/app/components/player/share-dialog.tsx @@ -62,6 +62,8 @@ function absoluteAppUrl(path: string): string { export interface ShareRecordingPopoverProps { recordingId: string; recordingTitle?: string; + initialVisibility?: Visibility | null; + initialRole?: "owner" | "admin" | "editor" | "viewer"; videoUrl?: string | null; animatedThumbnailUrl?: string | null; isLoomRecording?: boolean; @@ -90,6 +92,8 @@ type ShareRecordingDialogProps = Omit< export function ShareRecordingPopover({ recordingId, recordingTitle, + initialVisibility, + initialRole, videoUrl, animatedThumbnailUrl, isLoomRecording = false, @@ -108,6 +112,8 @@ export function ShareRecordingPopover({ @@ -264,6 +285,7 @@ function LinkTab({ recordingId, shareUrl, sharesQuery, + visibility, canManage, videoUrl, animatedThumbnailUrl, @@ -272,6 +294,7 @@ function LinkTab({ recordingId: string; shareUrl: string; sharesQuery: SharesQuery; + visibility: Visibility | null; canManage: boolean; videoUrl?: string | null; animatedThumbnailUrl?: string | null; @@ -283,11 +306,9 @@ function LinkTab({ recordingId, sharesQuery, ); - const data = sharesQuery.data; - const visibility: Visibility = - (data?.visibility as Visibility | null) ?? "private"; const isPublic = visibility === "public"; - const sharesLoaded = data !== undefined; + const sharesLoaded = visibility !== null; + const visibilityPending = isPending || sharesQuery.isLoading; const isLoomRecording = isLoomRecordingProp || isLoomEmbedUrl(videoUrl); const createAgentLink = useActionMutation( "create-recording-agent-link" as any, @@ -355,25 +376,34 @@ function LinkTab({ return (

- setResourceVisibility(next)} - publicDescription={t("shareDialog.publicDescription")} - /> + {visibility ? ( + setResourceVisibility(next)} + publicDescription={t("shareDialog.publicDescription")} + /> + ) : ( +
+
+
+
+ )} {/* Public links unfurl into a playable video in Slack; surface that here (and a connect link) instead of leaving it buried in Settings. */} {isPublic ? : null} - {!isPublic ? ( + {sharesLoaded && !isPublic ? ( ) : null}
@@ -188,8 +204,18 @@ export function TranscriptPanel(props: TranscriptPanelProps) {
{onRetry ? ( - ) : null}
@@ -229,6 +255,24 @@ export function TranscriptPanel(props: TranscriptPanelProps) { {t("transcriptPanel.downloadSrt")} + {onRegenerate ? ( + + + + + {t("transcriptPanel.regenerate")} + + ) : null}
{cleanup?.status === "running" ? ( diff --git a/templates/clips/app/components/player/video-player.test.ts b/templates/clips/app/components/player/video-player.test.ts index 31e4b4aec4..28b885a10e 100644 --- a/templates/clips/app/components/player/video-player.test.ts +++ b/templates/clips/app/components/player/video-player.test.ts @@ -54,14 +54,35 @@ describe("video player mobile controls", () => { expect(videoPlayerSource).toContain("onSeekRelative={seekByMs}"); }); - it("routes video surface clicks and taps through the same playback activation", () => { + it("reveals controls on touch while mouse clicks toggle playback", () => { const videoPlayerSource = readSource("./video-player.tsx"); const activation = videoPlayerSource.indexOf("const activateVideoSurface"); - const touchTap = videoPlayerSource.indexOf("activateVideoSurface();"); - const clickTap = videoPlayerSource.lastIndexOf("activateVideoSurface();"); + const touchBehavior = videoPlayerSource.indexOf( + 'if (input === "touch" && !hideChrome)', + activation, + ); + const revealControls = videoPlayerSource.indexOf( + "bumpControls();", + touchBehavior, + ); + const playbackToggle = videoPlayerSource.indexOf( + "togglePlayback();", + revealControls, + ); + const touchTap = videoPlayerSource.indexOf( + 'activateVideoSurface("touch");', + playbackToggle, + ); + const clickTap = videoPlayerSource.indexOf( + 'activateVideoSurface("mouse");', + touchTap, + ); expect(activation).toBeGreaterThan(-1); - expect(touchTap).toBeGreaterThan(activation); + expect(touchBehavior).toBeGreaterThan(activation); + expect(revealControls).toBeGreaterThan(touchBehavior); + expect(playbackToggle).toBeGreaterThan(revealControls); + expect(touchTap).toBeGreaterThan(playbackToggle); expect(clickTap).toBeGreaterThan(touchTap); }); }); diff --git a/templates/clips/app/components/player/video-player.tsx b/templates/clips/app/components/player/video-player.tsx index 26b8cd399d..eab79bf52c 100644 --- a/templates/clips/app/components/player/video-player.tsx +++ b/templates/clips/app/components/player/video-player.tsx @@ -563,16 +563,21 @@ export const VideoPlayer = forwardRef( requestPlay(); }, [isPlaying, pauseVideo, requestPlay]); - const activateVideoSurface = useCallback(() => { - const v = videoRef.current; - if (!v) return; - if (!v.paused || isPlaying) { - pauseVideo(); - } else { - requestPlay(); - } - bumpControls(); - }, [bumpControls, isPlaying, pauseVideo, requestPlay]); + const activateVideoSurface = useCallback( + (input: "mouse" | "touch") => { + // Match native mobile players: touching the video reveals the controls + // without unexpectedly pausing or resuming it. Embeds that explicitly + // hide their chrome keep surface-tap playback so they remain usable. + if (input === "touch" && !hideChrome) { + bumpControls(); + return; + } + + togglePlayback(); + bumpControls(); + }, + [bumpControls, hideChrome, togglePlayback], + ); const handlePlayerPointerDown = useCallback( (e: React.PointerEvent) => { @@ -610,7 +615,7 @@ export const VideoPlayer = forwardRef( e.preventDefault(); suppressNextClickRef.current = true; - activateVideoSurface(); + activateVideoSurface("touch"); }, [activateVideoSurface, isLoomEmbed], ); @@ -1115,7 +1120,7 @@ export const VideoPlayer = forwardRef( // keep their own behavior. if (isPlayerUiTarget(e.target)) return; if (isLoomEmbed) return; - activateVideoSurface(); + activateVideoSurface("mouse"); }} > {isLoomEmbed && loomIframeSrc ? ( diff --git a/templates/clips/app/i18n/ar-SA.ts b/templates/clips/app/i18n/ar-SA.ts index aa3a60f015..27fba51dba 100644 --- a/templates/clips/app/i18n/ar-SA.ts +++ b/templates/clips/app/i18n/ar-SA.ts @@ -379,6 +379,7 @@ const messages = { searchPlaceholder: "نص البحث", copyTranscript: "نسخ النص", downloadSrt: "تحميل .srt", + regenerate: "إعادة إنشاء النص", cleanupRunning: "تنظيف النص في الخلفية.", noMatches: "لا توجد مباريات.", noTranscript: "لا يوجد نسخة بعد.", diff --git a/templates/clips/app/i18n/de-DE.ts b/templates/clips/app/i18n/de-DE.ts index ccd5b2f172..974afcd4df 100644 --- a/templates/clips/app/i18n/de-DE.ts +++ b/templates/clips/app/i18n/de-DE.ts @@ -388,6 +388,7 @@ const messages = { searchPlaceholder: "Transkript durchsuchen", copyTranscript: "Transkript kopieren", downloadSrt: "Laden Sie .srt herunter", + regenerate: "Transkript neu erstellen", cleanupRunning: "Bereinigen des Transkripts im Hintergrund.", noMatches: "Keine Übereinstimmungen.", noTranscript: "Noch kein Transkript.", diff --git a/templates/clips/app/i18n/en-US.ts b/templates/clips/app/i18n/en-US.ts index b8b4101ff3..712e5c6753 100644 --- a/templates/clips/app/i18n/en-US.ts +++ b/templates/clips/app/i18n/en-US.ts @@ -373,6 +373,7 @@ const messages = { searchPlaceholder: "Search transcript", copyTranscript: "Copy transcript", downloadSrt: "Download .srt", + regenerate: "Regenerate transcript", cleanupRunning: "Cleaning up transcript in the background.", noMatches: "No matches.", noTranscript: "No transcript yet.", diff --git a/templates/clips/app/i18n/es-ES.ts b/templates/clips/app/i18n/es-ES.ts index 701fdfc508..5954a3af13 100644 --- a/templates/clips/app/i18n/es-ES.ts +++ b/templates/clips/app/i18n/es-ES.ts @@ -386,6 +386,7 @@ const messages = { searchPlaceholder: "Transcripción de búsqueda", copyTranscript: "Copiar transcripción", downloadSrt: "Descargar .srt", + regenerate: "Regenerar transcripción", cleanupRunning: "Limpiando la transcripción en segundo plano.", noMatches: "Sin coincidencias.", noTranscript: "Aún no hay transcripción.", diff --git a/templates/clips/app/i18n/fr-FR.ts b/templates/clips/app/i18n/fr-FR.ts index 1a1076385e..0c90b37e2c 100644 --- a/templates/clips/app/i18n/fr-FR.ts +++ b/templates/clips/app/i18n/fr-FR.ts @@ -386,6 +386,7 @@ const messages = { searchPlaceholder: "Rechercher la transcription", copyTranscript: "Copier la transcription", downloadSrt: "Télécharger .srt", + regenerate: "Régénérer la transcription", cleanupRunning: "Nettoyage de la transcription en arrière-plan.", noMatches: "Aucun match.", noTranscript: "Pas encore de transcription.", diff --git a/templates/clips/app/i18n/hi-IN.ts b/templates/clips/app/i18n/hi-IN.ts index 3de0630c25..0db4f6cfc5 100644 --- a/templates/clips/app/i18n/hi-IN.ts +++ b/templates/clips/app/i18n/hi-IN.ts @@ -371,6 +371,7 @@ const messages = { searchPlaceholder: "प्रतिलेख खोजें", copyTranscript: "प्रतिलेख कॉपी करें", downloadSrt: "डाउनलोड .srt", + regenerate: "प्रतिलेख फिर से बनाएँ", cleanupRunning: "पृष्ठभूमि में प्रतिलेख साफ़ करना.", noMatches: "कोई मेल नहीं।", noTranscript: "अभी तक कोई प्रतिलेख नहीं.", diff --git a/templates/clips/app/i18n/ja-JP.ts b/templates/clips/app/i18n/ja-JP.ts index eb7c5acbfa..21ffcc0caa 100644 --- a/templates/clips/app/i18n/ja-JP.ts +++ b/templates/clips/app/i18n/ja-JP.ts @@ -381,6 +381,7 @@ const messages = { searchPlaceholder: "トランスクリプトの検索", copyTranscript: "トランスクリプトをコピーする", downloadSrt: ".srtをダウンロード", + regenerate: "トランスクリプトを再生成", cleanupRunning: "バックグラウンドでトランスクリプトをクリーンアップしています。", noMatches: "一致はありません。", diff --git a/templates/clips/app/i18n/ko-KR.ts b/templates/clips/app/i18n/ko-KR.ts index 5c2c5f2e26..e7b3796665 100644 --- a/templates/clips/app/i18n/ko-KR.ts +++ b/templates/clips/app/i18n/ko-KR.ts @@ -376,6 +376,7 @@ const messages = { searchPlaceholder: "성적표 검색", copyTranscript: "성적표 복사", downloadSrt: ".srt 다운로드", + regenerate: "스크립트 다시 생성", cleanupRunning: "백그라운드에서 기록을 정리합니다.", noMatches: "일치하는 항목이 없습니다.", noTranscript: "아직 성적표가 없습니다.", diff --git a/templates/clips/app/i18n/pt-BR.ts b/templates/clips/app/i18n/pt-BR.ts index 403abb2bed..20282eca75 100644 --- a/templates/clips/app/i18n/pt-BR.ts +++ b/templates/clips/app/i18n/pt-BR.ts @@ -383,6 +383,7 @@ const messages = { searchPlaceholder: "Transcrição da pesquisa", copyTranscript: "Copiar transcrição", downloadSrt: "Baixe .srt", + regenerate: "Gerar transcrição novamente", cleanupRunning: "Limpando a transcrição em segundo plano.", noMatches: "Nenhuma correspondência.", noTranscript: "Nenhuma transcrição ainda.", diff --git a/templates/clips/app/i18n/zh-CN.ts b/templates/clips/app/i18n/zh-CN.ts index 9f3a08128b..3f28f87920 100644 --- a/templates/clips/app/i18n/zh-CN.ts +++ b/templates/clips/app/i18n/zh-CN.ts @@ -361,6 +361,7 @@ const messages = { searchPlaceholder: "搜索成绩单", copyTranscript: "复制成绩单", downloadSrt: "下载.srt", + regenerate: "重新生成转录文本", cleanupRunning: "在后台清理成绩单。", noMatches: "没有匹配项。", noTranscript: "还没有文字记录。", diff --git a/templates/clips/app/i18n/zh-TW.ts b/templates/clips/app/i18n/zh-TW.ts index 43112ef4a6..5bb2c3b010 100644 --- a/templates/clips/app/i18n/zh-TW.ts +++ b/templates/clips/app/i18n/zh-TW.ts @@ -361,6 +361,7 @@ const messages = { searchPlaceholder: "搜尋逐字稿", copyTranscript: "複製逐字稿", downloadSrt: "下載 .srt", + regenerate: "重新產生逐字稿", cleanupRunning: "在背景清理逐字稿。", noMatches: "沒有符合項目。", noTranscript: "還沒有逐字稿。", diff --git a/templates/clips/app/routes/r.$recordingId.tsx b/templates/clips/app/routes/r.$recordingId.tsx index f2e186f74a..09c73d9fc0 100644 --- a/templates/clips/app/routes/r.$recordingId.tsx +++ b/templates/clips/app/routes/r.$recordingId.tsx @@ -59,7 +59,6 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { DropdownMenu, - DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, @@ -67,6 +66,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Spinner } from "@/components/ui/spinner"; +import { Switch } from "@/components/ui/switch"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import { Tooltip, @@ -499,6 +499,15 @@ export default function RecordingPage() { const firstCta = ctas[0] ?? null; const handleAiError = (err: Error) => toast.error(err?.message ?? t("recordingPage.aiRequestFailed")); + const requestTranscript = useActionMutation("request-transcript" as any, { + onSuccess: () => void playerDataQ.refetch(), + onError: (err: Error) => + toast.error( + t("recordingPage.retryFailed", { + message: err?.message ?? t("recordingPage.networkError"), + }), + ), + }); const regenerateTitle = useActionMutation("regenerate-title" as any, { onSuccess: (result: any) => { if (result?.updated) { @@ -913,36 +922,34 @@ export default function RecordingPage() { durationMs={recording.durationMs} currentMs={currentMs} onSeek={(ms) => playerRef.current?.seek(ms)} - status={transcriptStatus} + status={ + requestTranscript.isPending && transcriptStatus === "failed" + ? "pending" + : transcriptStatus + } failureReason={transcriptFailureReason} cleanup={transcriptCleanup} recordingTitle={recording.title} - onRetry={() => { - // Force a fresh transcript job, then let polling swap the panel - // back to the pending state while it runs. - fetch( - agentNativePath("/_agent-native/actions/request-transcript"), - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - recordingId: recording.id, - force: true, - }), - }, - ) - .then((res) => { - if (!res.ok) throw new Error(`HTTP ${res.status}`); - }) - .catch((err) => - toast.error( - t("recordingPage.retryFailed", { - message: err?.message ?? t("recordingPage.networkError"), - }), - ), - ) - .finally(() => playerDataQ.refetch()); - }} + onRetry={ + canEdit + ? () => + requestTranscript.mutate({ + recordingId: recording.id, + force: true, + } as any) + : undefined + } + onRegenerate={ + canEdit && transcriptStatus === "ready" + ? () => + requestTranscript.mutate({ + recordingId: recording.id, + force: true, + regenerate: true, + } as any) + : undefined + } + isRegenerating={requestTranscript.isPending} /> - event.preventDefault()} - title={t("recordingPage.includeFullVideoDescription")} + + requestTranscript.mutate({ + recordingId: recording.id, + force: true, + regenerate: true, + } as any) + } > - {t("recordingPage.includeFullVideo")} - - + {requestTranscript.isPending ? ( + + ) : null} + {t("transcriptPanel.regenerate")} + @@ -1161,6 +1173,25 @@ export default function RecordingPage() { ); })} + + { + event.preventDefault(); + handleIncludeFullVideoChange(!includeFullVideoInAi); + }} + title={t("recordingPage.includeFullVideoDescription")} + className="justify-between gap-3" + > + {t("recordingPage.includeFullVideo")} + ) : null} @@ -1168,6 +1199,8 @@ export default function RecordingPage() { Resu Ok(()) } +fn overlay_labels_to_hide(preserve_finalizing: bool) -> impl Iterator { + OVERLAY_LABELS + .iter() + .copied() + .filter(move |label| !preserve_finalizing || *label != FINALIZING_LABEL) +} + #[tauri::command] -pub async fn hide_overlays(app: AppHandle) -> Result<(), String> { +pub async fn hide_overlays( + app: AppHandle, + preserve_finalizing: Option, +) -> Result<(), String> { stop_countdown_control_tracking(); let _ = app.emit("clips:countdown-shortcuts-active", false); - for label in [ - COUNTDOWN_LABEL, - TOOLBAR_LABEL, - BUBBLE_LABEL, - FINALIZING_LABEL, - FLOW_BAR_LABEL, - REGION_GUIDES_LABEL, - REGION_RECORD_BORDER_LABEL, - ] { + for label in overlay_labels_to_hide(preserve_finalizing.unwrap_or(false)) { if let Some(w) = app.get_webview_window(label) { let _ = w.close(); } @@ -1524,9 +1535,16 @@ fn remembered_voice_target_bundle(app: &AppHandle) -> Option { #[cfg(test)] mod tests { use super::{ - strip_trailing_period_for_messaging, text_insertion_strategy, TextInsertionStrategy, + overlay_labels_to_hide, strip_trailing_period_for_messaging, text_insertion_strategy, + TextInsertionStrategy, FINALIZING_LABEL, }; + #[test] + fn overlay_cleanup_can_preserve_finalizing_progress() { + assert!(!overlay_labels_to_hide(true).any(|label| label == FINALIZING_LABEL)); + assert!(overlay_labels_to_hide(false).any(|label| label == FINALIZING_LABEL)); + } + #[test] fn uses_clipboard_paste_for_chrome() { assert!(matches!( diff --git a/templates/clips/desktop/src-tauri/src/native_screen.rs b/templates/clips/desktop/src-tauri/src/native_screen.rs index 46f4c012e2..be2288ebe3 100644 --- a/templates/clips/desktop/src-tauri/src/native_screen.rs +++ b/templates/clips/desktop/src-tauri/src/native_screen.rs @@ -2254,7 +2254,7 @@ fn preferred_default_microphone_device(devices: &[AudioInputDevice]) -> Option, device_label: Option<&str>, ) -> Result, String> { diff --git a/templates/clips/desktop/src-tauri/src/system_audio.rs b/templates/clips/desktop/src-tauri/src/system_audio.rs index 37b3fd5ff3..8763feefee 100644 --- a/templates/clips/desktop/src-tauri/src/system_audio.rs +++ b/templates/clips/desktop/src-tauri/src/system_audio.rs @@ -25,8 +25,8 @@ //! | `audio_transcription_reset_timeline`| Rebase transcript timestamps to now. | //! | `audio_transcription_stop` | Stop the capture. | //! -//! `start_raw_system_capture` (in the `macos` submodule) is the capture entry -//! point the Whisper engine calls directly. +//! `start_raw_system_capture` and `start_raw_meeting_capture` (in the `macos` +//! submodule) are the capture entry points the Whisper engine calls directly. //! //! ## Events //! - `voice:audio-level` `{ level, source: "system" }` — waveform meter. @@ -124,7 +124,10 @@ pub async fn audio_transcription_start( mic_device_id, mic_device_label, capture_system.unwrap_or(true), - voice_processing.unwrap_or(true), + // Fail safe for meeting/recording callers: an omitted flag must not + // create a second VoiceProcessingIO stack beside a live call app. + // Short dictation sessions opt in explicitly from the renderer. + voice_processing.unwrap_or(false), owner, ) .await @@ -246,6 +249,8 @@ pub(crate) mod macos { app: AppHandle, cancelled: Arc, level_tick: Arc, + output_type: SCStreamOutputType, + source: &'static str, } // SAFETY: `Retained` and `Retained` wrap @@ -261,7 +266,7 @@ pub(crate) mod macos { sample_buffer: CMSampleBuffer, of_type: SCStreamOutputType, ) { - if of_type != SCStreamOutputType::Audio { + if of_type != self.output_type { return; } if self.cancelled.load(Ordering::SeqCst) { @@ -287,7 +292,7 @@ pub(crate) mod macos { "voice:audio-level", AudioLevelPayload { level, - source: "system", + source: self.source, }, ); } @@ -295,17 +300,18 @@ pub(crate) mod macos { } } - /// Handle for a running raw system capture. `stop()` ends the SCK stream. - pub(crate) struct RawSystemCapture { + /// Handle for a running raw SCK audio capture. A meeting capture can carry + /// both system and microphone output handlers on this one stream. + pub(crate) struct RawSckAudioCapture { stream: SCStream, cancelled: Arc, } // SAFETY: `SCStream` is `Send` (the crate marks it so); the atomic is // trivially `Send`. We only move the handle through ownership. - unsafe impl Send for RawSystemCapture {} + unsafe impl Send for RawSckAudioCapture {} - impl RawSystemCapture { + impl RawSckAudioCapture { pub(crate) fn stop(self) { self.cancelled.store(true, Ordering::SeqCst); let _ = self.stream.stop_capture(); @@ -317,7 +323,50 @@ pub(crate) mod macos { pub(crate) fn start_raw_system_capture( app: AppHandle, on_samples: Arc, - ) -> Result { + ) -> Result { + start_raw_sck_audio_capture(app, true, None, None, Some(on_samples), None) + } + + /// Whether this macOS version supports ScreenCaptureKit's independent + /// microphone output (`SCStreamOutputType::Microphone`). + pub(crate) fn supports_sck_microphone_capture() -> bool { + let version = NSProcessInfo::processInfo().operatingSystemVersion(); + version.majorVersion >= 15 + } + + /// Start one ScreenCaptureKit stream with independent system-audio and + /// microphone callbacks. This avoids opening a second AVAudioEngine / + /// VoiceProcessingIO input while Zoom, Meet, or Teams owns the live-call + /// microphone uplink. + pub(crate) fn start_raw_meeting_capture( + app: AppHandle, + mic_device_id: Option, + mic_device_label: Option, + capture_system: bool, + on_mic_samples: Arc, + on_system_samples: Option>, + ) -> Result { + if !supports_sck_microphone_capture() { + return Err("ScreenCaptureKit microphone capture requires macOS 15 or later.".into()); + } + start_raw_sck_audio_capture( + app, + capture_system, + mic_device_id, + mic_device_label, + on_system_samples, + Some(on_mic_samples), + ) + } + + fn start_raw_sck_audio_capture( + app: AppHandle, + capture_system: bool, + mic_device_id: Option, + mic_device_label: Option, + on_system_samples: Option>, + on_mic_samples: Option>, + ) -> Result { let granted = unsafe { CGPreflightScreenCaptureAccess() }; if !granted { let granted_now = unsafe { CGRequestScreenCaptureAccess() }; @@ -340,13 +389,30 @@ pub(crate) mod macos { .with_excluding_windows(&[]) .build(); - let config = SCStreamConfiguration::new() - .with_captures_audio(true) + let capture_microphone = on_mic_samples.is_some(); + let selected_mic = if capture_microphone { + crate::native_screen::resolve_microphone_capture_device( + mic_device_id.as_deref(), + mic_device_label.as_deref(), + )? + } else { + None + }; + let mut config = SCStreamConfiguration::new() + .with_captures_audio(capture_system) + .with_captures_microphone(capture_microphone) .with_excludes_current_process_audio(true) .with_sample_rate(48000) .with_channel_count(2) .with_width(2) .with_height(2); + if let Some(device) = selected_mic.as_ref() { + config.set_microphone_capture_device_id(&device.id); + eprintln!( + "[whisper] ScreenCaptureKit meeting microphone pinned to {} ({})", + device.name, device.id + ); + } // Mono float32 @ 48 kHz destination format for the mono-mix. let speech_format = unsafe { @@ -357,21 +423,37 @@ pub(crate) mod macos { let cancelled = Arc::new(AtomicBool::new(false)); let mut stream = SCStream::new(&filter, &config); - let forwarder = RawAudioForwarder { - on_samples, - speech_format, - app: app.clone(), - cancelled: cancelled.clone(), - level_tick: Arc::new(AtomicU32::new(0)), - }; - stream.add_output_handler(forwarder, SCStreamOutputType::Audio); + if let Some(on_samples) = on_system_samples { + let forwarder = RawAudioForwarder { + on_samples, + speech_format: speech_format.clone(), + app: app.clone(), + cancelled: cancelled.clone(), + level_tick: Arc::new(AtomicU32::new(0)), + output_type: SCStreamOutputType::Audio, + source: "system", + }; + stream.add_output_handler(forwarder, SCStreamOutputType::Audio); + } + if let Some(on_samples) = on_mic_samples { + let forwarder = RawAudioForwarder { + on_samples, + speech_format, + app: app.clone(), + cancelled: cancelled.clone(), + level_tick: Arc::new(AtomicU32::new(0)), + output_type: SCStreamOutputType::Microphone, + source: "mic", + }; + stream.add_output_handler(forwarder, SCStreamOutputType::Microphone); + } if let Err(e) = stream.start_capture() { cancelled.store(true, Ordering::SeqCst); return Err(format!("SCStream start_capture failed: {e:?}")); } - Ok(RawSystemCapture { stream, cancelled }) + Ok(RawSckAudioCapture { stream, cancelled }) } #[derive(Serialize, Clone)] diff --git a/templates/clips/desktop/src-tauri/src/whisper_speech.rs b/templates/clips/desktop/src-tauri/src/whisper_speech.rs index 0e87f15674..ce1ac173d0 100644 --- a/templates/clips/desktop/src-tauri/src/whisper_speech.rs +++ b/templates/clips/desktop/src-tauri/src/whisper_speech.rs @@ -10,7 +10,9 @@ //! Capture is reused from the existing modules: //! - mic → `native_speech::macos::start_raw_mic_capture` (AVAudioEngine + //! optional VoiceProcessingIO AEC, other-audio ducking off) -//! - system → `system_audio::macos::start_raw_system_capture` (ScreenCaptureKit) +//! - meetings on macOS 15+ → one ScreenCaptureKit stream with independent +//! microphone + system-audio outputs +//! - legacy system audio → `system_audio::macos::start_raw_system_capture` //! use tauri::AppHandle; @@ -108,7 +110,10 @@ mod macos { use whisper_rs::{FullParams, SamplingStrategy, WhisperContext, WhisperContextParameters}; use crate::native_speech::macos::{start_raw_mic_capture, RawMicCapture}; - use crate::system_audio::macos::{start_raw_system_capture, RawSystemCapture}; + use crate::system_audio::macos::{ + start_raw_meeting_capture, start_raw_system_capture, supports_sck_microphone_capture, + RawSckAudioCapture, + }; use crate::whisper_model::{ensure_model, model_file}; /// One transcript segment with real timestamps from whisper, already @@ -571,11 +576,20 @@ mod macos { } } + fn should_use_combined_sck_capture( + owner: SessionOwner, + microphone_capture_supported: bool, + ) -> bool { + owner == SessionOwner::Meeting && microphone_capture_supported + } + struct Session { - mic_cap: RawMicCapture, + // macOS 15+ meetings use a combined SCK capture, so there is no + // competing AVAudioEngine / VoiceProcessingIO mic input to stop. + mic_cap: Option, // System capture is optional — skipped when the user turns system // audio off, so neither the recording nor the transcript include it. - sys_cap: Option, + sys_cap: Option, mic: Arc, sys: Option>, /// Who started this session — see `SessionOwner`. @@ -644,8 +658,10 @@ mod macos { let _ = language; let lang: Option = None; - // Mic stream + capture. The real hardware rate is read back from the - // capture handle and pushed into the stream (default 48 kHz until then). + // Create both Whisper streams first. On macOS 15+ meetings, one + // ScreenCaptureKit stream feeds both callbacks without opening a + // competing VoiceProcessingIO mic input. Older macOS versions (and a + // failed SCK start) keep the existing split-capture fallback. let session_start = Instant::now(); let mic_stream = WhisperStream::new( app.clone(), @@ -655,50 +671,92 @@ mod macos { ctx.clone(), session_start, ); - let mic_for_cb = mic_stream.clone(); - let reuse_voice_processing_engine = owner == SessionOwner::Dictation && !capture_system; - let mic_cap = start_raw_mic_capture( - app.clone(), - mic_device_id, - mic_device_label, - voice_processing, - reuse_voice_processing_engine, - Arc::new(move |s: &[f32]| mic_for_cb.push(s)), - ) - .map_err(|e| { - mic_stream.stop(); - format!("mic capture failed: {e}") - })?; - mic_stream.set_src_rate(mic_cap.sample_rate()); - - // System stream + capture (SCK delivers 48 kHz). Skipped entirely when - // system audio is off. - let (sys_stream, sys_cap) = if capture_system { - let sys_stream = WhisperStream::new( + let sys_stream = capture_system.then(|| { + WhisperStream::new( app.clone(), "system", 48000.0, lang.clone(), ctx.clone(), session_start, - ); - let sys_for_cb = sys_stream.clone(); - let sys_cap = match start_raw_system_capture( + ) + }); + let mic_for_cb = mic_stream.clone(); + let mic_callback: Arc = + Arc::new(move |samples: &[f32]| mic_for_cb.push(samples)); + let system_callback: Option> = + sys_stream.as_ref().map(|stream| { + let stream = stream.clone(); + Arc::new(move |samples: &[f32]| stream.push(samples)) + as Arc + }); + + let combined_cap = if should_use_combined_sck_capture( + owner, + supports_sck_microphone_capture(), + ) { + match start_raw_meeting_capture( app.clone(), - Arc::new(move |s: &[f32]| sys_for_cb.push(s)), + mic_device_id.clone(), + mic_device_label.clone(), + capture_system, + mic_callback.clone(), + system_callback.clone(), ) { - Ok(cap) => cap, + Ok(cap) => { + eprintln!("[whisper] using combined ScreenCaptureKit mic + system capture"); + Some(cap) + } Err(e) => { - // Roll back the mic side so we don't leave a half-open meeting. + eprintln!( + "[whisper] combined ScreenCaptureKit meeting capture failed: {e}; falling back to split capture" + ); + None + } + } + } else { + None + }; + + let (mic_cap, sys_cap) = if let Some(combined_cap) = combined_cap { + // Both SCK outputs are configured at 48 kHz. + mic_stream.set_src_rate(48000.0); + (None, Some(combined_cap)) + } else { + let reuse_voice_processing_engine = owner == SessionOwner::Dictation && !capture_system; + let mic_cap = start_raw_mic_capture( + app.clone(), + mic_device_id, + mic_device_label, + voice_processing, + reuse_voice_processing_engine, + mic_callback, + ) + .map_err(|e| { + mic_stream.stop(); + if let Some(sys_stream) = &sys_stream { sys_stream.stop(); - mic_stream.stop(); - mic_cap.stop(); - return Err(format!("system capture failed: {e}")); } + format!("mic capture failed: {e}") + })?; + mic_stream.set_src_rate(mic_cap.sample_rate()); + + let sys_cap = if let Some(system_callback) = system_callback { + match start_raw_system_capture(app.clone(), system_callback) { + Ok(cap) => Some(cap), + Err(e) => { + if let Some(sys_stream) = &sys_stream { + sys_stream.stop(); + } + mic_stream.stop(); + mic_cap.stop(); + return Err(format!("system capture failed: {e}")); + } + } + } else { + None }; - (Some(sys_stream), Some(sys_cap)) - } else { - (None, None) + (Some(mic_cap), sys_cap) }; let mut slot = session_slot().lock().map_err(|e| e.to_string())?; @@ -750,7 +808,9 @@ mod macos { sys.stop(); } // Stop captures so no more samples arrive while workers flush. - session.mic_cap.stop(); + if let Some(mic_cap) = session.mic_cap { + mic_cap.stop(); + } if let Some(sys_cap) = session.sys_cap { sys_cap.stop(); } @@ -769,4 +829,22 @@ mod macos { } eprintln!("[whisper] meeting transcription stopped"); } + + #[cfg(test)] + mod tests { + use super::{should_use_combined_sck_capture, SessionOwner}; + + #[test] + fn combined_sck_capture_is_only_selected_for_supported_meetings() { + assert!(should_use_combined_sck_capture(SessionOwner::Meeting, true)); + assert!(!should_use_combined_sck_capture( + SessionOwner::Meeting, + false + )); + assert!(!should_use_combined_sck_capture( + SessionOwner::Dictation, + true + )); + } + } } diff --git a/templates/clips/desktop/src/app.tsx b/templates/clips/desktop/src/app.tsx index 734acc7ec6..5236f08fcc 100644 --- a/templates/clips/desktop/src/app.tsx +++ b/templates/clips/desktop/src/app.tsx @@ -1527,6 +1527,10 @@ export function App() { // from THAT render and stops the camera stream even though recording is // still in flight. const recordingFlowGateRef = useRef(false); + // Stop detaches the recorder state before optimization/upload finishes so a + // fresh camera session can recover immediately. Keep that post-stop phase + // separate so React cleanup does not close the finalizing progress window. + const recordingStopFinalizingRef = useRef(false); useEffect(() => { recordingFlowGateRef.current = isRecording || recordingFlowActive; }, [isRecording, recordingFlowActive]); @@ -1565,7 +1569,9 @@ export function App() { // Hide them from here instead. Guard on !recordingInFlight so // we don't rip the toolbar out from under an active recording. if (!recordingFlowGateRef.current) { - invoke("hide_overlays").catch(() => {}); + invoke("hide_overlays", { + preserveFinalizing: recordingStopFinalizingRef.current, + }).catch(() => {}); } }; }, [toolbarActive]); @@ -1721,7 +1727,9 @@ export function App() { // source changed (e.g. cameraId flip re-runs this effect): hiding // would race the re-run's show_bubble and close the window out from under it. if (!recordingInFlight && !bubbleActiveRef.current) { - invoke("hide_overlays").catch(() => {}); + invoke("hide_overlays", { + preserveFinalizing: recordingStopFinalizingRef.current, + }).catch(() => {}); } }; }, [bubbleActive, cameraId, bubbleSessionEpoch]); @@ -2351,6 +2359,7 @@ export function App() { // finalize; keeping `recorder` set through the whole upload made // reopen show a blank preview and made Start a silent no-op. const handle = recorder; + recordingStopFinalizingRef.current = true; bubbleStreamTransferredToRecorder.current = false; bubbleStreamRef.current = null; recordingFlowGateRef.current = false; @@ -2381,6 +2390,7 @@ export function App() { setRecError(err instanceof Error ? err.message : String(err)); await loadPendingUploads(); } finally { + recordingStopFinalizingRef.current = false; invoke("set_recording_state", { active: false }).catch(() => {}); if (stopFailed || stopResult?.localOnly) { invoke("show_popover").catch(() => {}); diff --git a/templates/clips/desktop/src/hooks/useMeetingTranscription.ts b/templates/clips/desktop/src/hooks/useMeetingTranscription.ts index 0a311ac2df..d928fbb35a 100644 --- a/templates/clips/desktop/src/hooks/useMeetingTranscription.ts +++ b/templates/clips/desktop/src/hooks/useMeetingTranscription.ts @@ -377,7 +377,9 @@ export function useMeetingTranscription({ }; // Resume the engine that initial start settled on (no fallback here — - // the engine choice was already made below). + // the engine choice was already made below). Never add a second + // VoiceProcessingIO stack beside the meeting app: it can alter the + // microphone level that remote participants receive. const startAudio = async () => { await restartTranscriptionEngine( session.engine, @@ -386,6 +388,7 @@ export function useMeetingTranscription({ label: selectedMicLabel, }, true, + false, ); }; @@ -556,6 +559,10 @@ export function useMeetingTranscription({ session.engine = await startTranscriptionEngine({ mic: { deviceId: selectedMicId, label: selectedMicLabel }, + // macOS 15+ uses ScreenCaptureKit's independent microphone output. + // The legacy fallback must also remain a raw tap so Zoom/Meet/Teams + // stay in sole control of their live-call voice processing. + voiceProcessing: false, }); await invoke("silence_detector_start", { diff --git a/templates/clips/desktop/src/lib/recorder.ts b/templates/clips/desktop/src/lib/recorder.ts index 34f262462a..d586320701 100644 --- a/templates/clips/desktop/src/lib/recorder.ts +++ b/templates/clips/desktop/src/lib/recorder.ts @@ -2232,11 +2232,14 @@ async function startNativeFullscreenRecording( ); } - const chromeCmd = wantsCamera - ? "hide_recording_chrome" - : "hide_overlays"; - await invoke(chromeCmd).catch((err) => - console.error(`[clips-recorder] ${chromeCmd} failed:`, err), + // The finalizing window owns the whole stop -> optimized upload -> + // browser-open gap. Only tear down recording chrome here; the outer + // finally closes finalizing after the clip has opened or failed. + await invoke("hide_recording_chrome").catch((err) => + console.error( + "[clips-recorder] hide_recording_chrome failed:", + err, + ), ); await invoke("native_fullscreen_capture_thumbnail", { serverUrl: params.serverUrl, diff --git a/templates/clips/desktop/src/lib/transcription-engine.test.ts b/templates/clips/desktop/src/lib/transcription-engine.test.ts index ebb49371a0..bdc2aed5e9 100644 --- a/templates/clips/desktop/src/lib/transcription-engine.test.ts +++ b/templates/clips/desktop/src/lib/transcription-engine.test.ts @@ -1,9 +1,58 @@ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; -import { recordingTranscriptionLanguage } from "./transcription-engine"; +const { invokeMock } = vi.hoisted(() => ({ invokeMock: vi.fn() })); + +vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock })); +vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn() })); + +import { + recordingTranscriptionLanguage, + restartTranscriptionEngine, + startTranscriptionEngine, +} from "./transcription-engine"; + +beforeEach(() => { + invokeMock.mockReset(); + invokeMock.mockResolvedValue(undefined); +}); describe("recording transcription language", () => { it("leaves local Whisper recordings on auto-detect instead of forcing the UI locale", () => { expect(recordingTranscriptionLanguage()).toBeNull(); }); }); + +describe("meeting microphone capture", () => { + it("starts without VoiceProcessingIO so call apps keep control of mic gain", async () => { + await startTranscriptionEngine({ + mic: { deviceId: "mic-1", label: "Built-in Microphone" }, + }); + + expect(invokeMock).toHaveBeenCalledWith("audio_transcription_start", { + meetingId: null, + locale: null, + micDeviceId: "mic-1", + micDeviceLabel: "Built-in Microphone", + captureSystem: true, + voiceProcessing: false, + owner: "meeting", + }); + }); + + it("keeps VoiceProcessingIO off when meeting transcription resumes", async () => { + await restartTranscriptionEngine("whisper", { + deviceId: "mic-1", + label: "Built-in Microphone", + }); + + expect(invokeMock).toHaveBeenCalledWith("audio_transcription_start", { + meetingId: null, + locale: null, + micDeviceId: "mic-1", + micDeviceLabel: "Built-in Microphone", + captureSystem: true, + voiceProcessing: false, + owner: "meeting", + }); + }); +}); diff --git a/templates/clips/desktop/src/lib/transcription-engine.ts b/templates/clips/desktop/src/lib/transcription-engine.ts index fb866008ef..5b79f9e6c7 100644 --- a/templates/clips/desktop/src/lib/transcription-engine.ts +++ b/templates/clips/desktop/src/lib/transcription-engine.ts @@ -119,7 +119,7 @@ export async function restartTranscriptionEngine( engine: TranscriptionEngine, mic?: MicSelection, captureSystem: boolean = true, - voiceProcessing: boolean = true, + voiceProcessing: boolean = false, ): Promise { if (engine === "whisper") { await invoke("audio_transcription_start", { @@ -151,14 +151,14 @@ export async function startTranscriptionEngine(opts: { /** Capture + transcribe system audio (whisper). Default true. */ captureSystem?: boolean; /** - * Enable Apple's voice-processing input mode for the Whisper mic tap. Native - * recordings disable this so the transcription tap does not reconfigure the - * shared microphone before ScreenCaptureKit opens it. + * Enable Apple's voice-processing input mode for the Whisper mic tap. + * Meeting and recording capture leave this off so Clips never adds a second + * VoIP processor beside Zoom, Meet, or Teams and changes their mic uplink. */ voiceProcessing?: boolean; }): Promise { const captureSystem = opts.captureSystem ?? true; - const voiceProcessing = opts.voiceProcessing ?? true; + const voiceProcessing = opts.voiceProcessing ?? false; try { await restartTranscriptionEngine( "whisper", diff --git a/templates/clips/desktop/src/lib/voice-dictation.ts b/templates/clips/desktop/src/lib/voice-dictation.ts index b169241bed..265a1cd3fc 100644 --- a/templates/clips/desktop/src/lib/voice-dictation.ts +++ b/templates/clips/desktop/src/lib/voice-dictation.ts @@ -1254,6 +1254,9 @@ export function installDesktopVoiceDictation( micDeviceId: concreteMediaDeviceId(micDeviceId) || null, micDeviceLabel: micDeviceLabel || null, captureSystem: false, + // Short, standalone dictation sessions benefit from Apple's AEC/AGC. + // Meeting and recording capture explicitly keep this disabled. + voiceProcessing: true, owner: "dictation", }); console.log("[voice-dictation] audio_transcription_start ok"); diff --git a/templates/clips/desktop/src/styles.css b/templates/clips/desktop/src/styles.css index 6fa37464da..15f8931cd1 100644 --- a/templates/clips/desktop/src/styles.css +++ b/templates/clips/desktop/src/styles.css @@ -25,9 +25,11 @@ --radius: 12px; --radius-sm: 8px; --radius-pill: 999px; - /* Must cover `--shadow-md` blur (24px in dark mode) or the transparent - Tauri window clips the drop shadow on every edge. */ + /* Keep the side and bottom shadow clear of the transparent Tauri window. + The top is intentionally tighter: the popover should feel attached to + its menu-bar icon, where a clipped shadow is not visible anyway. */ --popover-shadow-gutter: 24px; + --popover-shadow-gutter-top: 6px; --overlay-shadow-gutter: 18px; --shadow-sm: 0 1px 2px rgba(23, 23, 23, 0.06); @@ -106,7 +108,8 @@ button { body[data-clips-route="popover"] #root { min-height: 100vh; - padding: var(--popover-shadow-gutter); + padding: var(--popover-shadow-gutter-top) var(--popover-shadow-gutter) + var(--popover-shadow-gutter); } body[data-clips-route]:not([data-clips-route="popover"]) #root { diff --git a/templates/clips/server/lib/slack-unfurls.test.ts b/templates/clips/server/lib/slack-unfurls.test.ts index 2b6aa8b950..1e01df4ff9 100644 --- a/templates/clips/server/lib/slack-unfurls.test.ts +++ b/templates/clips/server/lib/slack-unfurls.test.ts @@ -29,6 +29,7 @@ function recording(overrides: Record = {}) { id: "rec-1", title: "Launch walkthrough", description: "A product walkthrough", + durationMs: 222_000, thumbnailUrl: "/api/media/thumb", animatedThumbnailUrl: null, visibility: "public", @@ -114,12 +115,30 @@ describe("Clips Slack unfurls", () => { ).toMatchObject({ type: "video", title_url: "https://clips.example.com/share/rec-1", + description: { + type: "plain_text", + text: "3:42 · A product walkthrough", + emoji: true, + }, video_url: "https://clips.example.com/embed/rec-1?autoplay=1", thumbnail_url: "https://clips.example.com/api/media/thumb", provider_name: "Clips", }); }); + it("omits unknown durations from Slack video descriptions", () => { + expect( + buildSlackVideoBlock({ + recording: recording({ durationMs: 0 }), + origin: "https://clips.example.com", + }), + ).toMatchObject({ + description: { + text: "A product walkthrough", + }, + }); + }); + it("does not build playable unfurls for password-protected clips", () => { expect( buildSlackVideoBlock({ diff --git a/templates/clips/server/lib/slack-unfurls.ts b/templates/clips/server/lib/slack-unfurls.ts index babec69554..78a140b449 100644 --- a/templates/clips/server/lib/slack-unfurls.ts +++ b/templates/clips/server/lib/slack-unfurls.ts @@ -28,6 +28,7 @@ type SlackUnfurlRecording = { id: string; title: string; description: string; + durationMs: number; thumbnailUrl: string | null; animatedThumbnailUrl: string | null; visibility: string | null; @@ -205,6 +206,19 @@ function isExpired(value: string | null): boolean { return Number.isFinite(expires) && expires < Date.now(); } +function formatSlackDuration(durationMs: number): string | null { + if (!Number.isFinite(durationMs) || durationMs <= 0) return null; + const totalSeconds = Math.floor(durationMs / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + return hours > 0 + ? `${hours}:${minutes.toString().padStart(2, "0")}:${seconds + .toString() + .padStart(2, "0")}` + : `${minutes}:${seconds.toString().padStart(2, "0")}`; +} + export function isSlackPlayableRecording( recording: SlackUnfurlRecording, ): boolean { @@ -236,7 +250,11 @@ export function buildSlackVideoBlock(options: { origin, ).toString(); const title = displayRecordingTitle(recording.title); - const description = clipsShareDescription(recording); + const shareDescription = clipsShareDescription(recording); + const duration = formatSlackDuration(recording.durationMs); + const description = duration + ? `${duration} · ${shareDescription}` + : shareDescription; const thumbnailUrl = absoluteUrl( recording.thumbnailUrl || recording.animatedThumbnailUrl, origin, @@ -265,6 +283,7 @@ export async function loadSlackVideoBlockForUrl( id: schema.recordings.id, title: schema.recordings.title, description: schema.recordings.description, + durationMs: schema.recordings.durationMs, thumbnailUrl: schema.recordings.thumbnailUrl, animatedThumbnailUrl: schema.recordings.animatedThumbnailUrl, visibility: schema.recordings.visibility, diff --git a/templates/clips/server/lib/streaming-upload-mode.test.ts b/templates/clips/server/lib/streaming-upload-mode.test.ts index 7c21a9bd31..d85d481a26 100644 --- a/templates/clips/server/lib/streaming-upload-mode.test.ts +++ b/templates/clips/server/lib/streaming-upload-mode.test.ts @@ -41,6 +41,25 @@ describe("streaming upload mode", () => { expect(shouldEnableStreamingUpload({ mimeType: undefined })).toBe(false); }); + it("enables requested video streaming when buffered fallback is unavailable", () => { + delete process.env.CLIPS_DISABLE_STREAMING_UPLOAD; + delete process.env.CLIPS_ENABLE_STREAMING_UPLOAD; + + expect( + shouldEnableStreamingUpload({ + client: "desktop-native", + mimeType: "video/mp4", + bufferedFallbackAvailable: false, + }), + ).toBe(true); + expect( + shouldEnableStreamingUpload({ + mimeType: "audio/webm", + bufferedFallbackAvailable: false, + }), + ).toBe(false); + }); + it("honors explicit enable and disable flags", () => { process.env.CLIPS_ENABLE_STREAMING_UPLOAD = "true"; delete process.env.CLIPS_DISABLE_STREAMING_UPLOAD; diff --git a/templates/clips/server/lib/streaming-upload-mode.ts b/templates/clips/server/lib/streaming-upload-mode.ts index 9d6a83ddb1..e9af04761a 100644 --- a/templates/clips/server/lib/streaming-upload-mode.ts +++ b/templates/clips/server/lib/streaming-upload-mode.ts @@ -1,8 +1,8 @@ import { enabledFlag } from "./env-flags.js"; -// Streaming resumable uploads are deployment opt-in while the provider/finalize -// path hardens. Set CLIPS_ENABLE_STREAMING_UPLOAD=1 to allow recorder requests; -// CLIPS_DISABLE_STREAMING_UPLOAD=1 still forces the buffered fallback. +// Local/dev deployments can opt into resumable uploads while retaining their +// SQL scratch fallback. Hosted deployments have no safe buffered fallback, so +// requested video uploads use resumable storage unless explicitly disabled. export function isStreamingUploadDisabled(): boolean { return enabledFlag(process.env.CLIPS_DISABLE_STREAMING_UPLOAD); } @@ -10,9 +10,15 @@ export function isStreamingUploadDisabled(): boolean { export function shouldEnableStreamingUpload(args: { client?: string | null; mimeType?: string | null; + bufferedFallbackAvailable?: boolean; }): boolean { if (isStreamingUploadDisabled()) return false; - if (!enabledFlag(process.env.CLIPS_ENABLE_STREAMING_UPLOAD)) return false; + if ( + args.bufferedFallbackAvailable !== false && + !enabledFlag(process.env.CLIPS_ENABLE_STREAMING_UPLOAD) + ) { + return false; + } const mimeType = (args.mimeType ?? "").split(";")[0]?.trim().toLowerCase(); return !mimeType || mimeType.startsWith("video/"); diff --git a/templates/clips/shared/recording-core.test.ts b/templates/clips/shared/recording-core.test.ts index 2197cffa78..43bdeebc0b 100644 --- a/templates/clips/shared/recording-core.test.ts +++ b/templates/clips/shared/recording-core.test.ts @@ -1,8 +1,18 @@ import { describe, expect, it } from "vitest"; -import { chunkUploadQuery, normalizeChunkUploadNumber } from "./recording-core"; +import { + chunkUploadParallelism, + chunkUploadQuery, + normalizeChunkUploadNumber, +} from "./recording-core"; describe("recording upload URL helpers", () => { + it("serializes resumable chunks while keeping buffered upload parallelism", () => { + expect(chunkUploadParallelism("streaming", 4)).toBe(1); + expect(chunkUploadParallelism("buffered", 4)).toBe(4); + expect(chunkUploadParallelism(undefined, 0)).toBe(1); + }); + it("normalizes finite upload metadata before encoding", () => { expect(normalizeChunkUploadNumber("1200.6")).toBe(1201); expect(normalizeChunkUploadNumber(-12)).toBe(0); diff --git a/templates/clips/shared/recording-core.ts b/templates/clips/shared/recording-core.ts index ebabc8ff60..58a6fb02b9 100644 --- a/templates/clips/shared/recording-core.ts +++ b/templates/clips/shared/recording-core.ts @@ -51,6 +51,18 @@ export function pickMimeType(): string { * - `"buffered"` — full blob assembled after stop() and uploaded in slices */ export type UploadMode = "streaming" | "buffered"; +/** + * Resumable providers advance a single byte offset, so their chunks must be + * sent in strict index order. Buffered uploads can retain bounded parallelism. + */ +export function chunkUploadParallelism( + uploadMode: UploadMode | undefined, + bufferedParallelism: number, +): number { + if (uploadMode === "streaming") return 1; + return Math.max(1, Math.floor(bufferedParallelism)); +} + /** Query params understood by the chunk-upload route * (`/api/uploads/:id/chunk`). This is the on-the-wire contract — the route in * `server/routes/api/uploads/[recordingId]/chunk.post.ts` reads exactly these. */ diff --git a/templates/content/AGENTS.md b/templates/content/AGENTS.md index 3ab6e47447..51b42ce2c8 100644 --- a/templates/content/AGENTS.md +++ b/templates/content/AGENTS.md @@ -158,13 +158,14 @@ to SQL. `pull-document` closes that gap with a flush handshake — if a live Yjs collab session exists for the document it writes a one-shot `flush-request-` application-state key (scoped to the browser session, just like `navigate`); the open editor sees that key, serializes its current document to markdown through -its own existing serializer, calls `update-document`, and deletes the key; -`pull-document` waits for the key to clear and then returns the now-fresh row. -When no editor is open the SQL column is authoritative and the handshake is +its own existing serializer, calls `update-document`, and writes an explicit +request-id-matched success/error acknowledgement. `pull-document` waits for that +acknowledgement and fails closed if the open editor cannot save; when no active +human editor is present, the SQL column is authoritative and the handshake is skipped. It is GET + read-only + public-agent exposed (`requiresAuth: true`), -returns `{ id, title, content, format, deepLink }`, and surfaces an -"Open document" deep link for external agents. Use `--format text` for a -plain-text strip of the markdown. +returns `{ id, title, content, format, deepLink }`, and surfaces an "Open +document" deep link for external agents. Use `--format text` for a plain-text +strip of the markdown. ### Local Source Files diff --git a/templates/content/actions/_builder-docs-client.test.ts b/templates/content/actions/_builder-docs-client.test.ts index 11b0339986..d64630c440 100644 --- a/templates/content/actions/_builder-docs-client.test.ts +++ b/templates/content/actions/_builder-docs-client.test.ts @@ -25,6 +25,10 @@ const resolveBuilderCredentialMock = vi.hoisted(() => const collabStateMock = vi.hoisted(() => ({ hasCollabState: vi.fn(async () => false), + loadAwarenessRowsStrict: vi.fn( + async () => + [] as Array<{ clientId: number; state: string; lastSeen: number }>, + ), })); const appStateMock = vi.hoisted(() => ({ @@ -123,7 +127,9 @@ vi.mock("@agent-native/core/server/request-context", () => ({ })); vi.mock("@agent-native/core/collab", () => ({ + AGENT_CLIENT_ID: 0xffffffff, hasCollabState: collabStateMock.hasCollabState, + loadAwarenessRowsStrict: collabStateMock.loadAwarenessRowsStrict, })); vi.mock("@agent-native/core/application-state", () => ({ @@ -190,6 +196,7 @@ beforeEach(() => { vi.clearAllMocks(); resolveBuilderCredentialMock.mockResolvedValue(null); collabStateMock.hasCollabState.mockResolvedValue(false); + collabStateMock.loadAwarenessRowsStrict.mockResolvedValue([]); appStateMock.appStateGet.mockResolvedValue(null); builderWriteMock.executeBuilderCmsWrite.mockResolvedValue({ ok: true, @@ -399,6 +406,21 @@ describe("Builder docs DB-backed source", () => { sourceUpdatedAt: bundle.mdx.metadata.lastUpdated, }; collabStateMock.hasCollabState.mockResolvedValue(true); + collabStateMock.loadAwarenessRowsStrict.mockResolvedValue([ + { + clientId: 123, + state: JSON.stringify({ + visible: true, + user: { email: "owner@example.com" }, + }), + lastSeen: Date.now(), + }, + ]); + appStateMock.appStateGet.mockImplementation(async () => ({ + id: bundle.mdx.documentId, + requestId: appStateMock.appStatePut.mock.calls[0]?.[2]?.requestId, + status: "success", + })); await resolveBuilderDocsSource({ documentId: bundle.mdx.documentId }); diff --git a/templates/content/actions/_document-flush.test.ts b/templates/content/actions/_document-flush.test.ts new file mode 100644 index 0000000000..2708b11b44 --- /dev/null +++ b/templates/content/actions/_document-flush.test.ts @@ -0,0 +1,370 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + appStateDelete: vi.fn(), + appStateGet: vi.fn(), + appStatePut: vi.fn(), + getRequestUserEmail: vi.fn(), + hasCollabState: vi.fn(), + loadAwarenessRowsStrict: vi.fn(), +})); + +vi.mock("@agent-native/core/application-state", () => ({ + appStateDelete: mocks.appStateDelete, + appStateGet: mocks.appStateGet, + appStatePut: mocks.appStatePut, +})); + +vi.mock("@agent-native/core/collab", () => ({ + AGENT_CLIENT_ID: 0xffffffff, + hasCollabState: mocks.hasCollabState, + loadAwarenessRowsStrict: mocks.loadAwarenessRowsStrict, +})); + +vi.mock("@agent-native/core/server/request-context", () => ({ + getRequestUserEmail: mocks.getRequestUserEmail, +})); + +import { flushOpenDocumentEditorToSql } from "./_document-flush"; + +describe("flushOpenDocumentEditorToSql", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + mocks.hasCollabState.mockResolvedValue(true); + mocks.loadAwarenessRowsStrict.mockResolvedValue([ + { + clientId: 123, + state: JSON.stringify({ + canFlushDocument: true, + visible: true, + user: { email: "owner@example.com" }, + }), + lastSeen: Date.now(), + }, + ]); + mocks.getRequestUserEmail.mockReturnValue("editor@example.com"); + mocks.appStatePut.mockResolvedValue(undefined); + mocks.appStateDelete.mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("completes after an open editor acknowledges the flush", async () => { + mocks.appStateGet.mockImplementation(async () => ({ + id: "doc-1", + requestId: mocks.appStatePut.mock.calls[0]?.[2]?.requestId, + status: "success", + })); + + const flush = flushOpenDocumentEditorToSql({ + documentId: "doc-1", + ownerEmail: "owner@example.com", + }); + await vi.advanceTimersByTimeAsync(200); + + await expect(flush).resolves.toBeUndefined(); + expect(mocks.appStatePut).toHaveBeenCalledWith( + "owner@example.com", + "flush-request-doc-1", + expect.objectContaining({ id: "doc-1" }), + { requestSource: "agent" }, + ); + }); + + it("fails closed when the live editor reports a save error", async () => { + mocks.appStateGet.mockImplementation(async () => ({ + id: "doc-1", + requestId: mocks.appStatePut.mock.calls[0]?.[2]?.requestId, + status: "error", + error: "The document changed while preparing it for sync.", + })); + + const flush = flushOpenDocumentEditorToSql({ + documentId: "doc-1", + ownerEmail: "owner@example.com", + }); + const rejected = expect(flush).rejects.toThrow( + "The document changed while preparing it for sync.", + ); + await vi.advanceTimersByTimeAsync(200); + + await rejected; + expect(mocks.appStateDelete).toHaveBeenCalled(); + }); + + it("fails closed when no active editor acknowledges before timeout", async () => { + mocks.appStateGet.mockImplementation(async () => ({ + id: "doc-1", + requestId: mocks.appStatePut.mock.calls[0]?.[2]?.requestId, + status: "pending", + })); + + const flush = flushOpenDocumentEditorToSql({ + documentId: "doc-1", + ownerEmail: "owner@example.com", + }); + const rejected = expect(flush).rejects.toThrow(/did not finish saving/i); + await vi.advanceTimersByTimeAsync(4_200); + + await rejected; + }); + + it("fails closed when the flush request cannot be written", async () => { + mocks.appStatePut.mockRejectedValue(new Error("connection unavailable")); + + await expect( + flushOpenDocumentEditorToSql({ + documentId: "doc-1", + ownerEmail: "owner@example.com", + }), + ).rejects.toThrow(/could not ask the open document editor/i); + }); + + it("fails closed when active-editor awareness cannot be read", async () => { + mocks.loadAwarenessRowsStrict.mockRejectedValue( + new Error("awareness storage unavailable"), + ); + + await expect( + flushOpenDocumentEditorToSql({ + documentId: "doc-1", + ownerEmail: "owner@example.com", + }), + ).rejects.toThrow("awareness storage unavailable"); + expect(mocks.appStatePut).not.toHaveBeenCalled(); + }); + + it("skips the handshake when only persisted Yjs state remains", async () => { + mocks.loadAwarenessRowsStrict.mockResolvedValue([]); + + await expect( + flushOpenDocumentEditorToSql({ + documentId: "doc-1", + ownerEmail: "owner@example.com", + }), + ).resolves.toBeUndefined(); + + expect(mocks.appStatePut).not.toHaveBeenCalled(); + }); + + it("uses SQL immediately when the only active collaborators are modern read-only viewers", async () => { + mocks.loadAwarenessRowsStrict.mockResolvedValue([ + { + clientId: 123, + state: JSON.stringify({ + canFlushDocument: false, + visible: true, + user: { email: "viewer@example.com" }, + }), + lastSeen: Date.now(), + }, + ]); + + await expect( + flushOpenDocumentEditorToSql({ + documentId: "doc-1", + ownerEmail: "owner@example.com", + }), + ).resolves.toBeUndefined(); + + expect(mocks.appStatePut).not.toHaveBeenCalled(); + expect(mocks.appStateGet).not.toHaveBeenCalled(); + }); + + it("accepts an acknowledgement from a legacy editor without a capability field", async () => { + mocks.loadAwarenessRowsStrict.mockResolvedValue([ + { + clientId: 456, + state: JSON.stringify({ + visible: true, + user: { email: "legacy-editor@example.com" }, + }), + lastSeen: Date.now(), + }, + ]); + mocks.appStateGet.mockImplementation(async () => ({ + id: "doc-1", + requestId: mocks.appStatePut.mock.calls[0]?.[2]?.requestId, + status: "success", + })); + + const flush = flushOpenDocumentEditorToSql({ + documentId: "doc-1", + ownerEmail: "owner@example.com", + }); + await vi.advanceTimersByTimeAsync(200); + + await expect(flush).resolves.toBeUndefined(); + expect(mocks.appStatePut).toHaveBeenCalledWith( + "legacy-editor@example.com", + "flush-request-doc-1", + expect.objectContaining({ id: "doc-1" }), + { requestSource: "agent" }, + ); + }); + + it("honors an explicit save error from a legacy editor", async () => { + mocks.loadAwarenessRowsStrict.mockResolvedValue([ + { + clientId: 456, + state: JSON.stringify({ + visible: true, + user: { email: "legacy-editor@example.com" }, + }), + lastSeen: Date.now(), + }, + ]); + mocks.appStateGet.mockImplementation(async () => ({ + id: "doc-1", + requestId: mocks.appStatePut.mock.calls[0]?.[2]?.requestId, + status: "error", + error: "Legacy editor could not serialize the live document.", + })); + + const flush = flushOpenDocumentEditorToSql({ + documentId: "doc-1", + ownerEmail: "owner@example.com", + }); + const rejected = expect(flush).rejects.toThrow( + "Legacy editor could not serialize the live document.", + ); + await vi.advanceTimersByTimeAsync(200); + + await rejected; + }); + + it("falls back to SQL after a legacy-only editor handshake stays silent", async () => { + mocks.loadAwarenessRowsStrict.mockResolvedValue([ + { + clientId: 456, + state: JSON.stringify({ + visible: true, + user: { email: "legacy-viewer-or-editor@example.com" }, + }), + lastSeen: Date.now(), + }, + ]); + mocks.appStateGet.mockImplementation(async () => ({ + id: "doc-1", + requestId: mocks.appStatePut.mock.calls[0]?.[2]?.requestId, + status: "pending", + })); + + const flush = flushOpenDocumentEditorToSql({ + documentId: "doc-1", + ownerEmail: "owner@example.com", + }); + await vi.advanceTimersByTimeAsync(4_200); + + await expect(flush).resolves.toBeUndefined(); + expect(mocks.appStatePut).toHaveBeenCalledWith( + "legacy-viewer-or-editor@example.com", + "flush-request-doc-1", + expect.objectContaining({ id: "doc-1" }), + { requestSource: "agent" }, + ); + expect(mocks.appStateDelete).toHaveBeenCalled(); + }); + + it("falls back to SQL when a legacy-only flush request cannot be written", async () => { + mocks.loadAwarenessRowsStrict.mockResolvedValue([ + { + clientId: 456, + state: JSON.stringify({ + visible: true, + user: { email: "legacy-editor@example.com" }, + }), + lastSeen: Date.now(), + }, + ]); + mocks.appStatePut.mockRejectedValue(new Error("connection unavailable")); + + await expect( + flushOpenDocumentEditorToSql({ + documentId: "doc-1", + ownerEmail: "owner@example.com", + }), + ).resolves.toBeUndefined(); + }); + + it("still waits when an edit-capable collaborator is present beside viewers", async () => { + mocks.loadAwarenessRowsStrict.mockResolvedValue([ + { + clientId: 123, + state: JSON.stringify({ + canFlushDocument: false, + visible: true, + user: { email: "viewer@example.com" }, + }), + lastSeen: Date.now(), + }, + { + clientId: 456, + state: JSON.stringify({ + canFlushDocument: true, + visible: true, + user: { email: "shared-editor@example.com" }, + }), + lastSeen: Date.now(), + }, + ]); + mocks.appStateGet.mockImplementation(async () => ({ + id: "doc-1", + requestId: mocks.appStatePut.mock.calls[0]?.[2]?.requestId, + status: "success", + })); + + const flush = flushOpenDocumentEditorToSql({ + documentId: "doc-1", + ownerEmail: "owner@example.com", + }); + await vi.advanceTimersByTimeAsync(200); + + await expect(flush).resolves.toBeUndefined(); + expect(mocks.appStatePut).toHaveBeenCalledWith( + "shared-editor@example.com", + "flush-request-doc-1", + expect.objectContaining({ id: "doc-1" }), + { requestSource: "agent" }, + ); + }); + + it("still fails closed when a modern editor is silent beside a legacy tab", async () => { + mocks.loadAwarenessRowsStrict.mockResolvedValue([ + { + clientId: 123, + state: JSON.stringify({ + canFlushDocument: true, + visible: true, + user: { email: "modern-editor@example.com" }, + }), + lastSeen: Date.now(), + }, + { + clientId: 456, + state: JSON.stringify({ + visible: true, + user: { email: "legacy-viewer-or-editor@example.com" }, + }), + lastSeen: Date.now(), + }, + ]); + mocks.appStateGet.mockImplementation(async () => ({ + id: "doc-1", + requestId: mocks.appStatePut.mock.calls[0]?.[2]?.requestId, + status: "pending", + })); + + const flush = flushOpenDocumentEditorToSql({ + documentId: "doc-1", + ownerEmail: "owner@example.com", + }); + const rejected = expect(flush).rejects.toThrow(/did not finish saving/i); + await vi.advanceTimersByTimeAsync(4_200); + + await rejected; + }); +}); diff --git a/templates/content/actions/_document-flush.ts b/templates/content/actions/_document-flush.ts index cce844c6b1..cd102e79eb 100644 --- a/templates/content/actions/_document-flush.ts +++ b/templates/content/actions/_document-flush.ts @@ -1,62 +1,177 @@ +import { randomUUID } from "node:crypto"; + import { appStateDelete, appStateGet, appStatePut, } from "@agent-native/core/application-state"; -import { hasCollabState } from "@agent-native/core/collab"; +import { + AGENT_CLIENT_ID, + hasCollabState, + loadAwarenessRowsStrict, +} from "@agent-native/core/collab"; import { getRequestUserEmail } from "@agent-native/core/server/request-context"; const FLUSH_POLL_INTERVAL_MS = 200; const FLUSH_TIMEOUT_MS = 4000; +function parseAwarenessState(state: string): { + canFlushDocument?: unknown; + visible?: boolean; + user?: { email?: unknown }; +} | null { + try { + return JSON.parse(state) as { + canFlushDocument?: unknown; + visible?: boolean; + user?: { email?: unknown }; + }; + } catch { + return null; + } +} + +function awarenessFlushCandidate(entry: { + clientId: number; + state: string; +}): { sessionEmail: string | null; required: boolean } | null { + if (entry.clientId === AGENT_CLIENT_ID) return null; + const state = parseAwarenessState(entry.state); + if (!state || state.visible === false || !state.user) return null; + // New clients publish an exact boolean. `false` is a known read-only viewer + // and must never block. A missing field is a pre-deploy client which may be + // an editor, so offer it the old handshake on a best-effort basis. Invalid + // values are neither a trustworthy editor capability nor a legacy omission. + if (state.canFlushDocument !== true && state.canFlushDocument !== undefined) { + return null; + } + const email = state.user.email; + return { + sessionEmail: + typeof email === "string" && email.trim() ? email.trim() : null, + required: state.canFlushDocument === true, + }; +} + export async function flushOpenDocumentEditorToSql(args: { documentId: string; ownerEmail?: string | null; }) { // If a live Yjs collab session is open, the in-memory editor doc is fresher // than the SQL column. Ask the open editor to serialize + save, then wait - // for it to acknowledge by clearing the flush-request key. + // for an explicit request-id-matched acknowledgement. if (!(await hasCollabState(args.documentId))) return; + // Persisted Yjs state outlives browser tabs. Modern clients distinguish + // editors (`true`) from viewers (`false`), so only the former are a hard + // freshness barrier. Pre-deploy tabs omit the field; they still know how to + // service this request, but may also be legacy viewers, so offer them the + // bounded handshake without failing if they stay silent. This preserves live + // legacy editor changes without making viewer-only tabs time out sync actions. + const awarenessRows = await loadAwarenessRowsStrict(args.documentId); + const flushCandidates = awarenessRows + .map(awarenessFlushCandidate) + .filter((candidate): candidate is NonNullable => { + return candidate !== null; + }); + if (flushCandidates.length === 0) return; + const acknowledgementRequired = flushCandidates.some( + (candidate) => candidate.required, + ); + const activeSessionEmails = flushCandidates + .map((candidate) => candidate.sessionEmail) + .filter((email): email is string => !!email); + const flushKey = `flush-request-${args.documentId}`; // The editor polls `flush-request-` via the framework app-state route, - // which scopes reads to the logged-in browser user (the document owner). - // Writing under the caller's (external agent's) session alone can miss the - // human editor tab, so write under both plausible sessions and de-dupe. + // which scopes reads to the logged-in browser user. Target every active + // collaborator email plus owner/caller fallbacks so shared editors and + // cross-instance actions reach the tab that can serialize the live Y.Doc. const callerEmail = getRequestUserEmail() || undefined; const targetSessions = Array.from( new Set( - [args.ownerEmail ?? undefined, callerEmail].filter( - (s): s is string => typeof s === "string" && s.length > 0, - ), + [ + ...activeSessionEmails, + args.ownerEmail ?? undefined, + callerEmail, + ].filter((s): s is string => typeof s === "string" && s.length > 0), ), ); - if (targetSessions.length === 0) return; + if (targetSessions.length === 0) { + if (!acknowledgementRequired) return; + throw new Error("Could not identify the open document editor to flush."); + } - const flushValue = { id: args.documentId, ts: Date.now() }; - await Promise.all( + const requestId = randomUUID(); + const flushValue = { + id: args.documentId, + ts: Date.now(), + requestId, + status: "pending", + }; + const writes = await Promise.allSettled( targetSessions.map((session) => appStatePut(session, flushKey, flushValue, { requestSource: "agent", - }).catch(() => {}), + }), ), ); + const writtenSessions = targetSessions.filter( + (_session, index) => writes[index]?.status === "fulfilled", + ); + if (writtenSessions.length === 0) { + if (!acknowledgementRequired) return; + throw new Error("Could not ask the open document editor to save."); + } const deadline = Date.now() + FLUSH_TIMEOUT_MS; + let flushError: string | null = null; + let acknowledged = false; while (Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, FLUSH_POLL_INTERVAL_MS)); - const pending = await Promise.all( - targetSessions.map((session) => appStateGet(session, flushKey)), + const reads = await Promise.allSettled( + writtenSessions.map((session) => appStateGet(session, flushKey)), + ); + const responses = reads.flatMap((result) => + result.status === "fulfilled" && result.value ? [result.value] : [], + ); + const failed = responses.find( + ( + value, + ): value is { + requestId: string; + status: "error"; + error?: string; + } => value.requestId === requestId && value.status === "error", + ); + if (failed) { + flushError = + typeof failed.error === "string" && failed.error.trim() + ? failed.error + : "The live document could not be saved before syncing."; + break; + } + acknowledged = responses.some( + (value) => value.requestId === requestId && value.status === "success", ); - if (pending.every((value) => !value)) break; + if (acknowledged) break; } - // Best-effort cleanup if the editor never picked it up (no tab open). + // Best-effort cleanup after success, explicit failure, or timeout. await Promise.all( - targetSessions.map((session) => + writtenSessions.map((session) => appStateDelete(session, flushKey, { requestSource: "agent" }).catch( () => {}, ), ), ); + + if (flushError) { + throw new Error(flushError); + } + if (!acknowledged && acknowledgementRequired) { + throw new Error( + "The open document editor did not finish saving before sync timed out.", + ); + } } diff --git a/templates/content/actions/_notion-action-utils.test.ts b/templates/content/actions/_notion-action-utils.test.ts index 393b483d04..0839511b74 100644 --- a/templates/content/actions/_notion-action-utils.test.ts +++ b/templates/content/actions/_notion-action-utils.test.ts @@ -4,6 +4,7 @@ const mocks = vi.hoisted(() => ({ getRequestOrgId: vi.fn(), getRequestUserEmail: vi.fn(), assertAccess: vi.fn(), + flushOpenDocumentEditorToSql: vi.fn(), })); vi.mock("@agent-native/core/server", () => ({ @@ -15,7 +16,12 @@ vi.mock("@agent-native/core/sharing", () => ({ assertAccess: mocks.assertAccess, })); +vi.mock("./_document-flush.js", () => ({ + flushOpenDocumentEditorToSql: mocks.flushOpenDocumentEditorToSql, +})); + import { + flushNotionDocumentEditor, getCurrentNotionOwner, getNotionDocumentOwner, resolveDocumentId, @@ -25,6 +31,7 @@ beforeEach(() => { mocks.getRequestOrgId.mockReset(); mocks.getRequestUserEmail.mockReset(); mocks.assertAccess.mockReset(); + mocks.flushOpenDocumentEditorToSql.mockReset(); }); describe("getCurrentNotionOwner", () => { @@ -103,6 +110,19 @@ describe("getNotionDocumentOwner", () => { }); }); +describe("flushNotionDocumentEditor", () => { + it("flushes the open editor under the document owner's session", async () => { + mocks.flushOpenDocumentEditorToSql.mockResolvedValue(undefined); + + await flushNotionDocumentEditor("doc-1", "owner@example.com"); + + expect(mocks.flushOpenDocumentEditorToSql).toHaveBeenCalledWith({ + documentId: "doc-1", + ownerEmail: "owner@example.com", + }); + }); +}); + describe("resolveDocumentId", () => { it("prefers documentId over id", () => { expect(resolveDocumentId({ documentId: "a", id: "b" })).toBe("a"); diff --git a/templates/content/actions/_notion-action-utils.ts b/templates/content/actions/_notion-action-utils.ts index 2e842bb35e..0df8bb36aa 100644 --- a/templates/content/actions/_notion-action-utils.ts +++ b/templates/content/actions/_notion-action-utils.ts @@ -4,6 +4,8 @@ import { } from "@agent-native/core/server"; import { assertAccess } from "@agent-native/core/sharing"; +import { flushOpenDocumentEditorToSql } from "./_document-flush.js"; + export function getCurrentNotionOwner() { const owner = getRequestUserEmail(); if (!owner) throw new Error("no authenticated user"); @@ -23,6 +25,19 @@ export async function getNotionDocumentOwner(documentId: string) { return owner; } +/** + * Flush the live collaborative editor before a user-triggered Notion operation + * reads or replaces SQL content. The Y.Doc can be ahead of the debounced + * documents row; without this handshake "Use local" can push a stale snapshot, + * while "Use Notion" can discard edits that never reached version history. + */ +export async function flushNotionDocumentEditor( + documentId: string, + ownerEmail: string, +) { + await flushOpenDocumentEditorToSql({ documentId, ownerEmail }); +} + export function resolveDocumentId(args: { documentId?: string; id?: string }) { const documentId = args.documentId?.trim() || args.id?.trim(); if (!documentId) { diff --git a/templates/content/actions/create-and-link-notion-page.ts b/templates/content/actions/create-and-link-notion-page.ts index dc33161ccf..753afc5d72 100644 --- a/templates/content/actions/create-and-link-notion-page.ts +++ b/templates/content/actions/create-and-link-notion-page.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { createAndLinkNotionPage } from "../server/lib/notion-sync.js"; import { + flushNotionDocumentEditor, getNotionDocumentOwner, resolveDocumentId, } from "./_notion-action-utils.js"; @@ -18,6 +19,7 @@ export default defineAction({ run: async (args) => { const documentId = resolveDocumentId(args); const owner = await getNotionDocumentOwner(documentId); + await flushNotionDocumentEditor(documentId, owner); return createAndLinkNotionPage(owner, documentId, args.parentPageIdOrUrl); }, }); diff --git a/templates/content/actions/link-notion-page.ts b/templates/content/actions/link-notion-page.ts index a7a70e2c66..d035fabaae 100644 --- a/templates/content/actions/link-notion-page.ts +++ b/templates/content/actions/link-notion-page.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { linkDocumentToNotionPage } from "../server/lib/notion-sync.js"; import { + flushNotionDocumentEditor, getNotionDocumentOwner, resolveDocumentId, } from "./_notion-action-utils.js"; @@ -26,6 +27,7 @@ export default defineAction({ } const owner = await getNotionDocumentOwner(documentId); + await flushNotionDocumentEditor(documentId, owner); return linkDocumentToNotionPage(owner, documentId, pageIdOrUrl); }, }); diff --git a/templates/content/actions/notion-flush-actions.test.ts b/templates/content/actions/notion-flush-actions.test.ts new file mode 100644 index 0000000000..1ac88354f3 --- /dev/null +++ b/templates/content/actions/notion-flush-actions.test.ts @@ -0,0 +1,114 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + createAndLinkNotionPage: vi.fn(), + flushNotionDocumentEditor: vi.fn(), + getNotionDocumentOwner: vi.fn(), + linkDocumentToNotionPage: vi.fn(), + pullDocumentFromNotion: vi.fn(), + pushDocumentToNotion: vi.fn(), + resolveDocumentSyncConflict: vi.fn(), +})); + +vi.mock("../server/lib/notion-sync.js", () => ({ + createAndLinkNotionPage: mocks.createAndLinkNotionPage, + linkDocumentToNotionPage: mocks.linkDocumentToNotionPage, + pullDocumentFromNotion: mocks.pullDocumentFromNotion, + pushDocumentToNotion: mocks.pushDocumentToNotion, + resolveDocumentSyncConflict: mocks.resolveDocumentSyncConflict, +})); + +vi.mock("./_notion-action-utils.js", () => ({ + flushNotionDocumentEditor: mocks.flushNotionDocumentEditor, + getNotionDocumentOwner: mocks.getNotionDocumentOwner, + resolveDocumentId: (args: { documentId?: string; id?: string }) => + args.documentId ?? args.id ?? "", +})); + +import createAndLinkAction from "./create-and-link-notion-page"; +import linkAction from "./link-notion-page"; +import pullAction from "./pull-notion-page"; +import pushAction from "./push-notion-page"; +import resolveAction from "./resolve-notion-sync-conflict"; + +beforeEach(() => { + vi.clearAllMocks(); + mocks.getNotionDocumentOwner.mockResolvedValue("owner@example.com"); + mocks.flushNotionDocumentEditor.mockResolvedValue(undefined); +}); + +describe("user-triggered Notion action flushes", () => { + it("flushes the live editor before pulling", async () => { + await pullAction.run({ documentId: "doc-1" }); + + expect(mocks.flushNotionDocumentEditor).toHaveBeenCalledWith( + "doc-1", + "owner@example.com", + ); + expect( + mocks.flushNotionDocumentEditor.mock.invocationCallOrder[0], + ).toBeLessThan(mocks.pullDocumentFromNotion.mock.invocationCallOrder[0]); + }); + + it("flushes the live editor before resolving either conflict direction", async () => { + await resolveAction.run({ documentId: "doc-1", direction: "pull" }); + + expect(mocks.flushNotionDocumentEditor).toHaveBeenCalledWith( + "doc-1", + "owner@example.com", + ); + expect( + mocks.flushNotionDocumentEditor.mock.invocationCallOrder[0], + ).toBeLessThan( + mocks.resolveDocumentSyncConflict.mock.invocationCallOrder[0], + ); + }); + + it("flushes manual pushes but skips the redundant post-save auto-push flush", async () => { + await pushAction.run({ + documentId: "doc-1", + flushOpenEditor: true, + }); + expect(mocks.flushNotionDocumentEditor).toHaveBeenCalledTimes(1); + + vi.clearAllMocks(); + mocks.getNotionDocumentOwner.mockResolvedValue("owner@example.com"); + await pushAction.run({ + documentId: "doc-1", + flushOpenEditor: false, + }); + + expect(mocks.flushNotionDocumentEditor).not.toHaveBeenCalled(); + expect(mocks.pushDocumentToNotion).toHaveBeenCalledWith( + "owner@example.com", + "doc-1", + ); + }); + + it("flushes before linking to an existing Notion page", async () => { + await linkAction.run({ + documentId: "doc-1", + pageId: "example-page-id", + }); + + expect(mocks.flushNotionDocumentEditor).toHaveBeenCalledWith( + "doc-1", + "owner@example.com", + ); + expect( + mocks.flushNotionDocumentEditor.mock.invocationCallOrder[0], + ).toBeLessThan(mocks.linkDocumentToNotionPage.mock.invocationCallOrder[0]); + }); + + it("flushes before creating a linked Notion page", async () => { + await createAndLinkAction.run({ documentId: "doc-1" }); + + expect(mocks.flushNotionDocumentEditor).toHaveBeenCalledWith( + "doc-1", + "owner@example.com", + ); + expect( + mocks.flushNotionDocumentEditor.mock.invocationCallOrder[0], + ).toBeLessThan(mocks.createAndLinkNotionPage.mock.invocationCallOrder[0]); + }); +}); diff --git a/templates/content/actions/pull-document.ts b/templates/content/actions/pull-document.ts index 0208deb53a..4512526af8 100644 --- a/templates/content/actions/pull-document.ts +++ b/templates/content/actions/pull-document.ts @@ -29,12 +29,10 @@ import { * never saw the key, so every external `pull-document` waited the full * timeout and returned stale DB content. * 2. The editor polls that key, serializes its current Y.Doc to markdown - * through its existing serializer, calls `update-document`, then deletes - * the key. - * 3. We poll (across both candidate sessions) for the key to disappear - * (flush acknowledged) and then read the now-fresh row. If the key never - * clears (no editor actually open), we fall back to the DB column, which - * is the best available snapshot. + * through its existing serializer, calls `update-document`, then writes an + * explicit success/error acknowledgement for that request id. + * 3. We poll every active collaborator session for the acknowledgement, fail + * closed on editor errors/timeouts, and then read the now-fresh row. * * When there is no live collab session the DB column is authoritative and we * skip the handshake entirely. The helper bounds the wait so stale collab @@ -90,7 +88,7 @@ export default defineAction({ // If a live Yjs collab session is open, the in-memory editor doc is fresher // than the SQL column. Ask the open editor to serialize + save, then wait - // for it to acknowledge by clearing the flush-request key. + // for its explicit request-id-matched acknowledgement. await flushOpenDocumentEditorToSql({ documentId: id, ownerEmail: (access.resource.ownerEmail as string | undefined) || null, diff --git a/templates/content/actions/pull-notion-page.ts b/templates/content/actions/pull-notion-page.ts index 4387f17a14..13e2626729 100644 --- a/templates/content/actions/pull-notion-page.ts +++ b/templates/content/actions/pull-notion-page.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { pullDocumentFromNotion } from "../server/lib/notion-sync.js"; import { + flushNotionDocumentEditor, getNotionDocumentOwner, resolveDocumentId, } from "./_notion-action-utils.js"; @@ -17,6 +18,7 @@ export default defineAction({ run: async (args) => { const documentId = resolveDocumentId(args); const owner = await getNotionDocumentOwner(documentId); + await flushNotionDocumentEditor(documentId, owner); return pullDocumentFromNotion(owner, documentId, true); }, }); diff --git a/templates/content/actions/push-notion-page.ts b/templates/content/actions/push-notion-page.ts index da200f3319..803ac63350 100644 --- a/templates/content/actions/push-notion-page.ts +++ b/templates/content/actions/push-notion-page.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { pushDocumentToNotion } from "../server/lib/notion-sync.js"; import { + flushNotionDocumentEditor, getNotionDocumentOwner, resolveDocumentId, } from "./_notion-action-utils.js"; @@ -12,11 +13,20 @@ export default defineAction({ schema: z.object({ documentId: z.string().optional().describe("Document ID (required)"), id: z.string().optional().describe("Alias for --documentId"), + flushOpenEditor: z + .boolean() + .default(true) + .describe( + "Flush an open collaborative editor before pushing (disable only when the caller just persisted the exact editor content)", + ), }), http: { method: "POST" }, run: async (args) => { const documentId = resolveDocumentId(args); const owner = await getNotionDocumentOwner(documentId); + if (args.flushOpenEditor) { + await flushNotionDocumentEditor(documentId, owner); + } return pushDocumentToNotion(owner, documentId); }, }); diff --git a/templates/content/actions/resolve-notion-sync-conflict.ts b/templates/content/actions/resolve-notion-sync-conflict.ts index e19008323f..57bbd03be9 100644 --- a/templates/content/actions/resolve-notion-sync-conflict.ts +++ b/templates/content/actions/resolve-notion-sync-conflict.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { resolveDocumentSyncConflict } from "../server/lib/notion-sync.js"; import { + flushNotionDocumentEditor, getNotionDocumentOwner, resolveDocumentId, } from "./_notion-action-utils.js"; @@ -18,6 +19,7 @@ export default defineAction({ run: async (args) => { const documentId = resolveDocumentId(args); const owner = await getNotionDocumentOwner(documentId); + await flushNotionDocumentEditor(documentId, owner); return resolveDocumentSyncConflict(owner, documentId, args.direction); }, }); diff --git a/templates/content/app/components/editor/DocumentEditor.layout.test.ts b/templates/content/app/components/editor/DocumentEditor.layout.test.ts index 75f236d536..8e839eebe7 100644 --- a/templates/content/app/components/editor/DocumentEditor.layout.test.ts +++ b/templates/content/app/components/editor/DocumentEditor.layout.test.ts @@ -146,6 +146,12 @@ describe("document editor layout", () => { expect(documentEditorSource).toContain( "awareness={collabEnabled ? awareness : null}", ); + expect(documentEditorSource).toContain( + 'awareness.setLocalStateField("canFlushDocument", editorCanEdit)', + ); + expect(documentEditorSource).toContain( + 'awareness.setLocalStateField("canFlushDocument", false)', + ); // Comments stay editor-only — viewers must not open the comment endpoints. expect(documentEditorSource).toContain( @@ -179,6 +185,20 @@ describe("document editor layout", () => { ); }); + it("localizes the live-editor flush failure fallback", () => { + const source = readFileSync( + new URL("./DocumentEditor.tsx", import.meta.url), + { + encoding: "utf8", + }, + ); + + expect(source).toContain('t("editor.liveDocumentSaveBeforeSyncFailed")'); + expect(source).not.toContain( + 'error instanceof Error\n ? error.message\n : "The live document could not be saved before syncing."', + ); + }); + it("lets slash-created page references use the editor save pipeline", () => { const documentEditorSource = readFileSync( new URL("./DocumentEditor.tsx", import.meta.url), diff --git a/templates/content/app/components/editor/DocumentEditor.tsx b/templates/content/app/components/editor/DocumentEditor.tsx index c2384b8d48..b4f9d080c2 100644 --- a/templates/content/app/components/editor/DocumentEditor.tsx +++ b/templates/content/app/components/editor/DocumentEditor.tsx @@ -477,6 +477,18 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) { canEdit && !bodyHydrationPending && (isLocalFileDocument || !collabLoading); canEditRef.current = editorCanEdit; + // Viewers intentionally join awareness so they receive live cursors, but + // only an editor runs the app-state flush poller below. Publish that exact + // capability so server-side pull/push/conflict actions do not wait on a + // read-only tab that can never acknowledge their request. + useEffect(() => { + if (!awareness || !collabEnabled) return; + awareness.setLocalStateField("canFlushDocument", editorCanEdit); + return () => { + awareness.setLocalStateField("canFlushDocument", false); + }; + }, [awareness, collabEnabled, editorCanEdit]); + // Initialize from fetched document, reset on document switch useEffect(() => { if (!document) return; @@ -813,6 +825,10 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) { try { const next = await pushDocumentToNotion.mutateAsync({ documentId, + // The exact editor value was persisted immediately above. Avoid + // a redundant live-editor flush handshake on every auto-sync + // save; manual pushes/conflict choices keep the safe default. + flushOpenEditor: false, }); queryClient.setQueryData( documentSyncStatusQueryKey(documentId, { autoSync }), @@ -1030,8 +1046,21 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) { try { const res = await fetch(flushPath); if (res.ok) { - const pending = (await res.json()) as { id?: string } | null; + const pending = (await res.json()) as { + id?: string; + ts?: number; + requestId?: string; + status?: "pending" | "success" | "error"; + error?: string; + } | null; if (pending && active) { + // A terminal acknowledgement waits for the requesting action to + // read and clear it. Retrying here could hide a failed flush or + // replace the explicit success signal before the server sees it. + if (pending.status === "error" || pending.status === "success") { + if (active) setTimeout(poll, 600); + return; + } const title = localTitleRef.current; const content = localContentRef.current; const updates: Record = {}; @@ -1043,30 +1072,60 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) { try { if (Object.keys(updates).length > 0) { const saved = await persistDocumentUpdates(updates); - // A CAS conflict here means a newer write landed between this - // editor's last reconcile and the flush; leave watermarks - // alone so the external-change effects reconcile this editor - // to the winning server content instead of us claiming the - // now-discarded flush content as saved. - if (!isDocumentUpdateConflict(saved)) { - const savedAt = saved?.updatedAt ?? new Date().toISOString(); - adoptConfirmedSaveWatermarks({ - saved, - savedAt, - title, - content, - updates, - lastSavedTitleRef, - lastSavedContentRef, - }); + if (isDocumentUpdateConflict(saved)) { + // Do not acknowledge a CAS loss as a successful flush. The + // requester must stop instead of pushing/replacing stale SQL. + throw new Error( + "The document changed while preparing it for sync.", + ); } + const savedAt = saved?.updatedAt ?? new Date().toISOString(); + adoptConfirmedSaveWatermarks({ + saved, + savedAt, + title, + content, + updates, + lastSavedTitleRef, + lastSavedContentRef, + }); } - } finally { - // Acknowledge the flush even if nothing changed — the SQL row is - // already current, and pull-document is waiting on this delete. + // Explicitly acknowledge this exact request only after the live + // editor state is confirmed in SQL (or nothing needed saving). + // A delete is ambiguous with a transient app-state read failure. await fetch(flushPath, { - method: "DELETE", - headers: { "X-Agent-Native-CSRF": "1" }, + method: "PUT", + headers: { + "Content-Type": "application/json", + "X-Agent-Native-CSRF": "1", + }, + body: JSON.stringify({ + id: pending.id ?? documentId, + ts: pending.ts ?? Date.now(), + requestId: pending.requestId, + status: "success", + }), + }).catch(() => {}); + } catch (error) { + // Keep a durable negative acknowledgement so the requesting + // Notion action can fail closed instead of timing out and using a + // stale documents row. The server clears this after reading it. + await fetch(flushPath, { + method: "PUT", + headers: { + "Content-Type": "application/json", + "X-Agent-Native-CSRF": "1", + }, + body: JSON.stringify({ + id: pending.id ?? documentId, + ts: pending.ts ?? Date.now(), + requestId: pending.requestId, + status: "error", + error: + error instanceof Error + ? error.message + : t("editor.liveDocumentSaveBeforeSyncFailed"), + }), }).catch(() => {}); } } @@ -1082,7 +1141,13 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) { active = false; clearTimeout(timer); }; - }, [documentId, editorCanEdit, isLocalFileDocument, persistDocumentUpdates]); + }, [ + documentId, + editorCanEdit, + isLocalFileDocument, + persistDocumentUpdates, + t, + ]); const handleTitleChange = useCallback( (newTitle: string) => { diff --git a/templates/content/app/components/editor/VisualEditor.markdown.test.ts b/templates/content/app/components/editor/VisualEditor.markdown.test.ts index c3e03a4c85..0a357e3015 100644 --- a/templates/content/app/components/editor/VisualEditor.markdown.test.ts +++ b/templates/content/app/components/editor/VisualEditor.markdown.test.ts @@ -6,25 +6,33 @@ import { parseNfmForEditor, serializeEditorToNfm, } from "@shared/notion-markdown"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { Editor, getSchema } from "@tiptap/core"; import StarterKit from "@tiptap/starter-kit"; +import { act, createElement } from "react"; +import { createRoot } from "react-dom/client"; +import { MemoryRouter } from "react-router"; import { Markdown } from "tiptap-markdown"; import { afterEach, describe, expect, it, vi } from "vitest"; import { Awareness } from "y-protocols/awareness"; import * as Y from "yjs"; +import { TooltipProvider } from "@/components/ui/tooltip"; + import { CodeBlock } from "./extensions/CodeBlockNode"; import { NotionToggle } from "./extensions/NotionExtensions"; import { createVisualEditorExtensions, EmptyLineParagraph, getRecentEditPresenceMarkerRect, + parseNfmForCollabReconcile, uploadAndInsertAudioFiles, uploadAndInsertImageFiles, uploadAndInsertVideoFiles, shouldApplyExternalContentSync, shouldPersistLocalFileEditorUpdate, shouldSeedCollaborativeContent, + VisualEditor, } from "./VisualEditor"; function createMarkdownEditor(content: string) { @@ -653,6 +661,189 @@ describe("VisualEditor markdown round-tripping", () => { ).toBe(false); }); + it("keeps adjacent NFM blocks separate in collaborative external reconciles", () => { + const editor = createFullEditor(); + const incoming = [ + "→ → slack questions", + '\tmuch simpler "what"', + "\twhat is it and how different from other app builders", + "\twhen to engage prospects", + ].join("\n"); + + try { + const parsed = parseNfmForCollabReconcile(editor, incoming); + + expect(parsed).not.toBeNull(); + expect(parsed?.childCount).toBe(4); + expect( + Array.from( + { length: parsed?.childCount ?? 0 }, + (_, index) => parsed?.child(index).textContent, + ), + ).toEqual([ + "→ → slack questions", + 'much simpler "what"', + "what is it and how different from other app builders", + "when to engage prospects", + ]); + } finally { + editor.destroy(); + } + }); + + it("uses the NFM parser when a newer SQL snapshot reconciles into a live Y.Doc", async () => { + const container = document.createElement("div"); + document.body.appendChild(container); + let root = createRoot(container); + const ydoc = new Y.Doc(); + const incoming = [ + "→ → slack questions", + '\tmuch simpler "what"', + "\twhat is it and how different from other app builders", + "\twhen to engage prospects", + ].join("\n"); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const emitted: string[] = []; + const actEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }; + const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT; + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + const renderEditor = (content: string, contentUpdatedAt: string) => + createElement( + MemoryRouter, + null, + createElement(TooltipProvider, { + delayDuration: 0, + children: createElement( + QueryClientProvider, + { client: queryClient }, + createElement(VisualEditor, { + content, + contentUpdatedAt, + onChange: (markdown) => emitted.push(markdown), + ydoc, + collabSynced: true, + editable: true, + }), + ), + }), + ); + + try { + // Match a real reload after an external version was previously live: seed + // the persisted Y.Doc through the actual VisualEditor, unmount the page, + // then mount a fresh editor whose SQL snapshot points somewhere else. + act(() => { + root.render(renderEditor(incoming, "2026-07-09T19:59:59.000Z")); + }); + await act(() => new Promise((resolve) => setTimeout(resolve, 50))); + act(() => root.unmount()); + root = createRoot(container); + + act(() => { + root.render( + renderEditor("Initial local block", "2026-07-09T20:00:00.000Z"), + ); + }); + await act(() => new Promise((resolve) => setTimeout(resolve, 50))); + expect( + Array.from( + container.querySelectorAll(".notion-editor > p"), + (node) => node.textContent, + ), + ).toEqual(["Initial local block"]); + + act(() => { + root.render(renderEditor(incoming, "2026-07-09T20:00:01.000Z")); + }); + await act(() => new Promise((resolve) => setTimeout(resolve, 50))); + + expect( + Array.from( + container.querySelectorAll(".notion-editor > p"), + (node) => node.textContent, + ), + ).toEqual([ + "→ → slack questions", + 'much simpler "what"', + "what is it and how different from other app builders", + "when to engage prospects", + ]); + expect(emitted).not.toContain(""); + } finally { + await act(async () => root.unmount()); + queryClient.clear(); + ydoc.destroy(); + container.remove(); + actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment; + } + }); + + it("does not clear awareness owned by the shared collab connection on unmount", async () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const ydoc = new Y.Doc(); + const awareness = new Awareness(ydoc); + const queryClient = new QueryClient(); + const actEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }; + const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT; + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + const user = { + name: "Awareness Owner", + email: "awareness-owner@example.com", + color: "#60a5fa", + }; + + try { + act(() => { + root.render( + createElement( + MemoryRouter, + null, + createElement(TooltipProvider, { + children: createElement( + QueryClientProvider, + { client: queryClient }, + createElement(VisualEditor, { + content: "Shared awareness body", + contentUpdatedAt: "2026-07-09T20:00:00.000Z", + onChange: () => {}, + ydoc, + collabSynced: true, + awareness, + user, + editable: true, + }), + ), + }), + ), + ); + }); + await act(() => new Promise((resolve) => setTimeout(resolve, 50))); + expect(awareness.getLocalState()?.user).toMatchObject({ + email: user.email, + }); + + act(() => root.unmount()); + + expect(awareness.getLocalState()?.user).toMatchObject({ + email: user.email, + }); + } finally { + queryClient.clear(); + awareness.destroy(); + ydoc.destroy(); + container.remove(); + actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment; + } + }); + it("does not apply stale SQL snapshots over live collaborative edits", () => { expect( shouldApplyExternalContentSync({ diff --git a/templates/content/app/components/editor/VisualEditor.tsx b/templates/content/app/components/editor/VisualEditor.tsx index 21e0f39ee1..7d949a0f47 100644 --- a/templates/content/app/components/editor/VisualEditor.tsx +++ b/templates/content/app/components/editor/VisualEditor.tsx @@ -822,6 +822,28 @@ export function shouldSeedCollaborativeContent({ return !!content.trim() && (fragmentLength === 0 || !semanticMarkdown); } +/** + * Parse authoritative Content NFM with Content's exact NFM parser before the + * shared reconcile computes its top-level surgical diff. + * + * Falling back to the shared CommonMark parser is lossy here: canonical NFM + * stores one Notion block per line without blank paragraph separators, while + * CommonMark merges those consecutive lines into one paragraph. That made + * external replacements such as Notion conflict resolution and version + * restores look correct in the non-collaborative history preview, then collapse + * into one wrapped paragraph when reconciled into the live Y.Doc. + */ +export function parseNfmForCollabReconcile( + editor: CoreEditor, + value: string, +): ProseMirrorNode | null { + try { + return editor.schema.nodeFromJSON(nfmToDoc(value) as any); + } catch { + return null; + } +} + export function shouldApplyExternalContentSync({ docChanged, content, @@ -1669,10 +1691,14 @@ export function VisualEditor({ // Clean up awareness on unmount useEffect(() => { return () => { - localAwareness?.setLocalState(null); + // Only the fallback instance is owned by this editor. A provided + // awareness belongs to the shared useCollaborativeDoc connection; clearing + // it here races StrictMode/remounts and can erase the tab's durable + // presence while the shared connection is still active. + fallbackAwareness?.setLocalState(null); fallbackAwareness?.destroy(); }; - }, [fallbackAwareness, localAwareness]); + }, [fallbackAwareness]); const extensions = useMemo( () => @@ -1941,6 +1967,10 @@ export function VisualEditor({ e.commands.setContent(doc); }, normalizeValue: canonicalizeNfm, + // The shared fallback parser is CommonMark. Content stores canonical NFM, + // whose adjacent lines are separate Notion blocks, so always provide the + // exact NFM parser for the surgical reconcile path. + parseValue: parseNfmForCollabReconcile, shouldSeed: ({ value, currentMarkdown, fragmentLength }) => editable && shouldSeedCollaborativeContent({ diff --git a/templates/content/app/hooks/use-notion.ts b/templates/content/app/hooks/use-notion.ts index 285f81b39e..cf7e1013f4 100644 --- a/templates/content/app/hooks/use-notion.ts +++ b/templates/content/app/hooks/use-notion.ts @@ -253,12 +253,12 @@ export function usePullDocumentFromNotion(documentId: string) { export function usePushDocumentToNotion(documentId: string) { const queryClient = useQueryClient(); - return useActionMutation( - "push-notion-page", - { - onSuccess: () => invalidateDocumentQueries(queryClient, documentId), - }, - ); + return useActionMutation< + DocumentSyncStatus, + { documentId: string; flushOpenEditor?: boolean } + >("push-notion-page", { + onSuccess: () => invalidateDocumentQueries(queryClient, documentId), + }); } export function useResolveDocumentSyncConflict(documentId: string) { diff --git a/templates/content/app/i18n-data.ts b/templates/content/app/i18n-data.ts index aa46d0a075..a056d6ae2b 100644 --- a/templates/content/app/i18n-data.ts +++ b/templates/content/app/i18n-data.ts @@ -2463,6 +2463,8 @@ const enUS = { couldNotSaveLocalFile: "Could not save local file", collabConnectingReadOnly: "Connecting live editor. Showing a read-only snapshot.", + liveDocumentSaveBeforeSyncFailed: + "The live document could not be saved before syncing.", documentTitle: "Document title", builderBodySyncing: "Content is still syncing from Builder", builderBodySyncingDescription: @@ -4843,6 +4845,7 @@ const editorMessagesByLocale = { "zh-CN": { noDocumentSelected: "未选择文档", collabConnectingReadOnly: "正在连接实时编辑器。显示只读快照。", + liveDocumentSaveBeforeSyncFailed: "实时文档无法在同步前保存。", builderBodySyncing: "内容仍在从 Builder 同步", builderBodySyncingDescription: "同步 Builder 正文完成前会暂停编辑,避免覆盖现有文章内容。", @@ -5169,6 +5172,8 @@ const editorMessagesByLocale = { noDocumentSelected: "Ningún documento seleccionado", collabConnectingReadOnly: "Conectando el editor en vivo. Mostrando una instantánea de solo lectura.", + liveDocumentSaveBeforeSyncFailed: + "No se pudo guardar el documento activo antes de sincronizarlo.", builderBodySyncing: "El contenido aún se está sincronizando desde Builder", builderBodySyncingDescription: "La edición está en pausa hasta que el cuerpo de Builder termine de sincronizarse, para no sobrescribir el contenido existente del artículo.", @@ -5506,6 +5511,8 @@ const editorMessagesByLocale = { noDocumentSelected: "Aucun document sélectionné", collabConnectingReadOnly: "Connexion de l'éditeur en direct. Affichage d'un instantané en lecture seule.", + liveDocumentSaveBeforeSyncFailed: + "Le document actif n’a pas pu être enregistré avant la synchronisation.", builderBodySyncing: "Le contenu est encore en cours de synchronisation depuis Builder", builderBodySyncingDescription: @@ -5846,6 +5853,8 @@ const editorMessagesByLocale = { noDocumentSelected: "Kein Dokument ausgewählt", collabConnectingReadOnly: "Live-Editor wird verbunden. Schreibgeschützte Momentaufnahme wird angezeigt.", + liveDocumentSaveBeforeSyncFailed: + "Das aktive Dokument konnte vor der Synchronisierung nicht gespeichert werden.", builderBodySyncing: "Inhalte werden noch von Builder synchronisiert", builderBodySyncingDescription: "Die Bearbeitung ist pausiert, bis der Builder-Textkörper fertig synchronisiert ist, damit der bestehende Artikelinhalt nicht überschrieben wird.", @@ -6190,6 +6199,8 @@ const editorMessagesByLocale = { noDocumentSelected: "ドキュメントが選択されていません", collabConnectingReadOnly: "ライブエディターに接続中。読み取り専用のスナップショットを表示しています。", + liveDocumentSaveBeforeSyncFailed: + "同期前にライブドキュメントを保存できませんでした。", builderBodySyncing: "コンテンツはまだ Builder から同期中です", builderBodySyncingDescription: "既存の記事内容を上書きしないよう、Builder 本文の同期が完了するまで編集は一時停止されます。", @@ -6524,6 +6535,8 @@ const editorMessagesByLocale = { noDocumentSelected: "선택한 문서가 없습니다.", collabConnectingReadOnly: "라이브 편집기에 연결하는 중입니다. 읽기 전용 스냅샷을 표시합니다.", + liveDocumentSaveBeforeSyncFailed: + "동기화하기 전에 실시간 문서를 저장하지 못했습니다.", builderBodySyncing: "콘텐츠가 아직 Builder에서 동기화되는 중입니다", builderBodySyncingDescription: "기존 문서 내용을 덮어쓰지 않도록 Builder 본문 동기화가 완료될 때까지 편집이 일시 중지됩니다.", @@ -6857,6 +6870,8 @@ const editorMessagesByLocale = { noDocumentSelected: "Nenhum documento selecionado", collabConnectingReadOnly: "Conectando o editor ao vivo. Exibindo um instantâneo somente leitura.", + liveDocumentSaveBeforeSyncFailed: + "Não foi possível salvar o documento ativo antes da sincronização.", builderBodySyncing: "O conteúdo ainda está sincronizando do Builder", builderBodySyncingDescription: "A edição fica pausada até o corpo do Builder terminar de sincronizar, para não sobrescrever o conteúdo existente do artigo.", @@ -7196,6 +7211,8 @@ const editorMessagesByLocale = { noDocumentSelected: "कोई दस्तावेज़ चयनित नहीं", collabConnectingReadOnly: "लाइव संपादक कनेक्ट हो रहा है। केवल-पठन स्नैपशॉट दिखाया जा रहा है।", + liveDocumentSaveBeforeSyncFailed: + "सिंक करने से पहले लाइव दस्तावेज़ सहेजा नहीं जा सका।", builderBodySyncing: "सामग्री अभी भी Builder से सिंक हो रही है", builderBodySyncingDescription: "Builder का मुख्य भाग सिंक पूरा होने तक संपादन रोका गया है, ताकि मौजूदा लेख सामग्री अधिलेखित न हो।", @@ -7525,6 +7542,7 @@ const editorMessagesByLocale = { noDocumentSelected: "لم يتم تحديد أي مستند", collabConnectingReadOnly: "جارٍ الاتصال بالمحرر المباشر. يتم عرض لقطة للقراءة فقط.", + liveDocumentSaveBeforeSyncFailed: "تعذّر حفظ المستند المباشر قبل المزامنة.", builderBodySyncing: "لا يزال المحتوى قيد المزامنة من Builder", builderBodySyncingDescription: "يتم إيقاف التحرير مؤقتًا حتى تكتمل مزامنة نص Builder، حتى لا يتم استبدال محتوى المقالة الحالي.", diff --git a/templates/content/app/i18n/zh-TW.ts b/templates/content/app/i18n/zh-TW.ts index ffe7109512..bb1f732ccb 100644 --- a/templates/content/app/i18n/zh-TW.ts +++ b/templates/content/app/i18n/zh-TW.ts @@ -94,6 +94,7 @@ const messages = { couldNotReadLocalSourceFile: "無法讀取本機來源檔案", couldNotSaveLocalFile: "無法儲存本機檔案", collabConnectingReadOnly: "正在連接即時編輯器。顯示唯讀快照。", + liveDocumentSaveBeforeSyncFailed: "即時文件無法在同步前儲存。", documentTitle: "檔案標題", builderBodySyncing: "內容仍在從 Builder 同步", builderBodySyncingDescription: diff --git a/templates/content/changelog/2026-07-09-notion-conflict-choices-and-version-restores-preserve-docume.md b/templates/content/changelog/2026-07-09-notion-conflict-choices-and-version-restores-preserve-docume.md new file mode 100644 index 0000000000..fd687dd733 --- /dev/null +++ b/templates/content/changelog/2026-07-09-notion-conflict-choices-and-version-restores-preserve-docume.md @@ -0,0 +1,6 @@ +--- +type: fixed +date: 2026-07-09 +--- + +Notion conflict choices and version restores preserve document paragraphs and protect live edits. diff --git a/templates/content/server/lib/notion-sync.spec.ts b/templates/content/server/lib/notion-sync.spec.ts index 8e14f9539b..c5ce24cb4e 100644 --- a/templates/content/server/lib/notion-sync.spec.ts +++ b/templates/content/server/lib/notion-sync.spec.ts @@ -22,6 +22,7 @@ const testState = vi.hoisted(() => ({ }, link: null as any, comments: [] as Array>, + versions: [] as Array>, })); const notionMocks = vi.hoisted(() => { @@ -106,6 +107,14 @@ vi.mock("../db/index.js", () => { ownerEmail: "documentSyncLinks.ownerEmail", syncClaimedAt: "documentSyncLinks.syncClaimedAt", }, + documentVersions: { + id: "documentVersions.id", + documentId: "documentVersions.documentId", + ownerEmail: "documentVersions.ownerEmail", + title: "documentVersions.title", + content: "documentVersions.content", + createdAt: "documentVersions.createdAt", + }, documentComments: { documentId: "documentComments.documentId", ownerEmail: "documentComments.ownerEmail", @@ -143,7 +152,7 @@ vi.mock("../db/index.js", () => { return true; } - const db = { + const db: any = { select: () => ({ from: (table: unknown) => ({ where: async () => { @@ -157,17 +166,23 @@ vi.mock("../db/index.js", () => { }), }), insert: (table: unknown) => ({ - values: (row: Record) => ({ - onConflictDoUpdate: async ({ - set, - }: { - set: Record; - }) => { - if (table === schema.documentSyncLinks) { - testState.link = { ...row, ...set }; - } - }, - }), + values: (row: Record) => { + if (table === schema.documentVersions) { + testState.versions.push(row); + return Promise.resolve(); + } + return { + onConflictDoUpdate: async ({ + set, + }: { + set: Record; + }) => { + if (table === schema.documentSyncLinks) { + testState.link = { ...row, ...set }; + } + }, + }; + }, }), update: (table: unknown) => ({ set: (updates: Record) => ({ @@ -213,6 +228,7 @@ vi.mock("../db/index.js", () => { } }, }), + transaction: async (run: (tx: unknown) => Promise) => run(db), }; return { getDb: () => db, schema }; @@ -403,6 +419,7 @@ describe("getDocumentSyncStatus", () => { hasConflict: false, warningsJson: "[]", }; + testState.versions = []; notionMocks.getNotionConnectionForOwner.mockResolvedValue({ accessToken: "notion-token", }); @@ -458,6 +475,7 @@ describe("pullDocumentFromNotion", () => { hasConflict: false, warningsJson: "[]", }; + testState.versions = []; notionMocks.getNotionConnectionForOwner.mockResolvedValue({ accessToken: "notion-token", @@ -528,6 +546,13 @@ describe("pullDocumentFromNotion", () => { expect(testState.link?.lastSyncedContentHash).toBe( hashContentForTest("Remote edit from Notion"), ); + expect(testState.versions).toContainEqual( + expect.objectContaining({ + documentId: "doc-1", + title: "Local title", + content: "Local body", + }), + ); expect(status.hasConflict).toBe(false); }); }); @@ -560,6 +585,7 @@ describe("refreshDocumentSyncStatus", () => { hasConflict: false, warningsJson: "[]", }; + testState.versions = []; notionMocks.getNotionConnectionForOwner.mockResolvedValue({ accessToken: "notion-token", @@ -921,6 +947,7 @@ describe("pushDocumentToNotion", () => { hasConflict: false, warningsJson: "[]", }; + testState.versions = []; notionMocks.getNotionConnectionForOwner.mockResolvedValue({ accessToken: "notion-token", @@ -1023,6 +1050,9 @@ describe("pushDocumentToNotion", () => { // The concurrent save must survive; normalized old content must not land. expect(testState.document.content).toBe("Newer local save mid-push"); + // A lost replacement CAS must not create a history entry for a + // replacement that never happened. + expect(testState.versions).toHaveLength(0); }); it("leaves localChanged false when Notion's post-push readback normalizes the content (n-C)", async () => { @@ -1064,6 +1094,13 @@ describe("pushDocumentToNotion", () => { expect(testState.document.content).toBe( "Local edit typed just now (normalized)", ); + expect(testState.versions).toContainEqual( + expect.objectContaining({ + documentId: "doc-1", + title: "Local title", + content: "Local edit typed just now", + }), + ); expect(testState.link?.lastSyncedContentHash).toBe( hashContentForTest("Local edit typed just now (normalized)"), ); diff --git a/templates/content/server/lib/notion-sync.ts b/templates/content/server/lib/notion-sync.ts index ccb189cb4a..13c5d88292 100644 --- a/templates/content/server/lib/notion-sync.ts +++ b/templates/content/server/lib/notion-sync.ts @@ -2,7 +2,6 @@ // in pnpm's node_modules. Logic is correct; types just don't unify across instances. import crypto from "node:crypto"; -import { deleteCollabState, releaseDoc } from "@agent-native/core/collab"; import { and, eq, inArray, isNull, lt, or } from "drizzle-orm"; import type { InferSelectModel } from "drizzle-orm"; @@ -37,6 +36,56 @@ function nanoid(size = 12): string { return Array.from(bytes, (byte) => chars[byte % chars.length]).join(""); } +async function replaceDocumentFromExternal(args: { + document: DocumentRow; + expectedUpdatedAt: string; + title: string; + content: string; + icon: string | null; + updatedAt: string; +}): Promise { + const db = getDb(); + return db.transaction(async (tx) => { + const applied = await tx + .update(schema.documents) + .set({ + title: args.title, + content: args.content, + icon: args.icon, + updatedAt: args.updatedAt, + }) + .where( + and( + eq(schema.documents.id, args.document.id), + eq(schema.documents.ownerEmail, args.document.ownerEmail), + eq(schema.documents.updatedAt, args.expectedUpdatedAt), + ), + ) + .returning({ id: schema.documents.id }); + + if (!applied || applied.length === 0) return false; + + if ( + args.title !== args.document.title || + args.content !== args.document.content + ) { + // Keep the recovery snapshot in the same transaction as the replacement: + // a lost CAS creates no phantom version, and a snapshot failure rolls the + // destructive replacement back instead of leaving it unrecoverable. + await tx.insert(schema.documentVersions).values({ + id: nanoid(), + ownerEmail: args.document.ownerEmail, + documentId: args.document.id, + title: args.document.title, + content: args.document.content, + createdAt: nowIso(), + }); + } + + return true; + }); +} + /** * Hash of the canonical content. Two documents with the same hash are * byte-identical once canonicalized, so this is the authoritative "did the @@ -827,8 +876,6 @@ async function pullDocumentFromNotionInner( remotePageDocumentIdByPageId?: RemotePageDocumentLookup; }, ): Promise { - const db = getDb(); - const document = await getDocument(documentId, owner); const link = await getSyncLink(documentId, owner); if (!link) throw new Error("Document is not linked to a Notion page."); const connection = await getNotionConnectionForOwner(owner); @@ -935,28 +982,18 @@ async function pullDocumentFromNotionInner( // check will mistake the unchanged document for a fresh local edit. const updatedAt = contentChanged ? nowIso() : freshDocument.updatedAt; if (contentChanged) { - // Compare-and-swap: only apply the pulled content if the row is still at - // the snapshot we just re-read. If a concurrent save landed in between, - // 0 rows match and we fall through to the conflict path instead of - // clobbering the newer local write. - const applied = await db - .update(schema.documents) - .set({ - title: newTitle, - content: newContent, - icon: newIcon, - updatedAt, - }) - .where( - and( - eq(schema.documents.id, documentId), - eq(schema.documents.ownerEmail, owner), - eq(schema.documents.updatedAt, freshDocument.updatedAt), - ), - ) - .returning({ id: schema.documents.id }); + // Snapshot + compare-and-swap are one transaction: only the winning + // replacement gets a recovery version, and snapshot failure rolls it back. + const applied = await replaceDocumentFromExternal({ + document: freshDocument, + expectedUpdatedAt: freshDocument.updatedAt, + title: newTitle, + content: newContent, + icon: newIcon, + updatedAt, + }); - if (!applied || applied.length === 0) { + if (!applied) { // A newer local save raced in after our re-read. Do not adopt the // pulled content or advance the hash baseline — surface a conflict so // the user resolves it explicitly instead of silently losing the edit. @@ -986,16 +1023,10 @@ async function pullDocumentFromNotionInner( }); } - // Reset the Yjs collaborative state so it no longer holds the pre-sync - // content. Connected clients re-seed their Y.XmlFragment from the new - // `documents.content` value via VisualEditor's content-sync effect, and - // a fresh page load starts from an empty server state and seeds from SQL. - try { - await deleteCollabState(documentId); - releaseDoc(documentId); - } catch { - // Non-fatal — the client-side sync will still reconcile via setContent. - } + // Keep the Y.Doc intact. The live editor's updatedAt-gated reconcile applies + // this authoritative SQL snapshot as a minimal Yjs transaction. Deleting + // collab state here races connected clients (which can re-persist the stale + // pre-pull state) and briefly makes later flush handshakes miss the editor. } await upsertSyncLink({ @@ -1193,7 +1224,6 @@ async function pushDocumentToNotionInner( // the rare case Notion normalizes a construct differently, immediately // converging instead of ping-ponging. Re-read the row first so a local save // that landed during the multi-round-trip push isn't clobbered below. - const db = getDb(); const freshDocument = await getDocument(documentId, owner); const newContent = remote.content ?? document.content; const newTitle = remote.title || document.title; @@ -1211,39 +1241,25 @@ async function pushDocumentToNotionInner( // exactly what we pushed, since contentChanged is false). let baselineContent = document.content; if (contentChanged) { - // Compare-and-swap against the pre-push snapshot: if a concurrent save - // changed the row since `document` was read, skip adopting Notion's - // normalized content so the newer local edit is never overwritten. The - // hash baseline is still advanced to the *pushed* content below so the - // concurrent edit continues to show as localChanged and gets re-pushed on - // the next sync cycle. - const applied = await db - .update(schema.documents) - .set({ - title: newTitle, - content: newContent, - icon: newIcon, - updatedAt: pushedAt, - }) - .where( - and( - eq(schema.documents.id, documentId), - eq(schema.documents.ownerEmail, owner), - eq(schema.documents.updatedAt, document.updatedAt), - ), - ) - .returning({ id: schema.documents.id }); + // Snapshot + compare-and-swap are atomic. If a concurrent save changed the + // row since `document` was read, skip both the provider readback adoption + // and its recovery version; the newer local edit remains localChanged. + const applied = await replaceDocumentFromExternal({ + document: freshDocument, + expectedUpdatedAt: document.updatedAt, + title: newTitle, + content: newContent, + icon: newIcon, + updatedAt: pushedAt, + }); - if (applied && applied.length > 0) { + if (applied) { // The CAS landed — the row now holds Notion's normalized readback, so // the baseline must match that, not the pre-push content we sent. baselineContent = newContent; - try { - await deleteCollabState(documentId); - releaseDoc(documentId); - } catch { - // Non-fatal — the client reconciles via setContent. - } + // Preserve the live Y.Doc and let the updatedAt-gated editor reconcile + // apply this provider-normalized snapshot. Clearing collab persistence + // here can race a connected client's stale update and resurrect it. } // else: CAS raced and lost — the row holds the concurrent edit's content, // not `newContent` and not `document.content`. Keep baselineContent as diff --git a/templates/design/.agents/skills/design-generation/SKILL.md b/templates/design/.agents/skills/design-generation/SKILL.md index 83040ddae8..2e708c0fd7 100644 --- a/templates/design/.agents/skills/design-generation/SKILL.md +++ b/templates/design/.agents/skills/design-generation/SKILL.md @@ -173,7 +173,7 @@ defaults. The banned-defaults list above still applies, plus: ## Generation Workflow — the canonical 5-phase flow -This flow mirrors Claude Design's UX: ask → show variants → user picks → refine. Don't skip phases for new designs. +This flow mirrors Claude Design's UX: clarify only what's unclear → show variants → user picks → refine. Don't collapse phases into one shot for new, open-ended designs. ### Phase 1 — Create the project + ask before generating @@ -187,7 +187,28 @@ application state. External MCP hosts should surface the `create-design` returned "Open design" link, then use `present-design-variants` to open the visual picker. -Then, for any non-trivial first prompt, call `show-design-questions` BEFORE generating. The editor renders a full-canvas overlay; answers come back as a chat message. Skip the questions only when the prompt is unambiguous ("re-skin this with my brand colors") or the user said "decide for me". +Then, for a brief or ambiguous first prompt to a **new** design, call +`show-design-questions` BEFORE generating. The editor renders a full-canvas +overlay; answers come back as a chat message. Size the question count to how +much is actually unresolved — most prompts warrant 2-4 questions, not the full +1-8 range — and never ask about something the prompt already specified (a +stated color, audience, or layout is settled; don't re-ask it as a choice). + +Skip the questions entirely when: +- the prompt is already specific enough to generate from (names the audience, + purpose, and a visual direction, e.g. "a dark, data-dense analytics + dashboard for ops engineers" or "re-skin this with my brand colors"); +- it's a tweak/edit/refinement to an existing design rather than a new one — + go straight to `edit-design` (see Phase 3 and "Making edits" below); +- the user already answered a question set for this design and is now + iterating — don't re-ask settled ground on follow-up prompts for the same + design; or +- the user says "decide for me," "surprise me," "just build it," or similar. + +Asking on every prompt is as much a failure mode as never asking: a detailed +prompt that already answers the obvious questions should generate +immediately, and a design already in flight should not be interrupted with a +second questionnaire. ```bash pnpm action show-design-questions \ @@ -196,6 +217,12 @@ pnpm action show-design-questions \ --questions '[{"id":"form_factor","type":"text-options","question":"What form factor?","options":[{"label":"Desktop web app","value":"desktop"},{"label":"Mobile app","value":"mobile"},{"label":"Both / responsive","value":"responsive"},{"label":"Decide for me","value":"decide"}],"allowOther":true}]' ``` +Favor choice-first questions (2-5 concrete options, `allowOther: true`, and a +"Decide for me" option when any answer is acceptable) over open-ended +`freeform` text, and avoid `multiSelect` unless the question genuinely allows +combining multiple answers — stacking multi-select questions multiplies +follow-up ambiguity instead of resolving it. + **Carry the form-factor answer through to generation — do not just ask and discard it.** A "Desktop web app" answer means the generated screen's canvas frame must be desktop-sized (~1440×1024), not left at whatever a screen with no placement falls back to. Map the answer to real frame geometry: pass `deviceType` (`"mobile"` / `"tablet"` / `"desktop"`) per screen to `generate-screens`, explicit `width`/`height` per variant to `present-design-variants`, or an explicit `canvasFrames` entry to `generate-design` — see Phase 2 and Phase 3 below. For "Both / responsive," generate at desktop width and rely on the responsive breakpoint system (see `responsive-breakpoints` skill) rather than guessing a size. ### Phase 2 — Generate side-by-side variations (2-5, three by default) diff --git a/templates/design/.agents/skills/design-systems/SKILL.md b/templates/design/.agents/skills/design-systems/SKILL.md index 0ff6832667..fb2a9e3636 100644 --- a/templates/design/.agents/skills/design-systems/SKILL.md +++ b/templates/design/.agents/skills/design-systems/SKILL.md @@ -248,6 +248,92 @@ design-system indexing just to insert a component. Use component/component set with provenance. Styles and variables still belong in the Builder-backed design-system path above. +### Import from Figma (pixel-accurate frame import) + +**When the user pastes a Figma frame/screen link and wants a real, editable +Design screen** (not a rendered image, not a component insert), use +`import-figma-frame` instead of `list-figma-library-assets` + +`insert-figma-library-asset`: + +```bash +pnpm action import-figma-frame --figmaUrl "https://www.figma.com/design//?node-id=" +# or +pnpm action import-figma-frame --fileKey "" --nodeId "12:34" --designId "" +``` + +- Accepts a full Figma URL (design/file/proto share links, including + `/branch//` branch URLs — the branch's own key is used automatically) or + an explicit `fileKey` + `nodeId`. If `nodeId` is omitted, the file's first + top-level frame is imported. +- Maps the node tree to real HTML/CSS: exact position/size, auto-layout as + flexbox, text (font, line-height, letter-spacing, case, decoration, align), + fills (solid/gradient/image, correctly layered and gradient-angle-derived, + not a default angle), strokes (including the CENTER/INSIDE/OUTSIDE + distinction), per-corner radii, shadows/blur, opacity, and blend modes. + Vector networks, boolean operations, and other structurally unsupported node + types are rendered as an exact PNG at 2x scale instead of an approximated + shape guess. +- Saves the result as a new screen via the same import path as other Design + imports (`saveImportedDesignFiles`), placed on the overview canvas. +- Returns a `fidelityReport` — `exactCount`, `approximated` (properties CSS + can only approximate: rotation, per-side stroke weights, radial/angular/ + diamond gradients, blur radius scale), and `imageFallbacks` (subtrees + rendered as PNG instead of structural HTML). Read this back to the user when + a design has non-trivial fallbacks so they know what to expect if they later + edit that subtree. +- After import, treat the screen like any other: `view-screen`, + `get-design-snapshot`, `apply-visual-edit` / `edit-design` all work normally + on it. +- For a file's published FILL/TEXT/EFFECT/GRID styles (name, description, node + id — not full token values), use `get-figma-styles` with `fileUrl`/`fileKey`. + This is the file's Styles panel, not the Enterprise Variables API; full + design-token extraction still routes through the Builder-backed + `index-design-system-with-builder` path above. + +#### Paste from Figma (Cmd+C/Cmd+V) vs. a copied frame link + +A plain clipboard paste (Cmd+C in Figma, Cmd+V on the Design canvas) is +handled separately from `import-figma-frame`, because a clipboard paste and a +copied frame **link** carry fundamentally different information: + +- **A copied frame link** (`?node-id=...`) names an exact node. Always exact — + use `import-figma-frame`. +- **A plain clipboard paste** only carries Figma's `figmeta` marker, which is + `{fileKey, pasteID, dataType}` — **no node id at all**, and `pasteID` is an + ephemeral, server-side identifier that the public REST API can't resolve + back to a node. So a clipboard paste can only ever get an exact node import + on a **best-effort match**, never a guarantee. + +The canvas paste listener (`app/lib/figma-clipboard.ts` + +`import-figma-clipboard`) handles this automatically: + +1. Decodes `figmeta` from the pasted HTML client-side + (`extractFigmeta`/`resolveFigmaPasteImportCall`) to decide whether to call + `import-figma-clipboard` (figmeta present) or the legacy + `import-design-source` HTML path (no figmeta — not a Figma paste, or an + older Figma client that doesn't emit the marker). +2. `import-figma-clipboard` fetches the file's shallow structure (top-level + frames + their direct children, `server/lib/figma-node-import.ts`'s + `fetchFileStructure(fileKey, 3)`) and heuristically matches it against the + pasted content's visible text (`server/lib/figma-clipboard-match.ts`): + a frame is only imported when its **name** or at least **two distinct + text-layer contents** appear verbatim in the paste. Anything ambiguous or + unmatched imports **nothing structural** — it never guesses and never + imports the whole file uninvited. +3. On a confident match, the matched node(s) are fetched and mapped through + the same `buildScreenFilesFromFigmaNodes` core `import-figma-frame` uses + (`strategy: "restNodes"` in the result, with a `fidelityReport`). +4. Otherwise it falls back to the legacy visible-HTML paste + (`strategy: "htmlFallback"`), and reports why via `matchStatus` + (`"ambiguous"`, `"none"`, or `"error"`) and `figmaApiKeyMissing` (no + `FIGMA_ACCESS_TOKEN` configured). The canvas paste toast surfaces a hint in + both cases: connect the Figma access token, or paste a frame **link** + instead for a guaranteed-exact import. + +Tell users who want guaranteed pixel-exact imports to copy a frame **link** +("Copy link to selection" in Figma), not just Cmd+C — a plain paste is +convenient but only best-effort. + ### Source: Brand Analysis (combines website + notes) ```bash diff --git a/templates/design/.agents/skills/export-handoff/SKILL.md b/templates/design/.agents/skills/export-handoff/SKILL.md index bb6ef9a7b8..3cd277f8b7 100644 --- a/templates/design/.agents/skills/export-handoff/SKILL.md +++ b/templates/design/.agents/skills/export-handoff/SKILL.md @@ -17,6 +17,9 @@ How to export designs and generate handoff documentation for developers converti with the editor's Download SVG command. The editor's own Download SVG command captures the live browser DOM for the most faithful snapshot; use the action when you need agent-side SVG export without a live browser. + **This is not importable into Figma as editable vectors** — Figma cannot + parse `foreignObject` content, so it stays an opaque embedded HTML blob. + Use `export-design-as-figma-svg` (below) when the destination is Figma. - **PNG**: there is no PNG export action. Point the user to the editor's download menu (Download PNG) — PNG export is a client-side rasterization of the live canvas and is not exposed as an agent action. @@ -88,6 +91,60 @@ pnpm action export-pdf --id Returns all design data and files needed for the client to render a PDF. +## Export to Figma (SVG) + +`export-design-as-figma-svg` exports a design screen (or a selected +element's subtree) as a genuinely VECTOR SVG document — real +``/``/``/`` markup with +``/``/`` defs. Figma's SVG importer +parses this into normal, editable layers (rect/path/gradients/filters stay +editable). This is a different artifact from `export-svg` above, whose +`foreignObject` wrapper Figma cannot import as vectors at all. + +```bash +pnpm action export-design-as-figma-svg --designId +``` + +Optional args: + +- `fileId` / `filename` — pick a specific screen (defaults to `index.html`). +- `nodeId` — scope the export to one selected element's subtree via its + `data-agent-native-node-id`, instead of the whole screen. +- `embedImages` (default `true`) — fetch and inline `http(s)` image sources + and background-images as `data:` URIs, so the SVG is self-contained for + clipboard paste. Set `false` to keep absolute URLs instead. + +Returns `{ svg, filename, report, filePath? }`. `report` classifies every +element as `vectorized`, `approximated` (mapped with a documented caveat — +e.g. a non-square gradient angle, a non-uniform border, a radial gradient's +shape/position), `rasterized` (video/canvas/iframe content, and any element +with `backdrop-filter`, which SVG cannot express — embedded as a cropped +screenshot instead), or `omitted`. If no headless Chromium binary is +available in the current environment (expected in hosted/serverless +deploys), the action returns `{ ok: false, reason }` instead of throwing — +fall back to `export-svg` or `export-html`. + +**Vectorized-text caveat**: Figma converts every imported SVG `` +element to outlined vector paths on paste/drag-import. The exported +geometry is pixel-exact, but text pasted from this export is no longer +live, editable type in Figma — it's outlines, the same way any other +SVG-authoring tool's text becomes outlines on import. This is a Figma +import limitation, not a defect in the export; the report's +`vectorizedTextCaveat` field carries this note for the agent/user. + +**Getting it into Figma**: two supported paths — + +1. **Copy, then paste into Figma.** In the editor, right-click a selected + element or the canvas and choose **Copy as SVG** (Copy/Paste as ▸ Copy as + SVG). This writes the SVG markup to the system clipboard as `text/plain` + (the MIME Figma's own paste handler reads for "paste as vector shapes") + plus `image/svg+xml` as a secondary representation. Paste directly into a + Figma canvas. +2. **Download, then drag-import.** Figma's file browser also accepts a + plain `.svg` file dropped/imported directly — save the `svg` string + returned by the action to a `.svg` file and drag it into a Figma page the + same way you'd import any other SVG asset. + ## Coding Handoff When a user wants to convert an Alpine.js + Tailwind prototype into production diff --git a/templates/design/.agents/skills/visual-edit/SKILL.md b/templates/design/.agents/skills/visual-edit/SKILL.md index b597602194..3900657017 100644 --- a/templates/design/.agents/skills/visual-edit/SKILL.md +++ b/templates/design/.agents/skills/visual-edit/SKILL.md @@ -208,6 +208,17 @@ the connected app's text/code files through the bridge - Saves are conflict-checked against the file's on-disk version — a file that changed since it was read fails with a version conflict instead of being overwritten. +- React/TSX canvas edits use build/debug provenance to locate the responsible + source, but structural meaning belongs to the coding agent. Do not apply a + generic AST reparent/group/ungroup transform. Hand off the exact subject and + target source anchors plus their runtime relationship; repeated `.map()` + instances, shared components, dynamic expressions, and cross-file edits + always require semantic inspection. +- For every semantic React write, read the file first, pass that exact + `versionHash` to `write-local-file` with `requireExpectedVersionHash: true`, + re-read and re-plan if it conflicts, and + verify the resulting HMR/runtime state before treating the preview as saved. + Human write consent remains mandatory and cannot be granted by an agent. ## Verification diff --git a/templates/design/.generated/bridge/editor-chrome.generated.ts b/templates/design/.generated/bridge/editor-chrome.generated.ts index 7c9e116e29..34ca05f329 100644 --- a/templates/design/.generated/bridge/editor-chrome.generated.ts +++ b/templates/design/.generated/bridge/editor-chrome.generated.ts @@ -13,6 +13,9 @@ export const editorChromeBridgeScript: string = `"use strict"; var textEditingEnabled = !readOnly && textEditingEnabledFlag; var designCanvasScreenId = __DESIGN_CANVAS_SCREEN_ID__ || ""; var designCanvasBoardSurface = !!__DESIGN_CANVAS_BOARD_SURFACE__; + var designCanvasContentOffsetX = Number(__DESIGN_CANVAS_CONTENT_OFFSET_X__) || 0; + var designCanvasContentOffsetY = Number(__DESIGN_CANVAS_CONTENT_OFFSET_Y__) || 0; + var runtimeLayerSnapshotEnabled = !!__RUNTIME_LAYER_SNAPSHOT_ENABLED__; var scaleToolEnabled = false; var statePreviewNodeId = null; var editorChromeScaleX = Math.max( @@ -121,6 +124,240 @@ export const editorChromeBridgeScript: string = `"use strict"; if (!el || !el.getAttribute) return ""; return el.getAttribute("data-agent-native-node-id") || el.getAttribute("data-code-layer-id") || el.getAttribute("data-layer-id") || el.getAttribute("data-builder-id") || el.getAttribute("data-loc") || el.id || ""; } + var reactDebugProvenanceCache = typeof WeakMap !== "undefined" ? /* @__PURE__ */ new WeakMap() : null; + function reactDebugProvenance(el) { + var cached = reactDebugProvenanceCache?.get(el); + if (cached !== void 0) return cached; + var fiberKey = Object.keys(el).find(function(key) { + return key.indexOf("__reactFiber$") === 0; + }); + if (!fiberKey) return void 0; + var fiber = el[fiberKey]; + for (var depth = 0; fiber && depth < 12; depth += 1) { + var stack = String(fiber._debugStack?.stack || ""); + var lines = stack.split("\\n"); + for (var index = 0; index < lines.length; index += 1) { + var lineText = lines[index]; + var match = lineText.match( + /(?:\\(|\\s)(https?:\\/\\/[^\\s)]+?):(\\d+):(\\d+)\\)?\\s*$/ + ); + if (!match) continue; + try { + var sourceUrl = new URL(match[1]); + var pathname = decodeURIComponent(sourceUrl.pathname); + if (pathname.indexOf("/node_modules/") >= 0) continue; + var sourceFile = pathname.indexOf("/@fs/") === 0 ? pathname.slice("/@fs".length) : pathname.replace(/^\\/+/, ""); + if (!sourceFile) continue; + var componentMatch = lineText.match(/\\bat\\s+([^\\s(]+)\\s*\\(/); + var provenance = { + sourceFile, + line: parseInt(match[2], 10), + column: parseInt(match[3], 10), + component: componentMatch ? componentMatch[1] : void 0 + }; + reactDebugProvenanceCache?.set(el, provenance); + return provenance; + } catch (_error) { + } + } + fiber = fiber.return; + } + return void 0; + } + var runtimeLayerSnapshotTimer = null; + var runtimeLayerSnapshotMaxTimer = null; + var lastRuntimeLayerSnapshotHtml = ""; + function runtimeLayerHash(value) { + var hash = 2166136261; + for (var index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0).toString(36); + } + function runtimeLayerStructuralPath(el) { + var parts = []; + var node = el; + while (node && node !== document.body) { + var tag = node.tagName.toLowerCase(); + var parent = node.parentElement; + if (parent) { + var sameTag = Array.prototype.filter.call( + parent.children, + function(sibling) { + return sibling.tagName === node.tagName; + } + ); + tag += ":nth-of-type(" + (sameTag.indexOf(node) + 1) + ")"; + } + parts.unshift(tag); + node = parent; + } + return parts.join(" > ") || "body"; + } + function ensureRuntimeLayerNodeId(el) { + var existing = el.getAttribute("data-agent-native-node-id")?.trim(); + if (existing) return existing; + var provenance = reactDebugProvenance(el); + var provenanceKey = provenance ? [ + provenance.sourceFile, + provenance.line, + provenance.column || 0, + provenance.component || "" + ].join(":") : "dom"; + var nodeId = "runtime-" + runtimeLayerHash( + designCanvasScreenId + ":" + provenanceKey + ":" + runtimeLayerStructuralPath(el) + ); + el.setAttribute("data-agent-native-node-id", nodeId); + return nodeId; + } + function isRuntimeLayerVisualNode(el) { + if (/^(script|style|template|noscript|link|meta|title)$/i.test(el.tagName)) { + return false; + } + return !(isOverlayElement(el) || el.closest("[data-agent-native-edit-overlay]")); + } + function serializeRuntimeLayerSnapshot() { + if (!document.body) return null; + var sourceNodes = Array.prototype.slice.call( + document.body.querySelectorAll("*") + ); + var cloneBody = document.body.cloneNode(true); + var cloneNodes = Array.prototype.slice.call( + cloneBody.querySelectorAll("*") + ); + var nodeCount = 0; + for (var index = 0; index < sourceNodes.length && index < cloneNodes.length; index += 1) { + var sourceNode = sourceNodes[index]; + var cloneNode = cloneNodes[index]; + if (!isRuntimeLayerVisualNode(sourceNode)) { + cloneNode.setAttribute("data-an-runtime-layer-remove", "true"); + continue; + } + cloneNode.setAttribute( + "data-agent-native-node-id", + ensureRuntimeLayerNodeId(sourceNode) + ); + var provenance = reactDebugProvenance(sourceNode); + if (provenance) { + cloneNode.setAttribute("data-source-file", provenance.sourceFile); + cloneNode.setAttribute("data-source-line", String(provenance.line)); + if (provenance.column) { + cloneNode.setAttribute( + "data-source-column", + String(provenance.column) + ); + } + if (provenance.component) { + cloneNode.setAttribute("data-component-name", provenance.component); + } + } + nodeCount += 1; + } + cloneBody.querySelectorAll("[data-an-runtime-layer-remove]").forEach(function(node) { + node.remove(); + }); + cloneBody.querySelectorAll("script,style,template,noscript,link,meta,title").forEach(function(node) { + node.remove(); + }); + cloneBody.setAttribute( + "data-agent-native-node-id", + ensureRuntimeLayerNodeId(document.body) + ); + cloneBody.setAttribute("data-an-runtime-layer-snapshot", "true"); + var html = "" + cloneBody.outerHTML + ""; + if (html.length > 2e6) return null; + return { html, nodeCount }; + } + function postRuntimeLayerSnapshot() { + if (runtimeLayerSnapshotTimer !== null) { + window.clearTimeout(runtimeLayerSnapshotTimer); + } + if (runtimeLayerSnapshotMaxTimer !== null) { + window.clearTimeout(runtimeLayerSnapshotMaxTimer); + } + runtimeLayerSnapshotTimer = null; + runtimeLayerSnapshotMaxTimer = null; + var snapshot = serializeRuntimeLayerSnapshot(); + if (!snapshot || snapshot.html === lastRuntimeLayerSnapshotHtml) return; + lastRuntimeLayerSnapshotHtml = snapshot.html; + window.parent.postMessage( + { + type: "agent-native:runtime-layer-snapshot", + payload: snapshot + }, + "*" + ); + } + function scheduleRuntimeLayerSnapshot() { + if (runtimeLayerSnapshotTimer !== null) { + window.clearTimeout(runtimeLayerSnapshotTimer); + } + runtimeLayerSnapshotTimer = window.setTimeout( + postRuntimeLayerSnapshot, + 300 + ); + if (runtimeLayerSnapshotMaxTimer === null) { + runtimeLayerSnapshotMaxTimer = window.setTimeout( + postRuntimeLayerSnapshot, + 1500 + ); + } + } + function runtimeLayerClassSignature(value) { + return String(value || "").split(/\\s+/).map(function(token) { + var parts = token.split(":"); + var utility = String(parts[parts.length - 1] || "").replace(/^!/, ""); + if (/^(?:flex|inline-flex|grid|inline-grid|hidden|block|inline-block|flex-row|flex-col)$/.test( + utility + ) || /^(?:items|justify)-/.test(utility)) { + return utility; + } + return /(?:component|card|button|control)/.test(utility) ? "component-like" : ""; + }).filter(Boolean).sort().join(" "); + } + function runtimeLayerStyleSignature(value) { + var relevant = {}; + String(value || "").split(";").forEach(function(declaration) { + var separator = declaration.indexOf(":"); + if (separator < 0) return; + var property = declaration.slice(0, separator).trim().toLowerCase(); + if (!/^(?:display|flex-direction|align-items|justify-content)$/.test( + property + )) { + return; + } + relevant[property] = declaration.slice(separator + 1).trim(); + }); + return Object.keys(relevant).sort().map(function(property) { + return property + ":" + relevant[property]; + }).join(";"); + } + function runtimeLayerMutationIsMeaningful(mutation) { + var target = mutation.target; + if (target.nodeType === 1 && (isOverlayElement(target) || target.closest?.("[data-agent-native-edit-overlay]"))) { + return false; + } + if (mutation.type === "childList") { + var changedNodes = Array.prototype.slice.call(mutation.addedNodes).concat(Array.prototype.slice.call(mutation.removedNodes)); + return changedNodes.some(function(node) { + var element = node.nodeType === 1 ? node : node.parentElement || mutation.target; + return !(element.nodeType === 1 && (isOverlayElement(element) || element.closest?.("[data-agent-native-edit-overlay]"))); + }); + } + if (mutation.type === "characterData") { + return true; + } + if (mutation.type !== "attributes") return false; + var name = mutation.attributeName || ""; + if (name === "class") { + return runtimeLayerClassSignature(mutation.oldValue) !== runtimeLayerClassSignature(target.getAttribute("class")); + } + if (name === "style") { + return runtimeLayerStyleSignature(mutation.oldValue) !== runtimeLayerStyleSignature(target.getAttribute("style")); + } + return true; + } function isDocumentRootElement(el) { return el === document.body || el === document.documentElement; } @@ -592,6 +829,15 @@ export const editorChromeBridgeScript: string = `"use strict"; if (hasColumn) dataSourceColumn = lastPart; } } + if (!dataSourceFile) { + var reactProvenance = reactDebugProvenance(el); + if (reactProvenance) { + dataSourceFile = reactProvenance.sourceFile; + dataSourceLine = String(reactProvenance.line); + dataSourceColumn = reactProvenance.column ? String(reactProvenance.column) : null; + dataComponentName = dataComponentName || reactProvenance.component || null; + } + } if (dataSourceFile || dataSourceLine || dataSourceColumn || dataComponentName) { provenance = {}; if (dataSourceFile) provenance.sourceFile = dataSourceFile; @@ -627,11 +873,18 @@ export const editorChromeBridgeScript: string = `"use strict"; lineHeight: cs.lineHeight, letterSpacing: cs.letterSpacing, textAlign: cs.textAlign, + // Clean longhand for decoration-toggle state (Cmd+U underline / + // Cmd+Shift+X strikethrough). Deliberately the longhand, not the + // \`textDecoration\` shorthand — see typography-helpers.ts's + // PERSISTENCE GOTCHA comment: reads use this clean value, writes + // still commit through the shorthand property name. + textDecorationLine: cs.textDecorationLine, display: cs.display, overflow: cs.overflow, flexDirection: cs.flexDirection, justifyContent: cs.justifyContent, alignItems: cs.alignItems, + justifyItems: cs.justifyItems, alignSelf: cs.alignSelf, flexGrow: cs.flexGrow, flexShrink: cs.flexShrink, @@ -639,12 +892,17 @@ export const editorChromeBridgeScript: string = `"use strict"; order: cs.order, gridColumn: cs.gridColumn, gridRow: cs.gridRow, + gridTemplateColumns: cs.gridTemplateColumns, + gridTemplateRows: cs.gridTemplateRows, + gridAutoFlow: cs.gridAutoFlow, position: cs.position, top: cs.top, right: cs.right, bottom: cs.bottom, left: cs.left, gap: cs.gap, + rowGap: cs.rowGap, + columnGap: cs.columnGap, width: cs.width, height: cs.height, opacity: cs.opacity, @@ -698,6 +956,7 @@ export const editorChromeBridgeScript: string = `"use strict"; width: rect.width, height: rect.height }, + parentBoundingRect: el.parentElement ? rectInfoForElement(el.parentElement) : void 0, textContent: el.textContent ? el.textContent.slice(0, 200) : void 0, htmlContent: el.innerHTML && el.innerHTML !== el.textContent ? el.innerHTML.slice(0, 4e3) : void 0, childElementCount: el.children ? el.children.length : 0, @@ -1068,6 +1327,11 @@ export const editorChromeBridgeScript: string = `"use strict"; } var selectedEl = null; var hoveredEl = null; + var lastHoverInfoPostedEl = null; + function clearHoverGate() { + hoveredEl = null; + lastHoverInfoPostedEl = null; + } var passiveSelectionEls = []; var passiveSelectionOverlays = []; var activeMarqueeSelection = null; @@ -1125,6 +1389,8 @@ export const editorChromeBridgeScript: string = `"use strict"; var spacingHatchNodesByKey = {}; var spacingOverlayRenderKey = ""; var activeDragCancel = null; + var bridgeSpaceKeyPressed = false; + var bridgeSpaceKeyConsumedByDrag = false; var activeCrossScreenStyleSnapshot = void 0; var spacingDrag = null; var lockedSelectors = []; @@ -1132,7 +1398,7 @@ export const editorChromeBridgeScript: string = `"use strict"; var lastEditorPointWasBlocked = false; function clearRuntimeSelection() { selectedEl = null; - hoveredEl = null; + clearHoverGate(); setPassiveSelectionElements([]); clearSpacingHoverTimer(); selectedSpacingHovered = false; @@ -1382,7 +1648,7 @@ export const editorChromeBridgeScript: string = `"use strict"; } catch (_err) { } } - hoveredEl = null; + clearHoverGate(); if (selectedEl && !isLayerInteractionBlocked(selectedEl)) { positionOverlay(selectionOverlay, selectedEl); postElementSelect(selectedEl); @@ -1411,7 +1677,7 @@ export const editorChromeBridgeScript: string = `"use strict"; }); applyHiddenSelectors(); selectedEl = null; - hoveredEl = null; + clearHoverGate(); for (var i = 0; i < activeCandidates.length && !selectedEl; i += 1) { try { var match = document.querySelector(activeCandidates[i]); @@ -2304,6 +2570,48 @@ export const editorChromeBridgeScript: string = `"use strict"; } } } + var overlayAnimationTrackingActive = false; + var overlayAnimationTrackingUntil = 0; + var overlayAnimationTrackingStartedAt = 0; + var OVERLAY_ANIMATION_TRACKING_WINDOW_MS = 1e3; + var OVERLAY_ANIMATION_TRACKING_MAX_MS = 4e3; + function isOverlayAnimationTrackingTarget(target) { + if (!target) return false; + return target === selectedEl || target === hoveredEl; + } + function tickOverlayAnimationTracking() { + if (!overlayAnimationTrackingActive) return; + refreshOverlays(); + var now = Date.now(); + if (now >= overlayAnimationTrackingUntil || now - overlayAnimationTrackingStartedAt >= OVERLAY_ANIMATION_TRACKING_MAX_MS) { + overlayAnimationTrackingActive = false; + return; + } + window.requestAnimationFrame(tickOverlayAnimationTracking); + } + function startOverlayAnimationTracking() { + var now = Date.now(); + overlayAnimationTrackingUntil = now + OVERLAY_ANIMATION_TRACKING_WINDOW_MS; + if (overlayAnimationTrackingActive) return; + overlayAnimationTrackingActive = true; + overlayAnimationTrackingStartedAt = now; + window.requestAnimationFrame(tickOverlayAnimationTracking); + } + function onOverlayAnimationTrackingEvent(e) { + if (isOverlayAnimationTrackingTarget(e.target)) { + startOverlayAnimationTracking(); + } + } + document.addEventListener( + "transitionrun", + onOverlayAnimationTrackingEvent, + true + ); + document.addEventListener( + "animationstart", + onOverlayAnimationTrackingEvent, + true + ); function hideMeasurements() { measurementOverlay.style.display = "none"; measurementOverlay.innerHTML = ""; @@ -2540,7 +2848,10 @@ export const editorChromeBridgeScript: string = `"use strict"; return !primary && !e.altKey && !e.shiftKey; } if (key === "Tab") return !!selectedEl; - if (key === "Delete" || key === "Backspace") return !primary; + if (key === "Delete" || key === "Backspace") { + if (primary) return key === "Backspace" && !e.altKey && !e.shiftKey; + return true; + } if (/^Arrow/.test(key || "")) return !e.altKey; if (primary) { return [ @@ -2557,8 +2868,34 @@ export const editorChromeBridgeScript: string = `"use strict"; "-", "0", "]", - "[" - ].indexOf(normalized) !== -1 || e.code === "Digit1" || e.code === "Digit2" || key === "1" || key === "2"; + "[", + // Cmd/Ctrl+U — toggle underline (useDesignHotkeys.ts onToggleUnderline). + "u", + // Cmd/Ctrl+F — find (onFind). Bridge's "primary" doesn't distinguish + // Cmd from Ctrl the way isPlatformPrimaryModifier does host-side, but + // forwarding is harmless when the host has no match for the combo. + "f", + // Cmd/Ctrl+R rename / Cmd/Ctrl+Shift+R paste-to-replace (onRename / + // onPasteToReplace) — both live under bare primary+r. + "r", + // Cmd/Ctrl+\\ — toggle UI (onToggleUi). + "\\\\" + ].indexOf(normalized) !== -1 || e.code === "Digit1" || e.code === "Digit2" || key === "1" || key === "2" || // Cmd/Ctrl+Shift+H / +L — toggle hidden / toggle locked + // (onToggleHidden / onToggleLocked). Gated on shiftKey so bare + // Cmd+H / Cmd+L — common OS "Hide app" / browser "focus address bar" + // shortcuts the host has no bare-primary binding for — are left + // alone (see useDesignHotkeys.ts: both require event.shiftKey). + e.shiftKey && (normalized === "h" || normalized === "l") || // Cmd/Ctrl+Alt+B detach instance / Cmd/Ctrl+Alt+K create component + // (onDetachInstance / onCreateComponent). Gated on altKey so bare + // Cmd+B / Cmd+K are left alone — the host has no bare-primary + // binding for either. + e.altKey && (normalized === "b" || normalized === "k") || // Ctrl+Alt+H / Ctrl+Alt+T — distribute horizontal / tidy up + // (onDistributeSelection / onTidyUp). useDesignHotkeys.ts keeps these + // on LITERAL Control on every platform (never remapped to Cmd), so + // this mirrors that exact gate instead of the generic "primary" flag + // — a blanket "t" entry above would otherwise swallow the common + // Cmd+T "new tab" browser shortcut for a combo the host never binds. + e.ctrlKey && e.altKey && !e.metaKey && !e.shiftKey && (normalized === "h" || normalized === "t"); } if (e.shiftKey && (e.code === "Digit1" || e.code === "Digit2" || key === "1" || key === "2")) return true; @@ -2886,8 +3223,38 @@ export const editorChromeBridgeScript: string = `"use strict"; function openContextMenuAtEvent(e) { stopNativeInteraction(e); blurActiveTextEditor(); - var target = elementFromEditorPoint(e.clientX, e.clientY); - if (!target && lastEditorPointWasBlocked) return; + var shieldPointerEvents = shieldOverlay.style.pointerEvents; + var selectionPointerEvents = selectionOverlay.style.pointerEvents; + var highlightPointerEvents = highlightOverlay.style.pointerEvents; + shieldOverlay.style.pointerEvents = "none"; + selectionOverlay.style.pointerEvents = "none"; + highlightOverlay.style.pointerEvents = "none"; + var pointTargets = document.elementsFromPoint ? document.elementsFromPoint(e.clientX, e.clientY) : [document.elementFromPoint(e.clientX, e.clientY)]; + shieldOverlay.style.pointerEvents = shieldPointerEvents; + selectionOverlay.style.pointerEvents = selectionPointerEvents; + highlightOverlay.style.pointerEvents = highlightPointerEvents; + var candidateElements = []; + var layerCandidates = []; + pointTargets.forEach(function(pointTarget) { + if (!pointTarget || pointTarget.nodeType !== 1) return; + if (isOverlayElement(pointTarget)) return; + var candidate = selectionTargetForHit(pointTarget); + if (!candidate || isDocumentRootElement(candidate) || isOverlayElement(candidate) || isLayerInteractionBlocked(candidate) || isTemplateCloneElement(candidate) || candidateElements.indexOf(candidate) !== -1) { + return; + } + candidateElements.push(candidate); + var candidateInfo = getElementInfo(candidate); + var explicitLabel = candidate.getAttribute && candidate.getAttribute("data-agent-native-layer-name") || ""; + var textLabel = (candidate.textContent || "").trim().replace(/\\s+/g, " "); + var label = explicitLabel || candidateInfo.componentName || candidate.id || (textLabel && textLabel.length <= 48 ? textLabel : "") || candidate.tagName.toLowerCase(); + var identity = candidateInfo.sourceId || candidateInfo.selector || String(layerCandidates.length); + layerCandidates.push({ + key: String(identity) + ":" + String(layerCandidates.length), + label: String(label).slice(0, 80), + info: candidateInfo + }); + }); + var target = candidateElements[0] || null; var info = null; if (target) { selectedSpacingHovered = false; @@ -2896,7 +3263,6 @@ export const editorChromeBridgeScript: string = `"use strict"; if (selectedEl && !isLayerInteractionBlocked(selectedEl)) { info = getElementInfo(selectedEl); positionOverlay(selectionOverlay, selectedEl); - postElementSelect(selectedEl, e); } else { selectedEl = null; hideSelectionOverlay(); @@ -2905,9 +3271,11 @@ export const editorChromeBridgeScript: string = `"use strict"; window.parent.postMessage( { type: "element-contextmenu", + screenId: designCanvasScreenId, clientX: e.clientX, clientY: e.clientY, - payload: info + payload: info, + layerCandidates }, "*" ); @@ -2935,6 +3303,44 @@ export const editorChromeBridgeScript: string = `"use strict"; } return null; } + function findUniqueRuntimeStructureTarget(selector, sourceId) { + var matches = /* @__PURE__ */ new Set(); + if (typeof sourceId === "string" && sourceId) { + var attributes = [ + "data-agent-native-node-id", + "data-code-layer-id", + "data-layer-id", + "data-builder-id", + "data-loc", + "id" + ]; + for (var i = 0; i < attributes.length; i += 1) { + try { + var sourceMatches = document.querySelectorAll( + "[" + attributes[i] + '="' + escapeAttribute(sourceId) + '"]' + ); + for (var j = 0; j < sourceMatches.length; j += 1) { + matches.add(sourceMatches[j]); + } + } catch (_err) { + } + } + if (matches.size > 1) return null; + if (matches.size === 1) { + var sourceMatch = Array.from(matches)[0]; + return sourceMatch && sourceMatch !== document.body && sourceMatch !== document.documentElement && !isOverlayElement(sourceMatch) && !isLayerInteractionBlocked(sourceMatch) ? sourceMatch : null; + } + } + if (typeof selector !== "string" || !selector) return null; + try { + var selectorMatches = document.querySelectorAll(selector); + if (selectorMatches.length !== 1) return null; + var selectorMatch = selectorMatches[0]; + return selectorMatch !== document.body && selectorMatch !== document.documentElement && !isOverlayElement(selectorMatch) && !isLayerInteractionBlocked(selectorMatch) ? selectorMatch : null; + } catch (_err) { + return null; + } + } function removeRuntimeTarget(selector, selectorCandidates) { var target = findRuntimeTarget(selector, selectorCandidates); if (!target || target === document.body || target === document.documentElement) @@ -2945,7 +3351,7 @@ export const editorChromeBridgeScript: string = `"use strict"; selectedEl = null; hideSelectionOverlay(); } - hoveredEl = null; + clearHoverGate(); highlightOverlay.style.display = "none"; hideMeasurements(); refreshOverlays(); @@ -3697,7 +4103,8 @@ export const editorChromeBridgeScript: string = `"use strict"; function isAbsolutePrimitiveContainer(el) { if (!el || (el.tagName || "").toLowerCase() !== "div") return false; var primitive = (el.getAttribute("data-an-primitive") || el.getAttribute("data-agent-native-primitive") || "").toLowerCase(); - if (primitive !== "rectangle" && primitive !== "rect") return false; + if (primitive !== "rectangle" && primitive !== "rect" && primitive !== "frame") + return false; var cs = window.getComputedStyle(el); return cs.position === "absolute" || cs.position === "fixed"; } @@ -4121,6 +4528,62 @@ export const editorChromeBridgeScript: string = `"use strict"; dropMode: "flow-insert" }; } + function flowMoveTargetForPoint(el, clientX, clientY, excludeEls, keepCurrentParent, ignoreTargetAutoLayout) { + if (!el || !el.parentElement) return null; + var currentParent = el.parentElement; + var dragged = [el].concat(excludeEls || []); + var parentRect = currentParent.getBoundingClientRect(); + var pointerOutsideCurrentParent = clientX < parentRect.left || clientX > parentRect.right || clientY < parentRect.top || clientY > parentRect.bottom; + if (keepCurrentParent && pointerOutsideCurrentParent) { + var retainedSlot = nearestChildInsertionTarget( + currentParent, + clientX, + clientY, + dragged + ); + return retainedSlot || { + anchor: currentParent, + placement: "inside", + axis: parentFlowAxis(currentParent), + dropMode: "flow-insert" + }; + } + var target = reorderTargetForPoint(el, clientX, clientY, excludeEls); + var container = dropContainerForTarget(target); + if (ignoreTargetAutoLayout && container && container !== document.body && isAutoLayoutElement(container)) { + return { + anchor: container, + placement: "inside", + axis: parentFlowAxis(container), + dropMode: "absolute-container" + }; + } + if (currentParent !== document.body && (container === document.body || container === document.documentElement || target?.anchor === document.body)) { + return { + anchor: document.body, + placement: "inside", + axis: "y", + dropMode: "absolute-container" + }; + } + if (target && target.dropMode === "flow-insert" && container && container !== document.body && isContainerDropTarget(container) && !isAutoLayoutElement(container)) { + target.needsAutoLayoutConversion = true; + target.conversionTarget = container; + } + return target; + } + function ignoreAutoLayoutForDropTarget(target) { + var container = dropContainerForTarget(target); + if (!target || !container || container === document.body || !isAutoLayoutElement(container)) { + return target; + } + return { + anchor: container, + placement: "inside", + axis: parentFlowAxis(container), + dropMode: "absolute-container" + }; + } function elementFromEditorPointIgnoring(clientX, clientY, ignore) { var ignoreList = []; var previousPointerEvents = []; @@ -4323,9 +4786,15 @@ export const editorChromeBridgeScript: string = `"use strict"; for (var i = 0; i < ABS_POSITION_INLINE_PROPS.length; i += 1) { htmlEl.style.removeProperty(ABS_POSITION_INLINE_PROPS[i]); } + var afterRemoval = window.getComputedStyle(htmlEl).position; + if (afterRemoval === "absolute" || afterRemoval === "fixed") { + htmlEl.style.setProperty("position", "static", "important"); + target.forceFlowPositionOverride = true; + } } function rebaseAbsoluteMemberForContainerDrop(el, target) { if (!el || !target || target.dropMode !== "absolute-container") return; + if (target.absoluteCoordinatesPrepared) return; var container = dropContainerForTarget(target); if (!container || container === document.body || container === el) return; if (el.contains && el.contains(container)) return; @@ -4334,8 +4803,10 @@ export const editorChromeBridgeScript: string = `"use strict"; if (cs.position !== "absolute" && cs.position !== "fixed") return; var containerRect = container.getBoundingClientRect(); var containerCS = window.getComputedStyle(container); - var newOriginX = containerRect.left + readPx(containerCS.borderLeftWidth) - container.scrollLeft; - var newOriginY = containerRect.top + readPx(containerCS.borderTopWidth) - container.scrollTop; + var boardOffsetX = designCanvasBoardSurface ? designCanvasContentOffsetX : 0; + var boardOffsetY = designCanvasBoardSurface ? designCanvasContentOffsetY : 0; + var newOriginX = containerRect.left - boardOffsetX + readPx(containerCS.borderLeftWidth) - container.scrollLeft; + var newOriginY = containerRect.top - boardOffsetY + readPx(containerCS.borderTopWidth) - container.scrollTop; var oldOriginX = -(window.scrollX || 0); var oldOriginY = -(window.scrollY || 0); var offsetParent = htmlEl.offsetParent; @@ -4348,8 +4819,10 @@ export const editorChromeBridgeScript: string = `"use strict"; if (offsetParentIsRealContainingBlock) { var opRect = offsetParent.getBoundingClientRect(); var opCS = window.getComputedStyle(offsetParent); - oldOriginX = opRect.left + readPx(opCS.borderLeftWidth) - offsetParent.scrollLeft; - oldOriginY = opRect.top + readPx(opCS.borderTopWidth) - offsetParent.scrollTop; + var oldContainingBlockOffsetX = designCanvasBoardSurface && offsetParent !== document.body ? boardOffsetX : 0; + var oldContainingBlockOffsetY = designCanvasBoardSurface && offsetParent !== document.body ? boardOffsetY : 0; + oldOriginX = opRect.left - oldContainingBlockOffsetX + readPx(opCS.borderLeftWidth) - offsetParent.scrollLeft; + oldOriginY = opRect.top - oldContainingBlockOffsetY + readPx(opCS.borderTopWidth) - offsetParent.scrollTop; } } var currentLeft = readPx(htmlEl.style.left || cs.left); @@ -4357,6 +4830,33 @@ export const editorChromeBridgeScript: string = `"use strict"; htmlEl.style.left = currentLeft + (oldOriginX - newOriginX) + "px"; htmlEl.style.top = currentTop + (oldOriginY - newOriginY) + "px"; } + function prepareFlowMembersForAbsoluteDrop(members, target, startRects, gestureStartRect, pointerOffset, clientX, clientY) { + if (!target || target.dropMode !== "absolute-container") return; + var container = dropContainerForTarget(target); + if (!container) return; + var containerRect = container.getBoundingClientRect(); + var containerCS = window.getComputedStyle(container); + var containerIsBodyContainingBlock = true; + if (container === document.body) { + containerIsBodyContainingBlock = containerCS.position !== "static" || containerCS.transform !== "none" || (containerCS.getPropertyValue("translate") || "none") !== "none"; + } + var originX = containerIsBodyContainingBlock ? containerRect.left + readPx(containerCS.borderLeftWidth) - container.scrollLeft : -(window.scrollX || 0); + var originY = containerIsBodyContainingBlock ? containerRect.top + readPx(containerCS.borderTopWidth) - container.scrollTop : -(window.scrollY || 0); + var desiredGestureLeft = clientX - pointerOffset.x; + var desiredGestureTop = clientY - pointerOffset.y; + var deltaX = desiredGestureLeft - gestureStartRect.left; + var deltaY = desiredGestureTop - gestureStartRect.top; + members.forEach(function(member, index) { + var startRect = startRects[index] || member.getBoundingClientRect(); + var htmlEl = member; + htmlEl.style.position = "absolute"; + htmlEl.style.left = Math.round(startRect.left + deltaX - originX) + "px"; + htmlEl.style.top = Math.round(startRect.top + deltaY - originY) + "px"; + htmlEl.style.removeProperty("right"); + htmlEl.style.removeProperty("bottom"); + }); + target.absoluteCoordinatesPrepared = true; + } function applyRuntimeReorder(el, target) { if (!el || !target || !target.anchor || !target.anchor.parentElement) return; @@ -4392,9 +4892,11 @@ export const editorChromeBridgeScript: string = `"use strict"; anchorSourceId: getSourceId(target.anchor), placement: target.placement, dropMode: target.dropMode || "flow-insert", + forceFlowPositionOverride: Boolean(target.forceFlowPositionOverride), sourceRect: rectInfoForElement(el), anchorRect: rectInfoForElement(target.anchor), - payload: getElementInfo(el) + payload: getElementInfo(el), + anchorPayload: getElementInfo(target.anchor) }, "*" ); @@ -4561,7 +5063,7 @@ export const editorChromeBridgeScript: string = `"use strict"; snapGuideV.style.display = "none"; snapGuideH.style.display = "none"; } - function startMove(e, gestureElParam) { + function startMove(e, gestureElParam, pointerStartParam) { var gestureEl = gestureElParam || selectedEl; if (!gestureEl) return; if (isLayerInteractionBlocked(gestureEl)) return; @@ -4665,7 +5167,14 @@ export const editorChromeBridgeScript: string = `"use strict"; hideInsertionGuide(); showTransformBadge("Move layer", cx, cy); } else { - currentTarget = reorderTargetForPoint(reorderEl, cx, cy, groupOthers); + currentTarget = flowMoveTargetForPoint( + reorderEl, + cx, + cy, + groupOthers, + keepCurrentFlowParent, + Boolean(ev.ctrlKey) + ); showInsertionGuideFor(currentTarget); showTransformBadge(currentTarget ? "Move layer" : "Move", cx, cy); } @@ -4673,6 +5182,7 @@ export const editorChromeBridgeScript: string = `"use strict"; document.removeEventListener(events.move, onReorderMove2, true); document.removeEventListener(events.up, onReorderUp2, true); document.removeEventListener("keydown", onReorderKeyDown2, true); + document.removeEventListener("keyup", onReorderKeyUp2, true); clearActiveDragCancel(onReorderEscape2); }, onReorderEscape2 = function() { cleanupReorderDrag2(); @@ -4692,10 +5202,19 @@ export const editorChromeBridgeScript: string = `"use strict"; suppressNextShieldClickBriefly(); return true; }, onReorderKeyDown2 = function(ev) { + if (ev.code === "Space" || ev.key === " ") { + keepCurrentFlowParent = true; + ev.preventDefault(); + return; + } if (ev.key === "Escape") { stopNativeInteraction(ev); onReorderEscape2(); } + }, onReorderKeyUp2 = function(ev) { + if (ev.code !== "Space" && ev.key !== " ") return; + keepCurrentFlowParent = false; + ev.preventDefault(); }, onReorderUp2 = function(ev) { cleanupReorderDrag2(); hideTransformBadge(); @@ -4723,6 +5242,14 @@ export const editorChromeBridgeScript: string = `"use strict"; ); } if (outsideOnDrop) return; + currentTarget = flowMoveTargetForPoint( + reorderEl, + cx, + cy, + groupOthers, + keepCurrentFlowParent, + Boolean(ev?.ctrlKey) + ); if (!currentTarget) { if (duplicatedForDrag && reorderEl && reorderEl !== originalSelectedEl) { if (reorderEl.parentElement) @@ -4733,6 +5260,21 @@ export const editorChromeBridgeScript: string = `"use strict"; } return; } + if (currentTarget.needsAutoLayoutConversion && currentTarget.conversionTarget) { + applyAutoLayoutConversionForDrop( + currentTarget.conversionTarget, + groupEls + ); + } + prepareFlowMembersForAbsoluteDrop( + groupEls, + currentTarget, + reorderGroupStartRects, + reorderGestureStartRect, + reorderPointerOffset, + cx, + cy + ); if (duplicatedForDrag) { applyRuntimeReorder(reorderEl, currentTarget); postVisualDuplicateChange( @@ -4758,13 +5300,20 @@ export const editorChromeBridgeScript: string = `"use strict"; }); } }; - var onReorderMove = onReorderMove2, cleanupReorderDrag = cleanupReorderDrag2, onReorderEscape = onReorderEscape2, onReorderKeyDown = onReorderKeyDown2, onReorderUp = onReorderUp2; + var onReorderMove = onReorderMove2, cleanupReorderDrag = cleanupReorderDrag2, onReorderEscape = onReorderEscape2, onReorderKeyDown = onReorderKeyDown2, onReorderKeyUp = onReorderKeyUp2, onReorderUp = onReorderUp2; var reorderEl = gestureEl; - var currentTarget = reorderTargetForPoint( + var reorderGroupStartRects = groupEls.map(function(member) { + return member.getBoundingClientRect(); + }); + var reorderGestureStartRect = reorderEl.getBoundingClientRect(); + var keepCurrentFlowParent = bridgeSpaceKeyPressed; + var currentTarget = flowMoveTargetForPoint( reorderEl, e.clientX, e.clientY, - groupOthers + groupOthers, + keepCurrentFlowParent, + Boolean(e.ctrlKey) ); showInsertionGuideFor(currentTarget); var pointerOutsideIframe = false; @@ -4772,13 +5321,15 @@ export const editorChromeBridgeScript: string = `"use strict"; var reorderSourceId = getSourceId(reorderEl); var reorderStyleSnapshot = collectPortableStyleSnapshot(reorderEl); var reorderRect = reorderEl.getBoundingClientRect(); + var reorderPointerStart = pointerStartParam || e; var reorderPointerOffset = { - x: e.clientX - reorderRect.left, - y: e.clientY - reorderRect.top + x: reorderPointerStart.clientX - reorderRect.left, + y: reorderPointerStart.clientY - reorderRect.top }; document.addEventListener(events.move, onReorderMove2, true); document.addEventListener(events.up, onReorderUp2, true); document.addEventListener("keydown", onReorderKeyDown2, true); + document.addEventListener("keyup", onReorderKeyUp2, true); setActiveDragCancel(onReorderEscape2); return; } @@ -4821,6 +5372,23 @@ export const editorChromeBridgeScript: string = `"use strict"; if (!duplicatedForDrag && !isGroupDrag) { postCrossScreenDrag("start", dragEl, e); } + var crossScreenDragMoveScheduled = false; + var crossScreenDragMovePendingEv = null; + function flushCrossScreenDragMove() { + crossScreenDragMoveScheduled = false; + var pendingEv = crossScreenDragMovePendingEv; + crossScreenDragMovePendingEv = null; + if (pendingEv) postCrossScreenDrag("move", dragEl, pendingEv); + } + function scheduleCrossScreenDragMove(ev) { + crossScreenDragMovePendingEv = { + clientX: ev.clientX, + clientY: ev.clientY + }; + if (crossScreenDragMoveScheduled) return; + crossScreenDragMoveScheduled = true; + window.requestAnimationFrame(flushCrossScreenDragMove); + } function onMove(ev) { if (!moved && Math.hypot(ev.clientX - startX, ev.clientY - startY) > DRAG_THRESHOLD) { moved = true; @@ -4856,19 +5424,24 @@ export const editorChromeBridgeScript: string = `"use strict"; state.el.style.top = Math.round(state.originTop + appliedDy) + "px"; }); if (!duplicatedForDrag && !isGroupDrag) { - postCrossScreenDrag("move", dragEl, ev); + scheduleCrossScreenDragMove(ev); } if (!duplicatedForDrag && isOutsideIframeViewport(ev.clientX, ev.clientY)) { currentAutoLayoutTarget = null; hideInsertionGuide(); setMembersOpacity(null); } else { - currentAutoLayoutTarget = !duplicatedForDrag ? autoLayoutInsertionTargetForPoint( + currentAutoLayoutTarget = !duplicatedForDrag && !bridgeSpaceKeyPressed ? autoLayoutInsertionTargetForPoint( dragEl, ev.clientX, ev.clientY, groupOthers ) : null; + if (currentAutoLayoutTarget && ev.ctrlKey) { + currentAutoLayoutTarget = ignoreAutoLayoutForDropTarget( + currentAutoLayoutTarget + ); + } if (currentAutoLayoutTarget) { showInsertionGuideFor(currentAutoLayoutTarget); setMembersOpacity("0.4"); @@ -4904,6 +5477,8 @@ export const editorChromeBridgeScript: string = `"use strict"; document.removeEventListener(events.up, onUp, true); document.removeEventListener("keydown", onMoveKeyDown, true); clearActiveDragCancel(cancelMoveDrag); + crossScreenDragMoveScheduled = false; + crossScreenDragMovePendingEv = null; } function cancelMoveDrag() { cleanupMoveDrag(); @@ -4945,16 +5520,23 @@ export const editorChromeBridgeScript: string = `"use strict"; restoreSourceDragPosition(); return; } - if (ev && !duplicatedForDrag && !outsideOnDrop) { + if (ev && !duplicatedForDrag && !outsideOnDrop && !bridgeSpaceKeyPressed) { var finalAutoLayoutTarget = autoLayoutInsertionTargetForPoint( dragEl, ev.clientX, ev.clientY, groupOthers ); + if (finalAutoLayoutTarget && ev.ctrlKey) { + finalAutoLayoutTarget = ignoreAutoLayoutForDropTarget( + finalAutoLayoutTarget + ); + } if (finalAutoLayoutTarget) { currentAutoLayoutTarget = finalAutoLayoutTarget; } + } else if (bridgeSpaceKeyPressed) { + currentAutoLayoutTarget = null; } if (duplicatedForDrag && !moved) { if (dragEl.parentElement) dragEl.parentElement.removeChild(dragEl); @@ -5043,8 +5625,8 @@ export const editorChromeBridgeScript: string = `"use strict"; var originalInlineFontSize = resizeEl.style.fontSize; ensurePositionable(resizeEl); var cs = window.getComputedStyle(resizeEl); - var originW = readPx(resizeEl.style.width || cs.width); - var originH = readPx(resizeEl.style.height || cs.height); + var originW = readPx(cs.width); + var originH = readPx(cs.height); var originBorderWidth = readPx( resizeEl.style.borderWidth || cs.borderWidth ); @@ -5059,6 +5641,8 @@ export const editorChromeBridgeScript: string = `"use strict"; var startX = e.clientX; var startY = e.clientY; var resizeTheta = currentRotation(resizeEl) * Math.PI / 180; + var widthTouched = false; + var heightTouched = false; function nextRect(ev) { var screenDx = ev.clientX - startX; var screenDy = ev.clientY - startY; @@ -5121,15 +5705,29 @@ export const editorChromeBridgeScript: string = `"use strict"; if (handle.indexOf("n") !== -1 || handle.indexOf("s") !== -1) top = origin.top - (height - origin.height) / 2; } - return { left, top, width, height }; + var handlesWidth = handle.indexOf("w") !== -1 || handle.indexOf("e") !== -1; + var handlesHeight = handle.indexOf("n") !== -1 || handle.indexOf("s") !== -1; + var aspectLocked = !!(ev.shiftKey || scaleToolEnabled); + var touchesWidth = handlesWidth || aspectLocked && handlesHeight; + var touchesHeight = handlesHeight || aspectLocked && handlesWidth; + return { + left, + top, + width, + height, + touchesWidth, + touchesHeight + }; } function onMove(ev) { if (!resizeEl) return; var rect = nextRect(ev); + if (rect.touchesWidth) widthTouched = true; + if (rect.touchesHeight) heightTouched = true; resizeEl.style.left = Math.round(rect.left) + "px"; resizeEl.style.top = Math.round(rect.top) + "px"; - resizeEl.style.width = Math.round(rect.width) + "px"; - resizeEl.style.height = Math.round(rect.height) + "px"; + if (widthTouched) resizeEl.style.width = Math.round(rect.width) + "px"; + if (heightTouched) resizeEl.style.height = Math.round(rect.height) + "px"; if (scaleToolEnabled) { var kScaleFactor = rect.width / Math.max(1, origin.width); if (originBorderWidth > 0) { @@ -5185,10 +5783,10 @@ export const editorChromeBridgeScript: string = `"use strict"; var styles = { position: resizeEl.style.position, left: resizeEl.style.left, - top: resizeEl.style.top, - width: resizeEl.style.width, - height: resizeEl.style.height + top: resizeEl.style.top }; + if (widthTouched) styles.width = resizeEl.style.width; + if (heightTouched) styles.height = resizeEl.style.height; if (scaleToolEnabled && originBorderWidth > 0) { styles.borderWidth = resizeEl.style.borderWidth; } @@ -5346,12 +5944,15 @@ export const editorChromeBridgeScript: string = `"use strict"; var groupGestureMember = !e.altKey ? groupMemberForGestureTarget(dragTarget) : null; if (groupGestureMember && collectMoveGroupMembers(groupGestureMember).length > 1) { suppressNextShieldClickBriefly(); - startMove(ev, groupGestureMember); + startMove(ev, groupGestureMember, { + clientX: startX, + clientY: startY + }); return; } selectTarget(dragTarget, ev); suppressNextShieldClickBriefly(); - startMove(ev); + startMove(ev, void 0, { clientX: startX, clientY: startY }); } function onUp(ev) { clearPendingShieldDrag(); @@ -5464,6 +6065,14 @@ export const editorChromeBridgeScript: string = `"use strict"; document.addEventListener( "keydown", function(e) { + if (e.key === " " && e.code === "Space" && !activeTextEditEl && !isEditorTypingTarget(e.target)) { + bridgeSpaceKeyPressed = true; + if (activeDragCancel) { + bridgeSpaceKeyConsumedByDrag = true; + stopNativeInteraction(e); + return; + } + } if (!activeTextEditEl && pendingBeginTextEdit) { if (!(e.isComposing || e.keyCode === 229) && !e.metaKey && !e.ctrlKey) { var pendingKey = e.key || ""; @@ -5564,6 +6173,12 @@ export const editorChromeBridgeScript: string = `"use strict"; "keyup", function(e) { if (e.key !== " " || e.code !== "Space") return; + bridgeSpaceKeyPressed = false; + if (bridgeSpaceKeyConsumedByDrag) { + bridgeSpaceKeyConsumedByDrag = false; + stopNativeInteraction(e); + return; + } if (activeTextEditEl || isEditorTypingTarget(e.target)) return; stopNativeInteraction(e); window.parent.postMessage( @@ -5707,6 +6322,21 @@ export const editorChromeBridgeScript: string = `"use strict"; } return; } + if (!programmaticFlag && isTemplateCloneElement(target)) { + showRejectedDragBadge( + "Can't edit repeated items directly", + e.clientX, + e.clientY + ); + window.setTimeout(hideTransformBadge, 1400); + var rejectedTextEditFallback = selectionTargetForHit(target); + if (rejectedTextEditFallback && !isLayerInteractionBlocked(rejectedTextEditFallback)) { + selectedEl = rejectedTextEditFallback; + positionOverlay(selectionOverlay, selectedEl); + postElementSelect(selectedEl, e); + } + return; + } selectedEl = selectionTargetForHit(target) || target; var programmaticTextEdit = programmaticFlag; var originalText = target.textContent || ""; @@ -6002,6 +6632,7 @@ export const editorChromeBridgeScript: string = `"use strict"; scheduleSpacingHoverClear(e); } hideMeasurements(); + lastHoverInfoPostedEl = null; return; } if (hoveredEl && hoveredEl.closest("[data-agent-native-text-editing]")) @@ -6036,11 +6667,14 @@ export const editorChromeBridgeScript: string = `"use strict"; } else { hideMeasurements(); } - var info = getLightElementInfo(hoveredEl); - window.parent.postMessage( - { type: "element-hover", payload: info }, - "*" - ); + if (hoveredEl !== lastHoverInfoPostedEl) { + lastHoverInfoPostedEl = hoveredEl; + var info = getLightElementInfo(hoveredEl); + window.parent.postMessage( + { type: "element-hover", payload: info }, + "*" + ); + } }, true ); @@ -6071,7 +6705,7 @@ export const editorChromeBridgeScript: string = `"use strict"; updateSpacingOverlay(selectedEl); return; } - hoveredEl = null; + clearHoverGate(); if (!spacingDrag) { scheduleSpacingHoverClear(e); } @@ -6122,6 +6756,13 @@ export const editorChromeBridgeScript: string = `"use strict"; } return; } + if (e.data.type === "set-content-offset") { + var nextContentOffsetX = Number(e.data.x); + var nextContentOffsetY = Number(e.data.y); + designCanvasContentOffsetX = Number.isFinite(nextContentOffsetX) ? nextContentOffsetX : 0; + designCanvasContentOffsetY = Number.isFinite(nextContentOffsetY) ? nextContentOffsetY : 0; + return; + } if (e.data.type === "begin-text-edit") { var forceBeginTextEdit = e.data.force === true; if ((readOnly || !textEditingEnabled) && !forceBeginTextEdit) return; @@ -6322,13 +6963,15 @@ export const editorChromeBridgeScript: string = `"use strict"; } } if (!target) return; - if (target !== selectedEl) { + var selectionChangedByHost = target !== selectedEl; + if (selectionChangedByHost) { selectedSpacingHovered = false; hoveredSpacingHandleKey = ""; } selectedEl = target; positionOverlay(selectionOverlay, target); if (hoveredEl === selectedEl) highlightOverlay.style.display = "none"; + if (selectionChangedByHost) postElementSelect(target); return; } if (e.data.type === "hover-element") { @@ -6344,7 +6987,7 @@ export const editorChromeBridgeScript: string = `"use strict"; hoverCandidates.push(String(e.data.selector)); } if (hoverCandidates.length === 0) { - hoveredEl = null; + clearHoverGate(); highlightOverlay.style.display = "none"; hideMeasurements(); return; @@ -6374,12 +7017,51 @@ export const editorChromeBridgeScript: string = `"use strict"; hideSelectionOverlay(); } if (hoveredEl && isLayerInteractionBlocked(hoveredEl)) { - hoveredEl = null; + clearHoverGate(); highlightOverlay.style.display = "none"; } applyHiddenSelectors(); return; } + if (e.data.type === "runtime-structure-move") { + if (readOnly) return; + var runtimePlacement = String(e.data.placement || ""); + if (runtimePlacement !== "before" && runtimePlacement !== "after" && runtimePlacement !== "inside") { + return; + } + var runtimeSubject = findUniqueRuntimeStructureTarget( + String(e.data.subjectSelector || ""), + typeof e.data.subjectSourceId === "string" ? e.data.subjectSourceId : "" + ); + var runtimeAnchor = findUniqueRuntimeStructureTarget( + String(e.data.anchorSelector || ""), + typeof e.data.anchorSourceId === "string" ? e.data.anchorSourceId : "" + ); + if (!runtimeSubject || !runtimeAnchor || runtimeSubject === runtimeAnchor || runtimeSubject.contains(runtimeAnchor)) { + return; + } + if (runtimePlacement === "inside" && !isContainerDropTarget(runtimeAnchor) || runtimePlacement !== "inside" && !runtimeAnchor.parentElement) { + return; + } + var runtimeTarget = { + anchor: runtimeAnchor, + placement: runtimePlacement, + axis: parentFlowAxis( + runtimePlacement === "inside" ? runtimeAnchor : runtimeAnchor.parentElement + ), + dropMode: runtimePlacement === "inside" && isAbsolutePrimitiveContainer(runtimeAnchor) ? "absolute-container" : "flow-insert" + }; + var runtimeOrigin = { + prevParent: runtimeSubject.parentElement, + prevNextSibling: runtimeSubject.nextSibling, + prevInlinePositionStyles: snapshotInlinePositionStyles(runtimeSubject) + }; + applyRuntimeReorder(runtimeSubject, runtimeTarget); + selectedEl = runtimeSubject; + positionOverlay(selectionOverlay, selectedEl); + postVisualStructureChange(runtimeSubject, runtimeTarget, runtimeOrigin); + return; + } if (e.data.type === "visual-structure-ack") { var move = pendingStructureMoves[e.data.requestId]; if (!move) return; @@ -6514,6 +7196,37 @@ export const editorChromeBridgeScript: string = `"use strict"; window.addEventListener("scroll", scheduleRefreshOverlays, true); window.addEventListener("resize", scheduleRefreshOverlays); applyEditorChromeScale(); + if (runtimeLayerSnapshotEnabled && typeof MutationObserver !== "undefined" && document.body) { + var runtimeLayerObserver = new MutationObserver(function(mutations) { + var hasMeaningfulMutation = mutations.some( + runtimeLayerMutationIsMeaningful + ); + if (hasMeaningfulMutation) scheduleRuntimeLayerSnapshot(); + }); + runtimeLayerObserver.observe(document.body, { + attributes: true, + attributeOldValue: true, + childList: true, + characterData: true, + subtree: true, + attributeFilter: [ + "aria-label", + "class", + "data-agent-native-component", + "data-agent-native-layer-name", + "data-an-primitive", + "data-component-name", + "data-source-column", + "data-source-file", + "data-source-line", + "hidden", + "id", + "style", + "title" + ] + }); + } + if (runtimeLayerSnapshotEnabled) scheduleRuntimeLayerSnapshot(); window.parent.postMessage( { type: "agent-native:editor-chrome-ready" }, "*" diff --git a/templates/design/.generated/bridge/embedded-wheel.generated.ts b/templates/design/.generated/bridge/embedded-wheel.generated.ts index b6af1eaa46..d7ad5891dc 100644 --- a/templates/design/.generated/bridge/embedded-wheel.generated.ts +++ b/templates/design/.generated/bridge/embedded-wheel.generated.ts @@ -184,6 +184,11 @@ export const embeddedWheelBridgeScript: string = `"use strict"; } if (e.data.type === "embedded-canvas-pan-mode") { leftButtonEnabled = !!e.data.leftButtonEnabled; + return; + } + if (e.data.type === "embedded-canvas-gesture-mode") { + wheelEnabled = !!e.data.wheelEnabled; + spaceKeyForwardingEnabled = !!e.data.spaceKeyForwardingEnabled; } } function cancelActivePan() { diff --git a/templates/design/.generated/bridge/hit-test.generated.ts b/templates/design/.generated/bridge/hit-test.generated.ts index 14810f1ff2..85ac542708 100644 --- a/templates/design/.generated/bridge/hit-test.generated.ts +++ b/templates/design/.generated/bridge/hit-test.generated.ts @@ -148,7 +148,8 @@ export const hitTestBridgeScript: string = `"use strict"; function isAbsolutePrimitiveContainer(el) { if (!el || (el.tagName || "").toLowerCase() !== "div") return false; var primitive = (el.getAttribute("data-an-primitive") || el.getAttribute("data-agent-native-primitive") || "").toLowerCase(); - if (primitive !== "rectangle" && primitive !== "rect") return false; + if (primitive !== "rectangle" && primitive !== "rect" && primitive !== "frame") + return false; var cs = window.getComputedStyle(el); return cs.position === "absolute" || cs.position === "fixed"; } diff --git a/templates/design/.generated/bridge/motion-preview.generated.ts b/templates/design/.generated/bridge/motion-preview.generated.ts index 47ba06769d..23cedc6ed2 100644 --- a/templates/design/.generated/bridge/motion-preview.generated.ts +++ b/templates/design/.generated/bridge/motion-preview.generated.ts @@ -11,6 +11,16 @@ export const motionPreviewBridgeScript: string = `"use strict"; var loadedTimelineDurationMs = null; var touchedProps = {}; var originalInlineValues = {}; + var elementCache = {}; + function resolveTrackElement(nodeId) { + var cached = elementCache[nodeId]; + if (cached && document.contains(cached)) return cached; + var el = document.querySelector( + '[data-agent-native-node-id="' + nodeId + '"]' + ); + elementCache[nodeId] = el; + return el; + } function camelizeProp(prop) { return String(prop).replace(/-([a-z])/g, function(_m, c) { return c.toUpperCase(); @@ -477,9 +487,7 @@ export const motionPreviewBridgeScript: string = `"use strict"; function applyPreview(t) { for (var i = 0; i < loadedTracks.length; i++) { var track = loadedTracks[i]; - var el = document.querySelector( - '[data-agent-native-node-id="' + track.targetNodeId + '"]' - ); + var el = resolveTrackElement(track.targetNodeId); if (!el) continue; var value = interpolate(track.keyframes, trackLocalT(track, t)); if (value === "") continue; @@ -500,9 +508,7 @@ export const motionPreviewBridgeScript: string = `"use strict"; function clearPreview() { var nodeIds = Object.keys(touchedProps); for (var i = 0; i < nodeIds.length; i++) { - var el = document.querySelector( - '[data-agent-native-node-id="' + nodeIds[i] + '"]' - ); + var el = resolveTrackElement(nodeIds[i]); if (!el) continue; var props = touchedProps[nodeIds[i]]; for (var j = 0; j < props.length; j++) { @@ -515,6 +521,7 @@ export const motionPreviewBridgeScript: string = `"use strict"; loadedTracks = []; loadedDefaultEase = "ease"; loadedTimelineDurationMs = null; + elementCache = {}; } var lastPreviewT = null; window.addEventListener("message", function(e) { diff --git a/templates/design/.generated/bridge/sample.generated.ts b/templates/design/.generated/bridge/sample.generated.ts index 7a91e93056..5fb2dc56e7 100644 --- a/templates/design/.generated/bridge/sample.generated.ts +++ b/templates/design/.generated/bridge/sample.generated.ts @@ -7,6 +7,7 @@ export const sampleBridgeScript: string = `"use strict"; // app/components/design/bridge/sample.bridge.ts (function() { window.addEventListener("message", function(e) { + if (e.source !== window.parent) return; if (!e.data || e.data.type !== "agent-native:sample-ping") return; var correlationId = e.data.correlationId ?? ""; try { diff --git a/templates/design/AGENTS.md b/templates/design/AGENTS.md index 1da6db855d..f1034eb40f 100644 --- a/templates/design/AGENTS.md +++ b/templates/design/AGENTS.md @@ -67,6 +67,17 @@ patterns live in `.agents/skills/`. or pass it as an action parameter. Figma styles and variables are design-system inputs, not draggable media assets; route full file/design-system extraction through Builder-backed indexing. +- To import a Figma frame/screen as a real, editable Design screen (not a + rendered image), use `import-figma-frame` with a `figmaUrl` (or + `fileKey`/`nodeId`) — it maps position, auto-layout, text, fills/gradients, + strokes, corner radii, effects, opacity, and blend modes pixel-accurately, + falling back to an exact PNG only for vector networks/boolean ops/unsupported + node types, and saves the result as a new screen. Read the returned + `fidelityReport` (`approximated`, `imageFallbacks`) back to the user when + non-trivial. Use `get-figma-styles` for a file's published style names (not + the Enterprise Variables API; full token extraction still routes through + Builder-backed indexing). See the `design-systems` skill's "Import from + Figma" section. - Use Alpine.js and Tailwind CDN for interactive prototypes. Prefer Alpine directives over raw inline event handlers. - Navigate between prototype screens with Alpine state (`x-show`), a @@ -112,9 +123,14 @@ patterns live in `.agents/skills/`. workflow. - Persist useful work early: create/update the design and files as soon as a coherent candidate exists, then iterate. -- For non-trivial new design prompts, ask before generating: create/open the - design shell, call `show-design-questions`, stop while the main canvas shows - the questions, then continue from the user's answers. +- For a brief or ambiguous _new_ design prompt, ask before generating: + create/open the design shell, call `show-design-questions` with a small + tailored set, stop while the main canvas shows the questions, then continue + from the user's answers. Skip asking when the prompt is already specific, + it's a tweak/edit to an existing design, the user already answered a + question set for this design and is now iterating, or they say "decide for + me"/"surprise me"/"just build it" — see the `design-generation` skill for + how to size and word the questions. - For multiple screens/states, call `generate-screens` first. It opens the infinite screen overview and returns target filenames plus `canvasFrame` placements. Then call `generate-design` with those files and pass the matching @@ -202,12 +218,27 @@ patterns live in `.agents/skills/`. layers: text, classes, styles, attributes, source order, and small structural changes. Use it for selected-element edits before falling back to full `update-design` / `generate-design` rewrites. +- For localhost React/TSX screens, treat compiler/debug metadata (project-relative + source file, line, column, component, and runtime multiplicity) as evidence + for locating source, not as permission to run a generic AST transform. + Compiler tooling may verify an anchor, classify a literal edit, and validate + syntax. Reparenting, grouping/ungrouping, wrappers, dynamic expressions, + repeated `.map()` instances, shared components, and cross-file changes must + be handed to the coding agent with both runtime relationships and exact + source anchors so it can inspect the surrounding program semantics. +- A localhost React handoff must read each source file first, write with the + exact returned `versionHash` as `expectedVersionHash` with + `requireExpectedVersionHash: true`, re-read and re-plan on conflict, and leave the + optimistic canvas preview in place until the dev server/HMR confirms the + runtime result. Never report a semantic canvas change as persisted merely + because it was submitted to the coding agent. - Prefer `data-agent-native-layer-name="Readable name"` on meaningful elements. The projection uses it before semantic/text fallbacks, and layer renames should persist by updating that attribute. -- Current limitation: visual code-layer edits are HTML-only. Localhost source - mode can list and resolve local routes now, but file reads/writes remain a - bridge contract until explicit permission controls are hardened. +- Inline/Alpine screens continue to use deterministic HTML code-layer edits. + Localhost React/TSX screens use the semantic coding-agent handoff above; + deterministic direct React writes remain intentionally limited to narrowly + proven literal edits and never include generic structural transforms. ## Code Workspace @@ -282,12 +313,16 @@ patterns live in `.agents/skills/`. and live app captures. See the same skill section. - **Components**: `create-component` promotes a selected element into a recognised reusable component; `index-components` scans a design's HTML for - existing component annotations; `get-component-details`, + existing component annotations; `list-design-components` scans all HTML + screens for swap targets; `get-component-details`, `preview-component-prop-edit`, `apply-component-prop-edit`, and `open-component-source` inspect, preview, persist, and navigate to a - component instance. See the `design-generation` skill's "Component reuse" - section — promote a 3+ times repeated pattern instead of inventing another - near-duplicate. + component instance. Use `go-to-main-component` to select the earliest known + instance, `swap-component-instance` to replace an inline/Alpine instance + while preserving same-named prop overrides, and + `detach-component-instance` to turn an instance into plain editable markup. + See the `design-generation` skill's "Component reuse" section — promote a + 3+ times repeated pattern instead of inventing another near-duplicate. ## Full App Building diff --git a/templates/design/actions/add-localhost-screens.action.spec.ts b/templates/design/actions/add-localhost-screens.action.spec.ts index 7d6592a6d3..a082510c43 100644 --- a/templates/design/actions/add-localhost-screens.action.spec.ts +++ b/templates/design/actions/add-localhost-screens.action.spec.ts @@ -28,11 +28,22 @@ const mocks = vi.hoisted(() => ({ }>, selectCount: 0, insertedFile: null as Record | null, + insertedFiles: [] as Array>, updatedFiles: [] as Array<{ values: Record; where: unknown; }>, updatedDesignData: null as Record | null, + // Forces the next insert() to throw a unique-constraint-violation error + // once, simulating a concurrent request winning the insert race for the + // same (design_id, filename) pair. + insertConflictOnce: false, + // The row a concurrent request is simulated to have already committed — + // returned by the conflict-recovery lookup after insertConflictOnce fires. + winnerFile: null as Record | null, + // Forces the next insert() to throw a non-constraint error once, to prove + // the conflict recovery path doesn't swallow unrelated failures. + insertGenericErrorOnce: false, }, })); @@ -93,13 +104,40 @@ vi.mock("../server/db/index.js", () => ({ }), }; } + if (mocks.state.selectCount === 3) { + return { + from: () => ({ where: () => Promise.resolve(mocks.state.files) }), + }; + } + // 4th+ select: the conflict-recovery lookup for whichever row won a + // simulated concurrent insert race (see insertConflictOnce/winnerFile). return { - from: () => ({ where: () => Promise.resolve(mocks.state.files) }), + from: () => ({ + where: () => ({ + limit: () => + Promise.resolve( + mocks.state.winnerFile ? [mocks.state.winnerFile] : [], + ), + }), + }), }; }, insert: () => ({ values: (values: Record) => { + if (mocks.state.insertGenericErrorOnce) { + mocks.state.insertGenericErrorOnce = false; + throw new Error("boom: unrelated insert failure"); + } + if (mocks.state.insertConflictOnce) { + mocks.state.insertConflictOnce = false; + const err = new Error( + 'duplicate key value violates unique constraint "design_files_design_filename_unique_idx"', + ); + (err as unknown as { code: string }).code = "23505"; + throw err; + } mocks.state.insertedFile = values; + mocks.state.insertedFiles.push(values); return Promise.resolve(); }, }), @@ -126,8 +164,12 @@ describe("add-localhost-screens refresh behavior", () => { beforeEach(() => { mocks.state.selectCount = 0; mocks.state.insertedFile = null; + mocks.state.insertedFiles = []; mocks.state.updatedFiles = []; mocks.state.updatedDesignData = null; + mocks.state.insertConflictOnce = false; + mocks.state.winnerFile = null; + mocks.state.insertGenericErrorOnce = false; mocks.assertAccess.mockReset().mockResolvedValue(undefined); mocks.applyText.mockReset().mockResolvedValue(undefined); mocks.hasCollabState.mockReset().mockResolvedValue(false); @@ -297,4 +339,98 @@ describe("add-localhost-screens refresh behavior", () => { }); expect(result.screens[0]?.filename).toBe("localhost-home-2.html"); }); + + it("never inserts two design_files rows for the same route requested twice in one call", async () => { + // `existingFiles`/route-candidate matching is snapshotted once above the + // per-route loop and never refreshed mid-loop, so two entries resolving + // to the same route (repeated `paths`, or a `path` duplicating a + // `routeId`-addressed entry) used to each see "no existing match" and + // each insert their own design_files row for the identical route. + const result = await action.run({ + designId: "design_1", + connectionId: "conn_1", + paths: ["/settings", "/settings"], + startX: 0, + startY: 0, + gap: 160, + }); + + expect(mocks.state.insertedFiles).toHaveLength(1); + expect(mocks.state.insertedFiles[0]).toMatchObject({ + filename: "localhost-settings.html", + }); + expect(result.screens).toHaveLength(1); + }); + + it("still creates distinct screens when the same route is requested twice with different explicit viewports", async () => { + // A duplicate route request is only collapsed when its explicit + // width/height also match — an intentional multi-viewport request for + // the same route must still create its own variant per viewport. + const result = await action.run({ + designId: "design_1", + connectionId: "conn_1", + routes: [ + { path: "/settings", width: 1280, height: 900 }, + { path: "/settings", width: 390, height: 844 }, + ], + startX: 0, + startY: 0, + gap: 160, + }); + + expect(mocks.state.insertedFiles).toHaveLength(2); + expect(result.screens).toHaveLength(2); + }); + + it("recovers when a concurrent request wins the insert race for the same route/filename", async () => { + // Cross-request race: this request's `existingFiles` snapshot (taken once, + // up front) found no match for /settings, but by the time its insert + // executes a concurrent add-localhost-screens call has already committed + // the winning design_files row for the identical (design_id, filename) + // pair. The design_files_design_filename_unique_idx unique index (see + // server/plugins/db.ts) turns that into a real constraint-violation error + // — forced here via insertConflictOnce — which the action must catch and + // recover from by adopting the winning row instead of throwing. + mocks.state.insertConflictOnce = true; + mocks.state.winnerFile = { + id: "winner_file", + designId: "design_1", + filename: "localhost-settings.html", + fileType: "html", + content: "http://localhost:5173/settings?winner=1", + }; + + const result = await action.run({ + designId: "design_1", + connectionId: "conn_1", + paths: ["/settings"], + startX: 0, + startY: 0, + gap: 160, + }); + + expect(mocks.state.insertedFile).toBeNull(); + expect(mocks.state.updatedFiles).toHaveLength(1); + expect(result.screens).toHaveLength(1); + expect(result.screens[0]?.id).toBe("winner_file"); + expect(result.placedFrames[0]?.fileId).toBe("winner_file"); + }); + + it("rethrows a non-conflict insert error instead of silently swallowing it", async () => { + // isUniqueConstraintViolation must only catch the specific + // unique/primary-key-violation error class it's meant to recover from — + // any other insert failure (e.g. a connection error) must still surface. + mocks.state.insertGenericErrorOnce = true; + + await expect( + action.run({ + designId: "design_1", + connectionId: "conn_1", + paths: ["/settings"], + startX: 0, + startY: 0, + gap: 160, + }), + ).rejects.toThrow("boom: unrelated insert failure"); + }); }); diff --git a/templates/design/actions/add-localhost-screens.ts b/templates/design/actions/add-localhost-screens.ts index 28c4aa8215..5bfcc2ef9b 100644 --- a/templates/design/actions/add-localhost-screens.ts +++ b/templates/design/actions/add-localhost-screens.ts @@ -25,6 +25,7 @@ import { type CanvasFrameGeometry, type CanvasFramePlacement, } from "../shared/canvas-frames.js"; +import { isUniqueConstraintViolation } from "../shared/db-conflict.js"; import { makeLocalhostRouteId, titleFromRoutePath, @@ -488,6 +489,18 @@ export default defineAction({ height: number; }> = []; const placementIntents: PlacementIntent[] = []; + // Duplicate-route guard: `existingFiles`/`existingByFilename`/the + // route-candidate lookups below are all snapshotted ONCE above the loop + // and never refreshed as new files are inserted mid-loop, so two entries + // in the SAME `requestedRoutes` call that resolve to the same route + // (repeated `paths`, or a `routeId` and a `path` naming the same route) + // each independently see "no existing match" and each insert a fresh + // `design_files` row — two overlapping screens for one route. Track + // routeIds already processed THIS call (keyed with width/height so an + // intentional multi-viewport request for the same route still creates + // its distinct variants) and skip later duplicates outright. + const seenRouteRequestKeys = new Set(); + let placementIndex = 0; for (let index = 0; index < requestedRoutes.length; index += 1) { const input = requestedRoutes[index]!; @@ -505,6 +518,9 @@ export default defineAction({ ); const routeId = input.routeId ?? manifestRoute?.id ?? makeLocalhostRouteId(path); + const routeRequestKey = `${routeId}::${input.width ?? ""}x${input.height ?? ""}`; + if (seenRouteRequestKeys.has(routeRequestKey)) continue; + seenRouteRequestKeys.add(routeRequestKey); const title = input.title ?? manifestRoute?.title ?? titleFromRoutePath(path); const sourceFile = input.sourceFile ?? manifestRoute?.sourceFile; @@ -619,7 +635,9 @@ export default defineAction({ const filename = existing?.filename ?? uniqueFilename(path, usedFilenames, preferredFilename); - const fileId = existing?.id ?? nanoid(); + // Reassigned below if a concurrent request wins the insert race for + // this exact (designId, filename) pair — see the `else` branch. + let fileId = existing?.id ?? nanoid(); const existingScreenMetadata = existing ? metadataForFile( existing.id, @@ -656,16 +674,50 @@ export default defineAction({ await seedFromText(existing.id, url); } } else { - await db.insert(schema.designFiles).values({ - id: fileId, - designId, - filename, - fileType: "html", - content: url, - createdAt: now, - updatedAt: now, - }); - await seedFromText(fileId, url); + try { + await db.insert(schema.designFiles).values({ + id: fileId, + designId, + filename, + fileType: "html", + content: url, + createdAt: now, + updatedAt: now, + }); + await seedFromText(fileId, url); + } catch (err) { + if (!isUniqueConstraintViolation(err)) throw err; + // Cross-request race: this snapshot's `existingFiles` query ran + // before a concurrent add-localhost-screens call (for the same + // route) committed its own insert, so both requests independently + // computed the same deterministic filename and both tried to + // create it. The `design_files_design_filename_unique_idx` unique + // index (see server/plugins/db.ts) turns the loser's insert into + // this error instead of a silent duplicate screen. Recover by + // adopting whichever row actually won the race and updating its + // content, the same way the `existing` branch above does. + const [winner] = await db + .select() + .from(schema.designFiles) + .where( + and( + eq(schema.designFiles.designId, designId), + eq(schema.designFiles.filename, filename), + ), + ) + .limit(1); + if (!winner) throw err; + fileId = winner.id; + await db + .update(schema.designFiles) + .set({ content: url, fileType: "html", updatedAt: now }) + .where(eq(schema.designFiles.id, winner.id)); + if (await hasCollabState(winner.id)) { + await applyText(winner.id, url, "content", "agent"); + } else { + await seedFromText(winner.id, url); + } + } } savedScreens.push({ @@ -688,12 +740,13 @@ export default defineAction({ x: input.x ?? existingFrame?.x ?? - layoutStartX + index * (width + layoutGap), + layoutStartX + placementIndex * (width + layoutGap), y: input.y ?? existingFrame?.y ?? layoutStartY, width, height, - z: input.z ?? existingFrame?.z ?? index, + z: input.z ?? existingFrame?.z ?? placementIndex, }; + placementIndex += 1; placementIntents.push({ fileId, filename, diff --git a/templates/design/actions/apply-component-prop-edit.interleave.spec.ts b/templates/design/actions/apply-component-prop-edit.interleave.spec.ts index c7019db481..8bd321428b 100644 --- a/templates/design/actions/apply-component-prop-edit.interleave.spec.ts +++ b/templates/design/actions/apply-component-prop-edit.interleave.spec.ts @@ -28,6 +28,7 @@ import * as Y from "yjs"; // Fake @agent-native/core/collab backed by a real per-docId Y.Doc registry. // --------------------------------------------------------------------------- const collabDocs = vi.hoisted(() => ({ docs: new Map() })); +const accessState = vi.hoisted(() => ({ sourceType: "inline" })); function getOrCreateDoc(docId: string): InstanceType { let doc = collabDocs.docs.get(docId) as @@ -83,10 +84,10 @@ vi.mock("@agent-native/core/collab", () => ({ vi.mock("@agent-native/core/sharing", () => ({ assertAccess: vi.fn().mockResolvedValue({ role: "editor", resource: {} }), - resolveAccess: vi.fn().mockResolvedValue({ + resolveAccess: vi.fn(async () => ({ role: "editor", - resource: { data: JSON.stringify({ sourceType: "inline" }) }, - }), + resource: { data: JSON.stringify({ sourceType: accessState.sourceType }) }, + })), accessFilter: vi.fn().mockReturnValue(undefined), })); @@ -207,10 +208,31 @@ function baseDoc(): string { beforeEach(() => { collabDocs.docs.clear(); designFilesStore.rows.clear(); + accessState.sourceType = "inline"; seedFile(baseDoc()); }); describe("apply-component-prop-edit CAS safety (false-CAS fix)", () => { + it("fails closed for localhost sources without touching the SQL HTML mirror", async () => { + accessState.sourceType = "localhost"; + const original = currentFileRef().content; + + const result = await action.run({ + designId: DESIGN_ID, + nodeId: "btn-1", + edit: { kind: "classReplace", from: "bg-blue-500", to: "bg-red-500" }, + } as never); + + expect(result).toMatchObject({ + sourceType: "localhost", + persisted: false, + ctaRequired: true, + }); + expect(result.ctaMessage).toMatch(/dedicated consented/i); + expect(currentFileRef().content).toBe(original); + expect(collabDocs.docs.size).toBe(0); + }); + it("succeeds and preserves a sibling's concurrent edit when the action's own read observes the latest base (no clobber of a landed sibling write)", async () => { await seedFromText(FILE_ID, baseDoc()); @@ -236,6 +258,28 @@ describe("apply-component-prop-edit CAS safety (false-CAS fix)", () => { expect(finalLive.content).toContain("bg-red-500"); }); + it("preserves an unsaved caller working copy when its revision still matches the unchanged live base", async () => { + const workingCopy = baseDoc().replace( + "Sibling text", + "Sibling text (unsaved locally)", + ); + + const result = (await action.run({ + designId: DESIGN_ID, + nodeId: "btn-1", + edit: { kind: "classReplace", from: "bg-blue-500", to: "bg-red-500" }, + source: { + currentContent: workingCopy, + revision: "2026-07-06T00:00:00.000Z", + }, + } as never)) as { persisted: boolean }; + + expect(result.persisted).toBe(true); + const finalLive = await readLiveSourceFile(currentFileRef()); + expect(finalLive.content).toContain("Sibling text (unsaved locally)"); + expect(finalLive.content).toContain("bg-red-500"); + }); + it("rejects a persist whose expectedVersionHash is stale relative to what's live, instead of silently overwriting the concurrent writer's change", async () => { // Prove the CAS is a real check (not a check-against-self no-op): build // the exact false-CAS shape the bug had directly against diff --git a/templates/design/actions/apply-component-prop-edit.ts b/templates/design/actions/apply-component-prop-edit.ts index 1fbc3397e7..be0d3155aa 100644 --- a/templates/design/actions/apply-component-prop-edit.ts +++ b/templates/design/actions/apply-component-prop-edit.ts @@ -12,11 +12,12 @@ * HTML attribute on the component root. * - `classReplace` — replaces one Tailwind class with another on the root node. * - * **Tier B (real-app, localhost / fusion):** prop writes require the - * `applyEdit` source capability. Localhost sources have it (design bridge + - * user write consent); fusion sources gain it after bridge hardening. For - * sources without it the action returns a `ctaRequired: true` response and - * does not modify any source. + * **Real-app sources (localhost / fusion):** deliberately fail closed. This + * action's patcher operates on SQL-backed HTML design files; it must never be + * reused for compiled JSX/TSX source. Real-app prop persistence needs a + * dedicated consented, version-guarded bridge transform. Until that exists, + * callers may preview but this action returns `ctaRequired: true` without + * reading or modifying a design file. * * See DESIGN-STUDIO-PLAN.md §6.1, §7 (preview/apply contract), §11 phase 2. */ @@ -38,10 +39,11 @@ import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; import "../server/db/index.js"; // ensure registerShareableResource runs import { + prepareInlineSourceEdit, + SourceWorkspaceEditConflictError, writeInlineSourceFile, type SourceWorkspaceFile, } from "../server/source-workspace.js"; -import { resolveSourceCapabilities } from "../shared/capability-resolver.js"; import { applyVisualEdit, buildCodeLayerProjection, @@ -56,9 +58,7 @@ import { componentNameFor, componentNodeIdMatches, } from "../shared/component-model.js"; -import { hasCapability } from "../shared/design-source-capabilities.js"; import { designSourceTypeFromData } from "../shared/source-mode.js"; -import { sourceContentHash } from "../shared/source-workspace.js"; // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -174,8 +174,8 @@ export default defineAction({ "For inline/Alpine designs, edits the data-agent-native-prop-* attributes, " + "x-data expression, or class list of the component root via the deterministic " + "HTML-patch path (same seam as apply-visual-edit). " + - "For real-app sources, the applyEdit capability must be available; if not, " + - "returns ctaRequired=true without modifying any file.", + "For real-app sources, returns ctaRequired=true without modifying any file; " + + "compiled source requires a dedicated consented bridge transform.", schema: z.object({ designId: z.string().describe("Design project ID"), nodeId: z @@ -234,19 +234,21 @@ export default defineAction({ .optional(), }), run: async ({ designId, nodeId, fileId, edit, source }) => { - const db = getDb(); - // ── Access check ──────────────────────────────────────────────────────── const access = await resolveAccess("design", designId); if (!access) throw new Error("Design not found"); - // ── Source type + capability gate ──────────────────────────────────────── + // ── Source type gate ──────────────────────────────────────────────────── const rawData = (access.resource as { data?: unknown }).data; const sourceType = designSourceTypeFromData(rawData); - const caps = resolveSourceCapabilities(sourceType); - // Real-app sources gate on `applyEdit` (bridge write hardening). - if (sourceType !== "inline" && !hasCapability(caps, "applyEdit")) { + // Fail closed for every real-app tier even if its generic capability map + // advertises applyEdit. This action only knows how to patch SQL-backed HTML; + // allowing localhost through here could report success for the mirror while + // leaving the real JSX/TSX file untouched. A future compiled-source action + // must perform consent, canonical path resolution, AST anchoring, and an + // expected-version bridge write as one dedicated transaction. + if (sourceType !== "inline") { return { designId, nodeId, @@ -254,13 +256,14 @@ export default defineAction({ persisted: false, ctaRequired: true, ctaMessage: - "Prop write-back to real app sources requires the bridge applyEdit " + - "capability, which lands with bridge write hardening. " + + "Prop write-back to real app sources requires a dedicated consented, " + + "version-guarded compiled-source transform. " + "Use preview-component-prop-edit to preview without persisting.", }; } await assertAccess("design", designId, "editor"); + const db = getDb(); // ── Fetch file ─────────────────────────────────────────────────────────── const conditions = [ @@ -289,12 +292,24 @@ export default defineAction({ if (!file) throw new Error("Design HTML file not found."); - if ( - source?.currentContent && - source.revision && - file.updatedAt && - source.revision !== file.updatedAt - ) { + const workspaceFile: SourceWorkspaceFile = { + id: file.id, + designId: file.designId, + filename: file.filename, + fileType: "html", + content: file.content, + createdAt: null, + updatedAt: file.updatedAt, + }; + let prepared: Awaited>; + try { + prepared = await prepareInlineSourceEdit({ + file: workspaceFile, + currentContent: source?.currentContent, + revision: source?.revision, + }); + } catch (error) { + if (!(error instanceof SourceWorkspaceEditConflictError)) throw error; return { designId, nodeId, @@ -307,18 +322,12 @@ export default defineAction({ }; } - // Prefer explicit editor content after the caller's revision check, and use - // the saved SQL content as the fallback. Collab/Yjs reads can be stale - // across local dev worker processes and make prop controls lag behind. - const html = - typeof source?.currentContent === "string" - ? source.currentContent - : (file.content ?? ""); - // Capture the hash of this EXACT base — the same string the transform - // below reads from — so persistEdit can pass it through as - // expectedVersionHash. A re-read at persist time would hash whatever is - // live "then" instead of proving this base is still current. - const baseVersionHash = sourceContentHash(html); + // The transform runs against the caller's working copy (when supplied), + // while the persist CAS uses the live hash that working copy is allowed to + // replace. Keeping those identities separate preserves rapid unsaved prop + // edits without weakening concurrent-writer rejection. + const html = prepared.content; + const baseVersionHash = prepared.expectedVersionHash; // ── Resolve node ───────────────────────────────────────────────────────── const codeLayerSource: CodeLayerSource = { diff --git a/templates/design/actions/apply-motion-edit.interleave.spec.ts b/templates/design/actions/apply-motion-edit.interleave.spec.ts index c1d24ee30b..488622aa47 100644 --- a/templates/design/actions/apply-motion-edit.interleave.spec.ts +++ b/templates/design/actions/apply-motion-edit.interleave.spec.ts @@ -391,6 +391,27 @@ describe("apply-motion-edit collab-aware persist (contract-bypass fix)", () => { expect(finalLive.content).toContain("data-agent-native-motion"); }); + it("preserves an unsaved caller working copy when its revision still matches the unchanged live base", async () => { + const workingCopy = baseDoc().replace( + "Caption text", + "Caption text (unsaved locally)", + ); + + const result = await action.run({ + designId: DESIGN_ID, + fileId: FILE_ID, + tracks: oneTrack(), + durationMs: 500, + currentContent: workingCopy, + revision: "2026-07-06T00:00:00.000Z", + } as never); + + expect(result.persisted).toBe(true); + const finalLive = await readLiveSourceFile(currentFileRef()); + expect(finalLive.content).toContain("Caption text (unsaved locally)"); + expect(finalLive.content).toContain("data-agent-native-motion"); + }); + it("rejects (loud, not silent) when a concurrent write lands using a caller-supplied stale currentContent base", async () => { // The action supports a caller-supplied `currentContent` param used // instead of the live read as the patch base. If that base has gone diff --git a/templates/design/actions/apply-motion-edit.ts b/templates/design/actions/apply-motion-edit.ts index f8f3972da2..2819bdf40b 100644 --- a/templates/design/actions/apply-motion-edit.ts +++ b/templates/design/actions/apply-motion-edit.ts @@ -32,7 +32,7 @@ import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; import "../server/db/index.js"; // ensure registerShareableResource runs import { - readLiveSourceFile, + prepareInlineSourceEdit, writeInlineSourceFile, type SourceWorkspaceFile, } from "../server/source-workspace.js"; @@ -51,7 +51,6 @@ import { readTimelinePlaybackMode, withTimelinePlaybackMode, } from "../shared/motion-timeline.js"; -import { sourceContentHash } from "../shared/source-workspace.js"; // ─── DoS-guard caps ────────────────────────────────────────────────────────── // @@ -343,7 +342,15 @@ export default defineAction({ .describe( "Current open editor HTML for the target file. When supplied, the " + "managed motion CSS is patched into this content instead of the " + - "last SQL snapshot so in-flight local edits are preserved.", + "last SQL snapshot so in-flight local edits are preserved. " + + "revision must also be supplied.", + ), + revision: z + .string() + .optional() + .describe( + "design_files.updatedAt value the currentContent is based on. " + + "Required when currentContent is supplied.", ), }), run: async ({ @@ -357,6 +364,7 @@ export default defineAction({ defaultEase, includeContent, currentContent: currentContentInput, + revision, }) => { const access = await assertAccess("design", designId, "editor"); @@ -378,6 +386,7 @@ export default defineAction({ filename: schema.designFiles.filename, fileType: schema.designFiles.fileType, content: schema.designFiles.content, + updatedAt: schema.designFiles.updatedAt, }) .from(schema.designFiles) .innerJoin( @@ -398,40 +407,25 @@ export default defineAction({ const fileId = file.id; const resolvedSourceRef = sourceRef ?? fileId; - // The EDITABLE base for compiling/injecting CSS: prefer the caller's - // in-flight editor snapshot when supplied (currentContentInput), else the - // LIVE collab-authoritative content for this file (not the raw SQL row — - // a live editor's Y.Text may be ahead of the last SQL snapshot). Either - // way, `baseVersionHash` is computed from the EXACT string chosen as the - // base, at this same point in time, so it faithfully represents "what - // this transform's base was" for the persist's optimistic-concurrency - // check below — never a fresh re-hash of already-transformed content - // (that would trivially match itself and prove nothing raced). - let currentContent: string; - let baseVersionHash: string; - if (currentContentInput !== undefined) { - // Caller supplied their own editor snapshot as the patch base. It may - // already be stale relative to the live doc — that's fine. - // writeInlineSourceFile's own guard (via readLiveSourceFile at write - // time) correctly rejects the write if this base no longer matches - // live state, exactly as designed for every other caller-supplied-base - // action (see apply-component-prop-edit.ts / apply-shader-fill.ts). - currentContent = currentContentInput; - baseVersionHash = sourceContentHash(currentContent); - } else { - const workspaceFileForRead: SourceWorkspaceFile = { + // Keep the transform working copy separate from the live version it may + // replace. A caller snapshot can legitimately contain unsaved local edits + // beyond the matching SQL revision; the write CAS must guard the observed + // live base, not incorrectly hash those unsaved edits as if already live. + const prepared = await prepareInlineSourceEdit({ + file: { id: file.id, designId: file.designId, filename: file.filename, fileType: file.fileType, content: file.content, createdAt: null, - updatedAt: null, - }; - const live = await readLiveSourceFile(workspaceFileForRead); - currentContent = live.content; - baseVersionHash = live.versionHash; - } + updatedAt: file.updatedAt, + }, + currentContent: currentContentInput, + revision, + }); + const currentContent = prepared.content; + const baseVersionHash = prepared.expectedVersionHash; // ── 2. Compile tracks → CSS ───────────────────────────────────────────── const inputTracks = tracks as MotionTrack[]; diff --git a/templates/design/actions/apply-shader-fill.interleave.spec.ts b/templates/design/actions/apply-shader-fill.interleave.spec.ts index 181a1b9c38..3ea456e559 100644 --- a/templates/design/actions/apply-shader-fill.interleave.spec.ts +++ b/templates/design/actions/apply-shader-fill.interleave.spec.ts @@ -271,6 +271,71 @@ describe("apply-shader-fill collab-aware persist (contract-bypass fix)", () => { expect(finalLive.content).toContain("background:"); }); + it("preserves an unsaved caller working copy when its revision still matches the unchanged live base", async () => { + const workingCopy = baseDoc().replace( + "Caption text", + "Caption text (unsaved locally)", + ); + + const result = await action.run( + shaderFillArgs({ + source: { + kind: "design-file", + designId: DESIGN_ID, + fileId: FILE_ID, + currentContent: workingCopy, + revision: "2026-07-06T00:00:00.000Z", + }, + }) as never, + ); + + expect(result.ok).toBe(true); + expect(result.persisted).toBe(true); + const finalLive = await readLiveSourceFile(currentFileRef()); + expect(finalLive.content).toContain("Caption text (unsaved locally)"); + expect(finalLive.content).toContain("background:"); + }); + + it("rejects a third live value even when the caller's SQL revision still matches", async () => { + const persistedBase = baseDoc(); + const workingCopy = persistedBase.replace( + "Caption text", + "Caption text (unsaved locally)", + ); + await ( + await import("@agent-native/core/collab") + ).seedFromText(FILE_ID, persistedBase); + const concurrentLive = persistedBase.replace( + "Caption text", + "Caption text (edited concurrently)", + ); + await applyText(FILE_ID, concurrentLive, "content", "agent"); + // Deliberately leave SQL at persistedBase with the matching revision. The + // live Y.Text is the independent third value that must win the conflict. + + const result = await action.run( + shaderFillArgs({ + source: { + kind: "design-file", + designId: DESIGN_ID, + fileId: FILE_ID, + currentContent: workingCopy, + revision: "2026-07-06T00:00:00.000Z", + }, + }) as never, + ); + + expect(result).toMatchObject({ + ok: false, + persisted: false, + conflict: true, + }); + const finalLive = await readLiveSourceFile(currentFileRef()); + expect(finalLive.content).toContain("Caption text (edited concurrently)"); + expect(finalLive.content).not.toContain("unsaved locally"); + expect(finalLive.content).not.toContain("background:"); + }); + it("rejects loud (ShaderFillRevisionConflictError shape) instead of silently clobbering when currentContent has gone stale by write time", async () => { const staleBase = baseDoc(); diff --git a/templates/design/actions/apply-shader-fill.ts b/templates/design/actions/apply-shader-fill.ts index dc67cc66c3..c68f7eb115 100644 --- a/templates/design/actions/apply-shader-fill.ts +++ b/templates/design/actions/apply-shader-fill.ts @@ -38,7 +38,8 @@ import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; import { - readLiveSourceFile, + prepareInlineSourceEdit, + SourceWorkspaceEditConflictError, writeInlineSourceFile, type SourceWorkspaceFile, } from "../server/source-workspace.js"; @@ -58,7 +59,6 @@ import { type ShaderPresetName, validateDescriptor, } from "../shared/shader-presets.js"; -import { sourceContentHash } from "../shared/source-workspace.js"; // ─── Schema ────────────────────────────────────────────────────────────────── @@ -163,14 +163,10 @@ interface ResolvedDesignFile { * editor access. Mirrors `resolveEditableDesignFile` in apply-visual-edit.ts so * the shader-fill write goes through the exact same ownership gate. * - * The editable base is collab-aware: `readLiveSourceFile` returns the live - * Y.Text content when a collab doc exists for this file, else the SQL row — - * never only the raw SQL snapshot. When the caller supplies - * `source.currentContent`, that in-flight editor snapshot is preferred as the - * base instead (same precedence as apply-motion-edit.ts / apply-component- - * prop-edit.ts), and its versionHash is computed directly from that exact - * string via `sourceContentHash` — not from a fresh live re-read, which would - * prove nothing about whether this specific base is still current. + * `prepareInlineSourceEdit` keeps the caller's working copy (the transform + * base) separate from the live hash it is allowed to replace (the write CAS). + * This preserves unsaved local edits based on the matching SQL revision while + * still rejecting a third, concurrently-edited live value. */ async function resolveEditableDesignFile(source: { designId?: string; @@ -241,36 +237,27 @@ async function resolveEditableDesignFile(source: { "source.revision is required when source.currentContent is provided.", ); } - // Legitimate pre-check, distinct from the CAS write-guard below: reject - // early when the caller's own revision stamp doesn't match the file's SQL - // updatedAt, before any transform work happens. - if ( - source.currentContent !== undefined && - source.revision && - file.updatedAt && - source.revision !== file.updatedAt - ) { - throw new ShaderFillRevisionConflictError(); - } - - let content: string; - let versionHash: string; - if (source.currentContent !== undefined) { - content = source.currentContent; - versionHash = sourceContentHash(content); - } else { - const workspaceFile: SourceWorkspaceFile = { - id: file.id, - designId: file.designId, - filename: file.filename, - fileType: file.fileType, - content: file.content, - createdAt: null, - updatedAt: file.updatedAt, - }; - const live = await readLiveSourceFile(workspaceFile); - content = live.content; - versionHash = live.versionHash; + const workspaceFile: SourceWorkspaceFile = { + id: file.id, + designId: file.designId, + filename: file.filename, + fileType: file.fileType, + content: file.content, + createdAt: null, + updatedAt: file.updatedAt, + }; + let prepared: Awaited>; + try { + prepared = await prepareInlineSourceEdit({ + file: workspaceFile, + currentContent: source.currentContent, + revision: source.revision, + }); + } catch (error) { + if (error instanceof SourceWorkspaceEditConflictError) { + throw new ShaderFillRevisionConflictError(); + } + throw error; } return { @@ -278,8 +265,8 @@ async function resolveEditableDesignFile(source: { designId: file.designId, filename: file.filename, fileType: file.fileType, - content, - versionHash, + content: prepared.content, + versionHash: prepared.expectedVersionHash, codeLayerSource: { kind: "design-file", designId: file.designId, diff --git a/templates/design/actions/detach-component-instance.spec.ts b/templates/design/actions/detach-component-instance.spec.ts new file mode 100644 index 0000000000..497fb4d5ad --- /dev/null +++ b/templates/design/actions/detach-component-instance.spec.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest"; + +import { buildCodeLayerProjection } from "../shared/code-layer.js"; +import { componentNodeIdMatches } from "../shared/component-model.js"; +import action, { + stripComponentAnnotations, +} from "./detach-component-instance.js"; + +// --------------------------------------------------------------------------- +// Schema +// --------------------------------------------------------------------------- + +describe("detach-component-instance schema", () => { + it("accepts the minimal designId + nodeId payload", () => { + expect( + action.schema.safeParse({ designId: "design_1", nodeId: "node_1" }) + .success, + ).toBe(true); + }); + + it("accepts an optional fileId and source revision guard", () => { + const parsed = action.schema.safeParse({ + designId: "design_1", + nodeId: "node_1", + fileId: "file_about", + source: { currentContent: "
", revision: "2024-01-01" }, + }); + expect(parsed.success).toBe(true); + }); + + it("rejects a payload missing nodeId", () => { + expect(action.schema.safeParse({ designId: "design_1" }).success).toBe( + false, + ); + }); +}); + +// --------------------------------------------------------------------------- +// stripComponentAnnotations — pure transform +// --------------------------------------------------------------------------- + +function findNode(html: string, nodeId: string) { + const projection = buildCodeLayerProjection(html, { + source: { + kind: "design-file", + designId: "d1", + fileId: "f1", + filename: "index.html", + }, + }); + const node = projection.nodes.find((n) => componentNodeIdMatches(n, nodeId)); + if (!node) throw new Error(`test fixture node "${nodeId}" not found`); + return node; +} + +describe("stripComponentAnnotations", () => { + it("removes the component annotation and prop attributes, preserving everything else", () => { + const html = + '
'; + + const node = findNode(html, "btn1"); + const result = stripComponentAnnotations(html, node.source); + + expect(result.changed).toBe(true); + expect(result.removedAttributes).toContain("data-agent-native-component"); + expect(result.removedAttributes).toContain( + "data-agent-native-prop-variant", + ); + expect(result.removedAttributes).toContain("data-agent-native-prop-size"); + + expect(result.content).not.toContain("data-agent-native-component"); + expect(result.content).not.toContain("data-agent-native-prop-"); + // Preserved: node id, classes, x-data, text content, tag. + expect(result.content).toContain('data-agent-native-node-id="btn1"'); + expect(result.content).toContain('class="rounded px-4"'); + expect(result.content).toContain('x-data="{ open: false }"'); + expect(result.content).toContain(">Save"); + }); + + it("is a no-op (changed: false) when the node has no component annotation", () => { + const html = + '
Hi
'; + const node = findNode(html, "plain1"); + const result = stripComponentAnnotations(html, node.source); + expect(result.changed).toBe(false); + expect(result.content).toBe(html); + expect(result.removedAttributes).toEqual([]); + }); + + it("returns unchanged content when the source span is missing", () => { + const result = stripComponentAnnotations("
", null); + expect(result).toEqual({ + content: "
", + changed: false, + removedAttributes: [], + }); + }); + + it("does not disturb sibling instances of the same component", () => { + const html = + "
" + + '' + + '' + + "
"; + const nodeA = findNode(html, "a"); + const result = stripComponentAnnotations(html, nodeA.source); + + expect(result.changed).toBe(true); + // Sibling "b" keeps its annotation and its own prop value untouched. + expect(result.content).toContain( + 'data-agent-native-node-id="b" data-agent-native-component="Chip" data-agent-native-prop-tone="danger"', + ); + // "a" lost its annotation entirely. + const aTagMatch = /'; + expect(findOpenTagEnd(markup)).toBe(''; + const result = setAttributeOnMarkup( + markup, + "data-agent-native-prop-variant", + "outline", + ); + expect(result).toBe( + '', + ); + }); + + it("inserts a missing attribute before the closing >", () => { + const markup = ""; + const result = setAttributeOnMarkup( + markup, + "data-agent-native-prop-variant", + "outline", + ); + expect(result).toBe( + '', + ); + }); + + it("only touches the opening tag, not text content that looks like an attribute", () => { + const markup = 'data-agent-native-prop-variant="ignored"'; + const result = setAttributeOnMarkup( + markup, + "data-agent-native-node-id", + "n1", + ); + expect(result).toBe( + 'data-agent-native-prop-variant="ignored"', + ); + }); +}); + +// --------------------------------------------------------------------------- +// mergeComponentSwapOverrides +// --------------------------------------------------------------------------- + +describe("mergeComponentSwapOverrides", () => { + it("carries over overrides for prop names both components share", () => { + const targetMarkup = + '"; + + const currentProps = [ + { name: "variant", value: "solid" }, + { name: "tone", value: "danger" }, // not declared by the target + ]; + const targetDefaultProps = [ + { name: "variant", value: "outline" }, + { name: "size", value: "md" }, + ]; + + const result = mergeComponentSwapOverrides( + targetMarkup, + currentProps, + targetDefaultProps, + "btn1", + ); + + // "variant" was overridden onto the new markup. + expect(result.markup).toContain('data-agent-native-prop-variant="solid"'); + // "size" was not overridden by the caller — keeps the target's default. + expect(result.markup).toContain('data-agent-native-prop-size="md"'); + // The selected instance's stable node id is stamped onto the result. + expect(result.markup).toContain('data-agent-native-node-id="btn1"'); + // The target's own x-data is left untouched (not merged). + expect(result.markup).toContain( + "x-data=\"{ variant: 'outline', size: 'md' }\"", + ); + + expect(result.overriddenProps).toEqual(["variant"]); + expect(result.droppedProps).toEqual(["tone"]); + expect(result.defaultedProps).toEqual(["size"]); + }); + + it("reports every current prop as dropped when the target declares none of them", () => { + const targetMarkup = '
Body
'; + const currentProps = [{ name: "tone", value: "info" }]; + const result = mergeComponentSwapOverrides( + targetMarkup, + currentProps, + [], + "card1", + ); + expect(result.overriddenProps).toEqual([]); + expect(result.droppedProps).toEqual(["tone"]); + expect(result.markup).not.toContain("data-agent-native-prop-tone"); + expect(result.markup).toContain('data-agent-native-node-id="card1"'); + }); + + it("re-keys copied descendants while preserving the selected root id", () => { + const targetMarkup = + '
' + + '

Title

' + + '
Label
' + + "
"; + + let sequence = 0; + const rekeyed = reassignCopiedDescendantNodeIds( + targetMarkup, + () => `fresh-${++sequence}`, + ); + expect(rekeyed).toContain('data-agent-native-node-id="source-root"'); + expect(rekeyed).toContain('data-agent-native-node-id="fresh-1"'); + expect(rekeyed).toContain('data-agent-native-node-id="fresh-2"'); + expect(rekeyed).toContain('data-agent-native-node-id="fresh-3"'); + expect(rekeyed).not.toContain("source-title"); + expect(rekeyed).not.toContain("source-label"); + + const result = mergeComponentSwapOverrides(rekeyed, [], [], "selected"); + expect(result.markup).toContain('data-agent-native-node-id="selected"'); + expect(result.markup).not.toContain( + 'data-agent-native-node-id="source-root"', + ); + }); +}); diff --git a/templates/design/actions/swap-component-instance.ts b/templates/design/actions/swap-component-instance.ts new file mode 100644 index 0000000000..b6eb3f52fb --- /dev/null +++ b/templates/design/actions/swap-component-instance.ts @@ -0,0 +1,544 @@ +/** + * swap-component-instance — Figma's "Swap instance". + * + * DESIGN NOTE — mapping onto this codebase's data model: there is no + * separate component "definition"/template anywhere here (see + * `shared/component-model.ts`) — every instance is an independently + * duplicated copy of HTML matched purely by its + * `data-agent-native-component="Name"` annotation. "Swap to component B" + * therefore means: find an existing instance of B somewhere in the design + * (any screen), copy ITS current markup in as a replacement for the selected + * instance of A, and carry over whichever `data-agent-native-prop-*` + * overrides the selected instance had that B's own instances also declare + * (Figma's "preserve same-named overrides"). + * + * Scope decisions, deliberately conservative: + * - Requires at least one existing instance of the target component + * somewhere in the design (any file) to copy markup from. There is no + * component library/definition to fall back to — this is a hard + * requirement, not a data-model gap to route around. + * - Only `data-agent-native-prop-*` attribute overrides are carried over. + * The `x-data` Alpine expression is intentionally NOT merged: Alpine state + * can hold arbitrary JS (methods, nested objects, expressions) and this + * codebase already treats blind x-data rewrites as unsafe (see + * `component-section.tsx`'s `canRebuildAlpineDataLosslessly` bail-out) — the + * swapped-in instance keeps ITS OWN component's default `x-data` as-is. + * Prop names present on the old instance but not declared by the new + * component are dropped (`droppedProps`); prop names the new component + * declares that the old instance didn't override keep the new component's + * default (`defaultedProps`). Both are reported so the caller/UI can tell + * the user what changed. + * - The selected instance's own `data-agent-native-node-id` is preserved on + * the swapped-in markup (rather than keeping whatever id the copied + * instance had) so selection, undo, and any other node-id-keyed state keep + * addressing the same slot on the canvas. + * + * Persists through the same deterministic HTML-patch + collab seam as + * `apply-component-prop-edit` (`writeInlineSourceFile` + `expectedVersionHash` + * CAS guard) — same undo/collab posture as every other HTML-mutating action. + * + * Inline/Alpine designs only; real-app sources fail closed. + */ + +import { randomUUID } from "node:crypto"; + +import { defineAction } from "@agent-native/core/action"; +import { + agentEnterDocument, + agentLeaveDocument, + agentUpdateSelection, +} from "@agent-native/core/collab"; +import { + accessFilter, + assertAccess, + resolveAccess, +} from "@agent-native/core/sharing"; +import { and, eq } from "drizzle-orm"; +import { z } from "zod"; + +import { getDb, schema } from "../server/db/index.js"; +import "../server/db/index.js"; // ensure registerShareableResource runs +import { + writeInlineSourceFile, + type SourceWorkspaceFile, +} from "../server/source-workspace.js"; +import { + buildCodeLayerProjection, + type CodeLayerNode, +} from "../shared/code-layer.js"; +import type { CodeLayerSource } from "../shared/code-layer.js"; +import { agentSelectionDescriptor } from "../shared/collab-selection.js"; +import { + componentNameFor, + componentNodeIdMatches, + extractProps, + propNameToDataAttribute, +} from "../shared/component-model.js"; +import { designSourceTypeFromData } from "../shared/source-mode.js"; +import { sourceContentHash } from "../shared/source-workspace.js"; +import { applyRootAttributeEdit } from "./apply-component-prop-edit.js"; + +// ─── Pure markup helpers ──────────────────────────────────────────────────── + +/** + * Find the end offset (exclusive) of the opening tag at the start of + * `markup`, respecting quoted attribute values that may themselves contain + * `>`. Returns `markup.length` if no unquoted `>` is found. Pure. + */ +export function findOpenTagEnd(markup: string): number { + let quote: '"' | "'" | null = null; + for (let i = 0; i < markup.length; i++) { + const ch = markup[i]; + if (quote) { + if (ch === quote) quote = null; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + continue; + } + if (ch === ">") return i + 1; + } + return markup.length; +} + +/** + * Set (or replace) a single attribute on a standalone markup string's opening + * tag — the same splice `applyRootAttributeEdit` performs against a full + * document, but for an already-extracted outerHTML fragment (e.g. copied from + * another instance elsewhere in the design). Pure — exported for tests. + */ +export function setAttributeOnMarkup( + markup: string, + attrName: string, + attrValue: string, +): string { + const openEnd = findOpenTagEnd(markup); + const result = applyRootAttributeEdit( + markup, + { openStart: 0, openEnd }, + attrName, + attrValue, + ); + return result.content; +} + +export interface SwapOverrideResult { + markup: string; + overriddenProps: string[]; + droppedProps: string[]; + defaultedProps: string[]; +} + +/** + * Re-key every descendant in markup copied from another component instance. + * Keeping the copied `data-agent-native-node-id` values would create duplicate + * stable layer identities, so selection and the aggregate layer-owner map + * could jump back to the source instance after a swap. The root keeps the + * selected instance's id later in `mergeComponentSwapOverrides`; descendants + * receive fresh ids here. + * + * `createNodeId` is injectable so the pure behavior stays deterministic in + * tests while production uses cryptographically unique ids. + */ +export function reassignCopiedDescendantNodeIds( + markup: string, + createNodeId: () => string = () => `an-${randomUUID()}`, +): string { + const projection = buildCodeLayerProjection(markup); + const rootIds = new Set(projection.rootNodeIds); + const descendants = projection.nodes + .filter((node) => !rootIds.has(node.id) && node.source) + .sort( + (a, b) => + (b.source?.openStart ?? Number.NEGATIVE_INFINITY) - + (a.source?.openStart ?? Number.NEGATIVE_INFINITY), + ); + + let content = markup; + for (const descendant of descendants) { + content = applyRootAttributeEdit( + content, + descendant.source, + "data-agent-native-node-id", + createNodeId(), + ).content; + } + return content; +} + +/** + * Apply the selected instance's `data-agent-native-prop-*` overrides onto a + * copy of the target component's markup, carrying over only prop names the + * target component ALSO declares, then stamp the selected instance's stable + * node id onto the result. Pure — exported for tests. + */ +export function mergeComponentSwapOverrides( + targetMarkup: string, + currentProps: Array<{ name: string; value: string }>, + targetDefaultProps: Array<{ name: string; value: string }>, + nodeId: string, +): SwapOverrideResult { + const targetNames = new Set(targetDefaultProps.map((p) => p.name)); + const currentNames = new Set(currentProps.map((p) => p.name)); + + const overriddenProps: string[] = []; + const droppedProps: string[] = []; + const defaultedProps: string[] = []; + + let markup = reassignCopiedDescendantNodeIds(targetMarkup); + + for (const prop of currentProps) { + if (targetNames.has(prop.name)) { + markup = setAttributeOnMarkup( + markup, + propNameToDataAttribute(prop.name), + prop.value, + ); + overriddenProps.push(prop.name); + } else { + droppedProps.push(prop.name); + } + } + + for (const prop of targetDefaultProps) { + if (!currentNames.has(prop.name)) defaultedProps.push(prop.name); + } + + markup = setAttributeOnMarkup(markup, "data-agent-native-node-id", nodeId); + + return { markup, overriddenProps, droppedProps, defaultedProps }; +} + +// ─── Persistence ──────────────────────────────────────────────────────────── + +async function persistEdit(file: { + id: string; + designId: string; + filename: string; + content: string; + expectedVersionHash: string; +}): Promise { + await assertAccess("design", file.designId, "editor"); + + agentEnterDocument(file.id); + try { + const workspaceFile: SourceWorkspaceFile = { + id: file.id, + designId: file.designId, + filename: file.filename, + fileType: "html", + content: file.content, + createdAt: null, + updatedAt: null, + }; + + const result = await writeInlineSourceFile({ + designId: file.designId, + file: workspaceFile, + content: file.content, + expectedVersionHash: file.expectedVersionHash, + }); + + return result.updatedAt; + } finally { + agentLeaveDocument(file.id); + } +} + +// ─── Action ─────────────────────────────────────────────────────────────────── + +export default defineAction({ + description: + "Swap a component instance for a different component from elsewhere in " + + "the design (Figma's Swap instance). Replaces the selected instance's " + + "markup with a copy of another existing instance of targetComponentName, " + + "carrying over data-agent-native-prop-* overrides whose prop name exists " + + "on both components. x-data (Alpine state) is not merged — the swapped-in " + + "instance keeps the target component's own x-data. Requires at least one " + + "existing instance of targetComponentName somewhere in the design. " + + "Inline/Alpine designs only.", + schema: z.object({ + designId: z.string().describe("Design project ID"), + nodeId: z + .string() + .describe("data-agent-native-node-id of the component instance to swap"), + fileId: z + .string() + .optional() + .describe( + "Design file id the instance currently lives in; defaults to index.html", + ), + targetComponentName: z + .string() + .min(1) + .describe( + "Name of the component to swap in, from list-design-components", + ), + source: z + .object({ + currentContent: z + .string() + .optional() + .describe("Latest editor HTML snapshot, when available."), + revision: z + .string() + .optional() + .describe( + "design_files.updatedAt value the currentContent is based on.", + ), + }) + .optional(), + }), + run: async ({ designId, nodeId, fileId, targetComponentName, source }) => { + const access = await resolveAccess("design", designId); + if (!access) throw new Error("Design not found"); + + const rawData = (access.resource as { data?: unknown }).data; + const sourceType = designSourceTypeFromData(rawData); + + if (sourceType !== "inline") { + return { + designId, + nodeId, + sourceType, + swapped: false, + ctaRequired: true, + ctaMessage: + "Swap instance requires a dedicated consented, version-guarded " + + "compiled-source transform for real-app sources. Not yet available.", + }; + } + + await assertAccess("design", designId, "editor"); + const db = getDb(); + + const conditions = [ + accessFilter(schema.designs, schema.designShares), + eq(schema.designFiles.designId, designId), + fileId + ? eq(schema.designFiles.id, fileId) + : eq(schema.designFiles.filename, "index.html"), + ]; + + const [file] = await db + .select({ + id: schema.designFiles.id, + designId: schema.designFiles.designId, + filename: schema.designFiles.filename, + content: schema.designFiles.content, + updatedAt: schema.designFiles.updatedAt, + }) + .from(schema.designFiles) + .innerJoin( + schema.designs, + eq(schema.designFiles.designId, schema.designs.id), + ) + .where(and(...conditions)) + .limit(1); + + if (!file) throw new Error("Design HTML file not found."); + + if ( + source?.currentContent && + source.revision && + file.updatedAt && + source.revision !== file.updatedAt + ) { + return { + designId, + nodeId, + swapped: false, + conflict: true, + fileId: file.id, + filename: file.filename, + error: + "This file changed since this swap was prepared. Refresh the editor and try again.", + }; + } + + const html = + typeof source?.currentContent === "string" + ? source.currentContent + : (file.content ?? ""); + const baseVersionHash = sourceContentHash(html); + + const codeLayerSource: CodeLayerSource = { + kind: "design-file", + designId: file.designId, + fileId: file.id, + filename: file.filename, + }; + + const projection = buildCodeLayerProjection(html, { + source: codeLayerSource, + }); + + const node = projection.nodes.find((n) => + componentNodeIdMatches(n, nodeId), + ); + if (!node) { + throw new Error( + `Node "${nodeId}" not found. Run get-code-layer-projection to list current ids.`, + ); + } + + const componentName = componentNameFor(node); + if (!componentName) { + throw new Error( + `Node "${nodeId}" is not a component root (no data-agent-native-component attribute) — nothing to swap.`, + ); + } + + if (componentName === targetComponentName) { + return { + designId, + nodeId, + componentName, + swapped: false, + note: `Already an instance of "${targetComponentName}".`, + }; + } + + if (!node.source) { + throw new Error( + `Node "${nodeId}" has no resolvable source span; cannot swap.`, + ); + } + + // ── Find a markup source for the target component ────────────────────── + const allFiles = await db + .select({ + id: schema.designFiles.id, + designId: schema.designFiles.designId, + filename: schema.designFiles.filename, + content: schema.designFiles.content, + }) + .from(schema.designFiles) + .innerJoin( + schema.designs, + eq(schema.designFiles.designId, schema.designs.id), + ) + .where( + and( + accessFilter(schema.designs, schema.designShares), + eq(schema.designFiles.designId, designId), + eq(schema.designFiles.fileType, "html"), + ), + ) + .orderBy(schema.designFiles.createdAt); + + let targetNode: CodeLayerNode | undefined; + let targetHtml = ""; + + for (const row of allFiles) { + const rowHtml = row.id === file.id ? html : (row.content ?? ""); + if (!rowHtml) continue; + const rowProjection = + row.id === file.id + ? projection + : buildCodeLayerProjection(rowHtml, { + source: { + kind: "design-file", + designId: row.designId, + fileId: row.id, + filename: row.filename, + }, + }); + + const match = rowProjection.nodes.find((n) => { + if (row.id === file.id && n.id === node.id) return false; + return componentNameFor(n) === targetComponentName; + }); + + if (match) { + targetNode = match; + targetHtml = rowHtml; + break; + } + } + + if (!targetNode) { + throw new Error( + `No existing instance of component "${targetComponentName}" found ` + + "anywhere in this design. Swap needs at least one existing " + + "instance to copy markup from — run list-design-components to see " + + "what's available.", + ); + } + if (!targetNode.source) { + throw new Error( + `The matched "${targetComponentName}" instance has no resolvable source span; cannot swap.`, + ); + } + + const targetMarkup = targetHtml.slice( + targetNode.source.start, + targetNode.source.end, + ); + + const currentProps = extractProps(node); + const targetDefaultProps = extractProps(targetNode); + + const { + markup: mergedMarkup, + overriddenProps, + droppedProps, + defaultedProps, + } = mergeComponentSwapOverrides( + targetMarkup, + currentProps, + targetDefaultProps, + nodeId, + ); + + const patchedContent = + html.slice(0, node.source.start) + + mergedMarkup + + html.slice(node.source.end); + + if (patchedContent === html) { + return { + designId, + nodeId, + componentName, + swapped: false, + note: "Swap produced no change.", + }; + } + + const updatedAt = await persistEdit({ + id: file.id, + designId: file.designId, + filename: file.filename, + content: patchedContent, + expectedVersionHash: baseVersionHash, + }); + + agentUpdateSelection(file.id, { + selection: agentSelectionDescriptor( + { nodeId, selector: node.selector }, + "Swapping instance", + ), + nodeId, + editingFile: file.filename, + designId: file.designId, + }); + + return { + designId, + nodeId, + fromComponent: componentName, + toComponent: targetComponentName, + swapped: true, + overriddenProps, + droppedProps, + defaultedProps, + fileId: file.id, + filename: file.filename, + updatedAt, + content: patchedContent, + note: `Swapped "${componentName}" for "${targetComponentName}".`, + }; + }, +}); diff --git a/templates/design/actions/take-design-screenshot.spec.ts b/templates/design/actions/take-design-screenshot.spec.ts index ef7b498b10..06bb2afbe6 100644 --- a/templates/design/actions/take-design-screenshot.spec.ts +++ b/templates/design/actions/take-design-screenshot.spec.ts @@ -56,6 +56,19 @@ describe("resolveViewports", () => { expect(vp.heightPx).toBeGreaterThan(0); } }); + + it("uses an explicit `heights` entry instead of the device heuristic when provided", () => { + const viewports = resolveViewports([960], [543]); + expect(viewports).toEqual([ + { label: "desktop-960", widthPx: 960, heightPx: 543 }, + ]); + }); + + it("falls back to the device heuristic for indices missing from `heights`", () => { + const viewports = resolveViewports([1280, 375], [900]); + expect(viewports[0]).toMatchObject({ widthPx: 1280, heightPx: 900 }); + expect(viewports[1]).toMatchObject({ widthPx: 375, heightPx: 812 }); + }); }); // --------------------------------------------------------------------------- diff --git a/templates/design/actions/take-design-screenshot.ts b/templates/design/actions/take-design-screenshot.ts index 0050476ef6..399329c7e8 100644 --- a/templates/design/actions/take-design-screenshot.ts +++ b/templates/design/actions/take-design-screenshot.ts @@ -125,75 +125,50 @@ function labelForWidth(widthPx: number): string { return `desktop-${widthPx}`; } -/** Resolve the viewport list from the optional `widths` input, defaulting to desktop + mobile. */ -export function resolveViewports(widths?: number[]): ScreenshotViewport[] { +/** + * Resolve the viewport list from the optional `widths` input, defaulting to + * desktop + mobile. `heights`, when provided, is matched index-for-index + * against `widths` so a caller that already knows the exact content height + * (e.g. the annotate-to-agent draw pipeline compositing over a specific + * on-screen rect) gets a screenshot with the same aspect ratio instead of the + * device-heuristic default — annotation coordinates are recorded in that + * exact rect's pixel space, so a mismatched aspect ratio would misalign the + * composited drawing against the screenshot content. A missing/undefined + * entry at a given index falls back to `heightForWidth` unchanged, so + * existing callers that only pass `widths` are unaffected. + */ +export function resolveViewports( + widths?: number[], + heights?: number[], +): ScreenshotViewport[] { if (!widths || widths.length === 0) return DEFAULT_VIEWPORTS; - return widths.map((widthPx) => ({ + return widths.map((widthPx, index) => ({ label: labelForWidth(widthPx), widthPx, - heightPx: heightForWidth(widthPx), + heightPx: heights?.[index] || heightForWidth(widthPx), })); } // --------------------------------------------------------------------------- // Playwright loading (mirrors packages/core/src/cli/recap.ts's runShot: a // dynamic import + system-Chrome fallback, so a missing browser binary is a -// clean, catchable failure rather than an unhandled module-resolution crash) +// clean, catchable failure rather than an unhandled module-resolution crash). +// The actual bootstrap lives in `playwright-runtime.ts` so other server-side +// Chromium consumers (e.g. the Figma SVG export's scene extractor in +// `design-to-figma-svg.ts`) share it instead of duplicating it; re-exported +// here for backward compatibility with this file's existing imports/spec. // --------------------------------------------------------------------------- -type PlaywrightModule = { chromium: import("@playwright/test").BrowserType }; - -async function importPlaywright(): Promise { - try { - // Bare "playwright" — present when @agent-native/core's optional - // dependency resolved (matches how packages/core/src/cli/recap.ts loads - // it). Loaded via a non-literal specifier so bundlers don't try to - // statically resolve/include it (it's optional and can be entirely - // absent, e.g. in a hosted deploy). - const specifier = "playwright"; - return (await import(specifier)) as unknown as PlaywrightModule; - } catch { - // "@playwright/test" is a direct devDependency of this template (used by - // its own e2e suite) and re-exports the same chromium/Browser API. - return (await import("@playwright/test")) as unknown as PlaywrightModule; - } -} - -const SYSTEM_CHROME_EXECUTABLES = [ - "/usr/bin/google-chrome-stable", - "/usr/bin/google-chrome", - "/usr/bin/chromium-browser", - "/usr/bin/chromium", -]; - -/** Exported for tests — pure classifier for "no Chromium binary available" errors. */ -export function isMissingBrowserError(err: unknown): boolean { - const message = err instanceof Error ? err.message : String(err); - return /Executable doesn't exist|playwright install|browser.*not found|chromium.*not found/i.test( - message, - ); -} - -async function launchChromium( - chromium: import("@playwright/test").BrowserType, -): Promise { - const launchOptions = { args: ["--no-sandbox"] }; - try { - return await chromium.launch(launchOptions); - } catch (err) { - if (!isMissingBrowserError(err)) throw err; - const { existsSync } = await import("node:fs"); - for (const executablePath of SYSTEM_CHROME_EXECUTABLES) { - if (!existsSync(executablePath)) continue; - try { - return await chromium.launch({ ...launchOptions, executablePath }); - } catch { - // Try the next candidate; the original error is rethrown below. - } - } - throw err; - } -} +export { + importPlaywright, + isMissingBrowserError, + launchChromium, +} from "../server/lib/playwright-runtime.js"; +import { + importPlaywright, + launchChromium, + type PlaywrightModule, +} from "../server/lib/playwright-runtime.js"; /** Human-readable, model-actionable message for the "no Chromium available" case. */ export function chromiumUnavailableReason(err: unknown): string { @@ -510,10 +485,20 @@ export default defineAction({ "Viewport widths in px to render. Defaults to [1280, 375] (desktop + mobile). " + "Height is derived per width using standard device aspect ratios.", ), + heights: z + .array(z.number().int().min(200).max(4096)) + .optional() + .describe( + "Exact viewport heights in px, matched index-for-index against `widths`. " + + "Use when the caller needs the screenshot's aspect ratio to match a " + + "known on-screen rect exactly (e.g. compositing an overlay on top) " + + "instead of the device-heuristic default. Omit an index to fall back " + + "to the standard derived height for that width.", + ), }), readOnly: true, http: { method: "POST" }, - run: async ({ designId, fileId, filename, widths }, ctx) => { + run: async ({ designId, fileId, filename, widths, heights }, ctx) => { if (!designId && !fileId) { throw new Error("designId or fileId is required."); } @@ -569,7 +554,7 @@ export default defineAction({ return { ok: false, reason: chromiumUnavailableReason(err) }; } - const viewports = resolveViewports(widths); + const viewports = resolveViewports(widths, heights); let browser: import("@playwright/test").Browser | undefined; try { browser = await launchChromium(playwright.chromium); diff --git a/templates/design/actions/write-local-file.spec.ts b/templates/design/actions/write-local-file.spec.ts index 2d8d8cd208..380c954de0 100644 --- a/templates/design/actions/write-local-file.spec.ts +++ b/templates/design/actions/write-local-file.spec.ts @@ -137,6 +137,50 @@ describe("write-local-file", () => { expect(body.expectedVersionHash).toBe("123-456"); }); + it("enforces and forwards the exact-hash contract for semantic source edits", async () => { + await expect( + action.run({ + designId: "design_1", + connectionId: "conn_1", + relPath: "src/App.tsx", + patch: { search: "old", replace: "new" }, + requireExpectedVersionHash: true, + }), + ).rejects.toThrow(/expectedVersionHash is required/); + expect(fetch).not.toHaveBeenCalled(); + + const exactHash = "a".repeat(64); + await action.run({ + designId: "design_1", + connectionId: "conn_1", + relPath: "src/App.tsx", + patch: { search: "old", replace: "new" }, + expectedVersionHash: exactHash, + requireExpectedVersionHash: true, + }); + + const call = (fetch as unknown as ReturnType).mock + .calls[0] as [string, RequestInit]; + expect(JSON.parse(call[1].body as string)).toMatchObject({ + expectedVersionHash: exactHash, + requireExpectedVersionHash: true, + }); + }); + + it("rejects legacy stat hashes for the exact-hash contract", async () => { + await expect( + action.run({ + designId: "design_1", + connectionId: "conn_1", + relPath: "src/App.tsx", + patch: { search: "old", replace: "new" }, + expectedVersionHash: "123-456", + requireExpectedVersionHash: true, + }), + ).rejects.toThrow(/SHA-256 expectedVersionHash/); + expect(fetch).not.toHaveBeenCalled(); + }); + it("throws a version-conflict error on a 409 from the bridge", async () => { vi.stubGlobal( "fetch", diff --git a/templates/design/actions/write-local-file.ts b/templates/design/actions/write-local-file.ts index cfd1d03ae4..a307e93e8e 100644 --- a/templates/design/actions/write-local-file.ts +++ b/templates/design/actions/write-local-file.ts @@ -60,6 +60,7 @@ const ALLOWED_EXTENSIONS = new Set([ ".yaml", ".svg", ]); +const SHA256_VERSION_HASH = /^[a-f0-9]{64}$/i; /** * Secret-looking paths are never writable, regardless of extension. All @@ -207,6 +208,14 @@ export default defineAction({ "version-conflict error if the file changed on disk since that " + "hash was read.", ), + requireExpectedVersionHash: z + .boolean() + .optional() + .describe( + "Set true for semantic/compiled-source edits. The bridge rejects the " + + "write unless expectedVersionHash is present and still exact. Leave " + + "false only for legacy writes or deliberate new-file creation.", + ), }), run: async ({ designId, @@ -215,6 +224,7 @@ export default defineAction({ content, patch, expectedVersionHash, + requireExpectedVersionHash, }) => { // --- Gate 1: access --- await assertAccess("design", designId, "editor"); @@ -246,6 +256,14 @@ export default defineAction({ "content and patch are mutually exclusive. Provide one or the other.", ); } + if ( + requireExpectedVersionHash && + (!expectedVersionHash || !SHA256_VERSION_HASH.test(expectedVersionHash)) + ) { + throw new Error( + "A SHA-256 expectedVersionHash is required when requireExpectedVersionHash is true. Re-read the file through the current local Design bridge before retrying.", + ); + } // --- Resolve bridge URL + current token --- const db = getDb(); @@ -289,7 +307,12 @@ export default defineAction({ const res = await fetch(`${bridgeUrl}/write-file`, { method: "POST", headers, - body: JSON.stringify({ relPath, content, expectedVersionHash }), + body: JSON.stringify({ + relPath, + content, + expectedVersionHash, + requireExpectedVersionHash, + }), }); if (!res.ok) { if (res.status === 409) { @@ -321,6 +344,7 @@ export default defineAction({ search: patch!.search, replace: patch!.replace, expectedVersionHash, + requireExpectedVersionHash, }), }); if (!applyRes.ok) { diff --git a/templates/design/app/components/design/CanvasContextMenu.test.tsx b/templates/design/app/components/design/CanvasContextMenu.test.tsx index 1f45cbddcc..70eaf1826d 100644 --- a/templates/design/app/components/design/CanvasContextMenu.test.tsx +++ b/templates/design/app/components/design/CanvasContextMenu.test.tsx @@ -111,3 +111,120 @@ describe("CanvasContextMenu Copy as PNG", () => { await view.cleanup(); }); }); + +describe("CanvasContextMenu Select layer", () => { + it("renders the ordered hit stack and selects the exact candidate", async () => { + const onSelectLayer = vi.fn(); + const candidates = [ + { + key: "front", + label: "Front card", + info: { + tagName: "div", + sourceId: "front", + selector: '[data-agent-native-node-id="front"]', + classes: [], + computedStyles: {}, + boundingRect: { x: 0, y: 0, width: 100, height: 100 }, + isFlexChild: false, + isFlexContainer: false, + }, + }, + { + key: "parent", + label: "Parent frame", + info: { + tagName: "section", + sourceId: "parent", + selector: '[data-agent-native-node-id="parent"]', + classes: [], + computedStyles: {}, + boundingRect: { x: 0, y: 0, width: 200, height: 200 }, + isFlexChild: false, + isFlexContainer: false, + }, + }, + ]; + const view = await renderContextMenu({ + selectedCount: 1, + layerCandidates: candidates, + onSelectLayer, + }); + + expect(view.container.textContent).toContain("Select layer"); + const buttons = Array.from(view.container.querySelectorAll("button")); + const front = buttons.find((button) => + button.textContent?.includes("Front card"), + ); + const parent = buttons.find((button) => + button.textContent?.includes("Parent frame"), + ); + expect(front).toBeDefined(); + expect(parent).toBeDefined(); + expect(buttons.indexOf(front!)).toBeLessThan(buttons.indexOf(parent!)); + + await act(async () => parent?.click()); + expect(onSelectLayer).toHaveBeenCalledWith(candidates[1]); + await view.cleanup(); + }); +}); + +describe("CanvasContextMenu instance cluster (Go to main / Swap / Detach)", () => { + it("renders nothing for a non-instance selection (backward compatible default)", async () => { + const view = await renderContextMenu({ + selectedCount: 1, + canCreateComponent: true, + }); + + expect(view.findButton("Go to main component")).toBeUndefined(); + expect(view.findButton("Swap instance")).toBeUndefined(); + expect(view.findButton("Detach instance")).toBeUndefined(); + await view.cleanup(); + }); + + it("renders and wires all three items when isComponentInstance is true", async () => { + const onGoToMainComponent = vi.fn(); + const onSwapInstance = vi.fn(); + const onDetachInstance = vi.fn(); + const view = await renderContextMenu({ + selectedCount: 1, + isComponentInstance: true, + onGoToMainComponent, + onSwapInstance, + onDetachInstance, + }); + + const detachButton = view.findButton("Detach instance"); + expect(detachButton).toBeDefined(); + expect(detachButton?.disabled).toBe(false); + await act(async () => detachButton?.click()); + expect(onDetachInstance).toHaveBeenCalledTimes(1); + expect(onDetachInstance).toHaveBeenCalledWith( + expect.objectContaining({ action: "detach-instance", selectedCount: 1 }), + ); + + const swapButton = view.findButton("Swap instance"); + await act(async () => swapButton?.click()); + expect(onSwapInstance).toHaveBeenCalledTimes(1); + + const mainButton = view.findButton("Go to main component"); + await act(async () => mainButton?.click()); + expect(onGoToMainComponent).toHaveBeenCalledTimes(1); + + await view.cleanup(); + }); + + it("disables items whose capability flag is explicitly false", async () => { + const onDetachInstance = vi.fn(); + const view = await renderContextMenu({ + selectedCount: 1, + isComponentInstance: true, + canDetachInstance: false, + onDetachInstance, + }); + + const detachButton = view.findButton("Detach instance"); + expect(detachButton?.disabled).toBe(true); + await view.cleanup(); + }); +}); diff --git a/templates/design/app/components/design/CanvasContextMenu.tsx b/templates/design/app/components/design/CanvasContextMenu.tsx index 1e45a523ae..39499dc5ad 100644 --- a/templates/design/app/components/design/CanvasContextMenu.tsx +++ b/templates/design/app/components/design/CanvasContextMenu.tsx @@ -1,3 +1,10 @@ +import { + IconComponents, + IconFrame, + IconPhoto, + IconTypography, + IconVector, +} from "@tabler/icons-react"; import { forwardRef, useCallback, @@ -22,6 +29,8 @@ import { } from "@/components/ui/context-menu"; import { cn } from "@/lib/utils"; +import type { CanvasLayerHitCandidate } from "./types"; + // LIVE-VERIFIED (real Figma, UI3) canvas context menus: // // WITH a selection: @@ -51,6 +60,15 @@ import { cn } from "@/lib/utils"; // back-compat. App-specific extras with no Figma equivalent (e.g. "Edit // screen") are appended at the very bottom, below one more separator, so the // Figma-muscle-memory zone above stays byte-identical to the real menu. +// +// NOTE — instance-only cluster (Go to main component / Swap instance / +// Detach instance): added for component-instance selections, gated behind +// `isComponentInstance` so it renders nothing for existing callers (fully +// backward compatible). Real Figma groups these together for an instance +// selection, but this exact placement (right after Add auto layout / Create +// component) was NOT independently re-verified against a live Figma session +// in this pass — reposition if a future LIVE-VERIFIED sweep finds a +// different spot. export type CanvasContextMenuAction = | "paste-here" | "select-all" @@ -73,6 +91,9 @@ export type CanvasContextMenuAction = | "frame-selection" | "add-auto-layout" | "create-component" + | "go-to-main-component" + | "swap-instance" + | "detach-instance" | "rename" | "toggle-lock" | "toggle-hide" @@ -112,6 +133,7 @@ export type CanvasContextMenuActionHandler = ( ) => void; export interface CanvasContextMenuLabels { + selectLayer: string; pasteHere: string; selectAll: string; zoomToFit: string; @@ -134,6 +156,9 @@ export interface CanvasContextMenuLabels { frameSelection: string; addAutoLayout: string; createComponent: string; + goToMainComponent: string; + swapInstance: string; + detachInstance: string; rename: string; lock: string; unlock: string; @@ -177,6 +202,9 @@ export interface CanvasContextMenuShortcuts { frameSelection: string; addAutoLayout: string; createComponent: string; + goToMainComponent: string; + swapInstance: string; + detachInstance: string; rename: string; toggleLock: string; toggleHide: string; @@ -199,6 +227,8 @@ export interface CanvasContextMenuProps { className?: string; contentClassName?: string; selectedCount?: number; + layerCandidates?: readonly CanvasLayerHitCandidate[]; + onSelectLayer?: (candidate: CanvasLayerHitCandidate) => void; hasClipboard?: boolean; hasPropsClipboard?: boolean; hasAnimationClipboard?: boolean; @@ -228,6 +258,14 @@ export interface CanvasContextMenuProps { canFrameSelection?: boolean; canAddAutoLayout?: boolean; canCreateComponent?: boolean; + // Whether the current selection IS a component instance — gates the + // whole Go to main component / Swap instance / Detach instance cluster on + // (rather than showing them permanently disabled for non-instance + // selections, since real Figma doesn't show this cluster at all then). + isComponentInstance?: boolean; + canGoToMainComponent?: boolean; + canSwapInstance?: boolean; + canDetachInstance?: boolean; // L12: this menu is target-agnostic — it has no built-in notion of "design // title" vs "layer". Rename is enabled by default for a single selection // (see the canRename default below) and fires through the onRename @@ -288,6 +326,9 @@ export interface CanvasContextMenuProps { onFrameSelection?: CanvasContextMenuActionHandler; onAddAutoLayout?: CanvasContextMenuActionHandler; onCreateComponent?: CanvasContextMenuActionHandler; + onGoToMainComponent?: CanvasContextMenuActionHandler; + onSwapInstance?: CanvasContextMenuActionHandler; + onDetachInstance?: CanvasContextMenuActionHandler; // L12: fired when the Rename item is selected (details.selectedCount tells // the caller how many things are selected). The caller decides what // "rename" means for the current target — e.g. calling a LayersPanel @@ -315,6 +356,7 @@ export interface CanvasContextMenuProps { } const DEFAULT_LABELS: CanvasContextMenuLabels = { + selectLayer: "Select layer", pasteHere: "Paste here", selectAll: "Select all", zoomToFit: "Zoom to fit", @@ -337,6 +379,9 @@ const DEFAULT_LABELS: CanvasContextMenuLabels = { frameSelection: "Frame selection", addAutoLayout: "Add auto layout", createComponent: "Create component", + goToMainComponent: "Go to main component", + swapInstance: "Swap instance", + detachInstance: "Detach instance", rename: "Rename", lock: "Lock", unlock: "Unlock", @@ -380,6 +425,9 @@ const DEFAULT_SHORTCUTS: CanvasContextMenuShortcuts = { frameSelection: "⌥⌘G", addAutoLayout: "⇧A", createComponent: "⌥⌘K", + goToMainComponent: "", + swapInstance: "", + detachInstance: "⌥⌘B", rename: "⌘R", toggleLock: "⇧⌘L", toggleHide: "⇧⌘H", @@ -426,6 +474,8 @@ export const CanvasContextMenu = forwardRef< className, contentClassName, selectedCount = 0, + layerCandidates = [], + onSelectLayer, hasClipboard = false, hasPropsClipboard = false, hasAnimationClipboard = false, @@ -444,6 +494,10 @@ export const CanvasContextMenu = forwardRef< canFrameSelection = selectedCount > 0, canAddAutoLayout = selectedCount > 0, canCreateComponent = selectedCount > 0, + isComponentInstance = false, + canGoToMainComponent = isComponentInstance, + canSwapInstance = isComponentInstance, + canDetachInstance = isComponentInstance, canRename = selectedCount === 1, canToggleLocked = selectedCount > 0, canToggleHidden = selectedCount > 0, @@ -479,6 +533,9 @@ export const CanvasContextMenu = forwardRef< onFrameSelection, onAddAutoLayout, onCreateComponent, + onGoToMainComponent, + onSwapInstance, + onDetachInstance, onRename, onToggleLocked, onToggleHidden, @@ -559,6 +616,9 @@ export const CanvasContextMenu = forwardRef< "frame-selection": onFrameSelection, "add-auto-layout": onAddAutoLayout, "create-component": onCreateComponent, + "go-to-main-component": onGoToMainComponent, + "swap-instance": onSwapInstance, + "detach-instance": onDetachInstance, rename: onRename, "toggle-lock": onToggleLocked, "toggle-hide": onToggleHidden, @@ -585,9 +645,11 @@ export const CanvasContextMenu = forwardRef< onCopyAsSvg, onCopyProps, onCreateComponent, + onDetachInstance, onFlipHorizontal, onFlipVertical, onFrameSelection, + onGoToMainComponent, onGroup, onPaste, onPasteAnimation, @@ -598,6 +660,7 @@ export const CanvasContextMenu = forwardRef< onRename, onSendBackward, onSendToBack, + onSwapInstance, onToggleComments, onToggleHidden, onToggleLocked, @@ -674,6 +737,32 @@ export const CanvasContextMenu = forwardRef< className={cn(MENU_CONTENT_CLASS, contentClassName)} style={manualContentStyle} > + {layerCandidates.length > 0 && onSelectLayer ? ( + <> + + + + {labels.selectLayer} + + + {layerCandidates.map((candidate) => ( + { + onSelectLayer(candidate); + handleOpenChange(false); + }} + /> + ))} + + + + + + ) : null} {hasSelection ? ( <> {/* LIVE-VERIFIED Figma "with selection" canvas menu. */} @@ -839,6 +928,39 @@ export const CanvasContextMenu = forwardRef< /> + {isComponentInstance ? ( + <> + + + + + ) : null} + @@ -936,6 +1058,36 @@ export const CanvasContextMenu = forwardRef< ); }); +function CanvasLayerCandidateItem({ + candidate, + onSelect, +}: { + candidate: CanvasLayerHitCandidate; + onSelect: () => void; +}) { + const tag = candidate.info.tagName.toLowerCase(); + const Icon = candidate.info.componentName + ? IconComponents + : /^(h[1-6]|p|span|label|input|textarea)$/.test(tag) + ? IconTypography + : /^(img|picture|video)$/.test(tag) + ? IconPhoto + : /^(svg|path|circle|ellipse|polygon|line)$/.test(tag) + ? IconVector + : tag === "button" || tag === "a" + ? IconComponents + : IconFrame; + return ( + + + {candidate.label} + + ); +} + function CanvasMenuItem({ hidden, disabled, diff --git a/templates/design/app/components/design/DesignCanvas.bridge-restart.test.tsx b/templates/design/app/components/design/DesignCanvas.bridge-restart.test.tsx new file mode 100644 index 0000000000..e32bcc6210 --- /dev/null +++ b/templates/design/app/components/design/DesignCanvas.bridge-restart.test.tsx @@ -0,0 +1,379 @@ +// @vitest-environment happy-dom +// +// Behavioral coverage for the live-edit bridge auto-reconnect decision logic +// (see the classifyLiveEditHealthProbe doc comment in DesignCanvas.tsx). The +// authenticated live-edit iframe is a real cross-origin navigation, so this +// component can never read a 409 "unknown-bridge-key" response body directly +// — it instead watches for the missing agent-native:editor-chrome-ready +// handshake and probes /health to compare bridgeInstanceId. These tests drive +// that flow end-to-end through mocked fetch responses rather than importing +// the (intentionally unexported, see DesignCanvas.refreshBoundary.test.ts) +// pure decision function directly. + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { DesignCanvas } from "./DesignCanvas"; + +vi.mock("@agent-native/core/client", async (importOriginal) => { + const original = + await importOriginal(); + return { + ...original, + useT: () => (key: string) => key, + }; +}); + +const BRIDGE_URL = "http://127.0.0.1:7331"; +const PREVIEW_TOKEN = "preview-token"; +const PREVIEW_URL = "http://localhost:5173/forms"; + +function jsonResponse(body: unknown, status = 200) { + return Promise.resolve({ + ok: status >= 200 && status < 300, + status, + json: () => Promise.resolve(body), + } as Response); +} + +async function flushMicrotasks(times = 8) { + for (let i = 0; i < times; i += 1) { + await Promise.resolve(); + } +} + +describe("DesignCanvas live-edit bridge restart detection", () => { + let container: HTMLDivElement; + let root: ReturnType; + let fetchMock: ReturnType; + + beforeEach(() => { + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + vi.useFakeTimers(); + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + async function renderLiveEditCanvas() { + await act(async () => { + root.render( + {}} + onElementHover={() => {}} + tweakValues={{}} + />, + ); + }); + await act(async () => { + await flushMicrotasks(); + }); + } + + function healthCallCount() { + return fetchMock.mock.calls.filter(([input]) => + String(input).startsWith(`${BRIDGE_URL}/health`), + ).length; + } + + function registrationCallCount() { + return fetchMock.mock.calls.filter(([input]) => + String(input).startsWith(`${BRIDGE_URL}/live-edit-bridge`), + ).length; + } + + function postReadyHandshake(source?: Window) { + const iframeWindow = + source ?? container.querySelector("iframe")?.contentWindow; + if (!iframeWindow) { + throw new Error("expected a live-edit iframe window"); + } + window.dispatchEvent( + new MessageEvent("message", { + data: { type: "agent-native:editor-chrome-ready" }, + origin: BRIDGE_URL, + source: iframeWindow, + }), + ); + } + + it("silently re-registers and reloads the frame when /health reports a different bridgeInstanceId (bridge process restarted)", async () => { + fetchMock.mockImplementation((input: RequestInfo | URL) => { + const url = String(input); + if (url.startsWith(`${BRIDGE_URL}/live-edit-bridge`)) { + return jsonResponse({ ok: true, bridgeInstanceId: "instance-1" }); + } + if (url.startsWith(`${BRIDGE_URL}/health`)) { + return jsonResponse({ ok: true, bridgeInstanceId: "instance-2" }); + } + throw new Error(`unexpected fetch: ${url}`); + }); + + await renderLiveEditCanvas(); + + const registrationCallsBeforeTimeout = registrationCallCount(); + expect(registrationCallsBeforeTimeout).toBe(1); + + // No agent-native:editor-chrome-ready message ever arrives (simulating + // the bridge injecting nothing because it 409'd on the real navigation) — + // advance past the ready-handshake watchdog window. + await act(async () => { + await vi.advanceTimersByTimeAsync(4200); + await flushMicrotasks(); + }); + + const registrationCallsAfterTimeout = registrationCallCount(); + // A second registration POST fired automatically — the silent + // re-register/reload path — without ever surfacing an error. + expect(registrationCallsAfterTimeout).toBeGreaterThanOrEqual(2); + expect(container.textContent ?? "").not.toContain( + "Live editor connection failed", + ); + }); + + it("does NOT tear down the iframe or show an error when /health reports the SAME bridgeInstanceId at the first 4s timeout — it re-arms the watchdog instead (regression coverage)", async () => { + // /health always confirms the bridge process is the one we registered + // with — a slow-but-healthy dev server (e.g. a 6-10s cold compile), not a + // real failure. Before the fix under test, this used to be indistinguishable + // from a genuine unknown-bridge-key bug and immediately tore the iframe + // down (setRegisteredLiveEditBridgeKey(null)), flashing "Live editor + // connection failed" under a load that was still legitimately in flight. + fetchMock.mockImplementation((input: RequestInfo | URL) => { + const url = String(input); + if (url.startsWith(`${BRIDGE_URL}/live-edit-bridge`)) { + return jsonResponse({ ok: true, bridgeInstanceId: "instance-1" }); + } + if (url.startsWith(`${BRIDGE_URL}/health`)) { + return jsonResponse({ ok: true, bridgeInstanceId: "instance-1" }); + } + throw new Error(`unexpected fetch: ${url}`); + }); + + await renderLiveEditCanvas(); + expect(registrationCallCount()).toBe(1); + + await act(async () => { + await vi.advanceTimersByTimeAsync(4200); + await flushMicrotasks(); + }); + + // Exactly one /health probe fired so far, and NEITHER error card is + // shown. Crucially, no second registration POST fired either — a + // same-instance-id "escalate" outcome must never touch + // registeredLiveEditBridgeKey/reload the frame the way a genuine restart + // ("reregister") does. + expect(healthCallCount()).toBe(1); + expect(registrationCallCount()).toBe(1); + expect(container.textContent ?? "").not.toContain( + "Live editor connection failed", + ); + expect(container.textContent ?? "").not.toContain("Preparing live editor"); + const iframeSrc = container.querySelector("iframe")?.getAttribute("src"); + expect(iframeSrc).toContain("/live-edit"); + + // Advance past the re-armed (longer, ~8s) wait: the watchdog must probe + // /health again on its own rather than giving up after one attempt. + await act(async () => { + await vi.advanceTimersByTimeAsync(8200); + await flushMicrotasks(); + }); + expect(healthCallCount()).toBeGreaterThanOrEqual(2); + expect(registrationCallCount()).toBe(1); + expect(container.textContent ?? "").not.toContain( + "Live editor connection failed", + ); + }); + + it("surfaces a NON-destructive error once the same-instance-id escalation ceiling is exceeded, without nulling registeredLiveEditBridgeKey — and a late ready handshake still clears it", async () => { + fetchMock.mockImplementation((input: RequestInfo | URL) => { + const url = String(input); + if (url.startsWith(`${BRIDGE_URL}/live-edit-bridge`)) { + return jsonResponse({ ok: true, bridgeInstanceId: "instance-1" }); + } + if (url.startsWith(`${BRIDGE_URL}/health`)) { + return jsonResponse({ ok: true, bridgeInstanceId: "instance-1" }); + } + throw new Error(`unexpected fetch: ${url}`); + }); + + await renderLiveEditCanvas(); + + // Escalation schedule: 4s, +8s, +16s, +16s, +16s (capped) — cumulative + // wait crosses the ~48s ceiling on the 5th probe, around the 60s mark. + // Advance in the same per-step chunks the real schedule uses (rather + // than one huge jump) so each nested setTimeout scheduled from inside the + // previous probe's async continuation is reliably due before the next + // advance runs. + for (const stepMs of [4200, 8200, 16200, 16200, 16200]) { + await act(async () => { + await vi.advanceTimersByTimeAsync(stepMs); + await flushMicrotasks(); + }); + } + + // The non-destructive card renders (same title copy as the destructive + // card, since both describe the same user-facing situation), but the + // live-edit iframe was never torn down: its src still points at the real + // /live-edit document, not the blank "Preparing..." placeholder. + expect(container.textContent ?? "").toContain( + "Live editor connection failed", + ); + expect(container.textContent ?? "").not.toContain("Preparing live editor"); + const iframeSrc = container.querySelector("iframe")?.getAttribute("src"); + expect(iframeSrc).toContain("/live-edit"); + // No reregistration ever happened — this was a stalled-but-healthy same + // process the whole time, never a genuine restart. + expect(registrationCallCount()).toBe(1); + + // A late ready handshake still wins: the still-loading document finally + // finished, and the error card must clear rather than staying stuck. + await act(async () => { + postReadyHandshake(); + await flushMicrotasks(); + }); + expect(container.textContent ?? "").not.toContain( + "Live editor connection failed", + ); + }); + + it("tears down the iframe and surfaces the destructive error when /health itself is unreachable (dev server actually down)", async () => { + fetchMock.mockImplementation((input: RequestInfo | URL) => { + const url = String(input); + if (url.startsWith(`${BRIDGE_URL}/live-edit-bridge`)) { + return jsonResponse({ ok: true, bridgeInstanceId: "instance-1" }); + } + if (url.startsWith(`${BRIDGE_URL}/health`)) { + return Promise.reject(new Error("network error probing /health")); + } + throw new Error(`unexpected fetch: ${url}`); + }); + + await renderLiveEditCanvas(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(4200); + await flushMicrotasks(); + }); + + // /health being unreachable means the dev server process is genuinely + // down, not just slow — this destructive path (tear down + surface the + // error) is unchanged and still correct here. + expect(container.textContent ?? "").toContain( + "Live editor connection failed", + ); + expect(container.textContent ?? "").toContain( + "Is the local dev server still running?", + ); + }); + + it("recovers from a destructive watchdog error when the exact retired live document posts ready late", async () => { + fetchMock.mockImplementation((input: RequestInfo | URL) => { + const url = String(input); + if (url.startsWith(`${BRIDGE_URL}/live-edit-bridge`)) { + return jsonResponse({ ok: true, bridgeInstanceId: "instance-1" }); + } + if (url.startsWith(`${BRIDGE_URL}/health`)) { + return Promise.reject(new Error("temporary health probe failure")); + } + throw new Error(`unexpected fetch: ${url}`); + }); + + await renderLiveEditCanvas(); + const liveIframe = container.querySelector("iframe"); + const retiredLiveWindow = liveIframe?.contentWindow; + expect(retiredLiveWindow).toBeTruthy(); + expect(liveIframe?.getAttribute("src")).toContain("/live-edit"); + + await act(async () => { + await vi.advanceTimersByTimeAsync(4200); + await flushMicrotasks(); + }); + + expect(container.textContent ?? "").toContain( + "Live editor connection failed", + ); + expect(container.querySelector("iframe")?.getAttribute("src")).toBeNull(); + + // Window identity is part of the recovery token: an otherwise well-formed + // ready packet from another same-origin window cannot revive the key. + await act(async () => { + postReadyHandshake(window); + await flushMicrotasks(); + }); + expect(container.textContent ?? "").toContain( + "Live editor connection failed", + ); + + // The live document that the watchdog retired had already queued ready. + // Its exact WindowProxy + bridge-key generation is allowed to restore the + // registration, clearing both the error and the pending placeholder. + await act(async () => { + postReadyHandshake(retiredLiveWindow!); + await flushMicrotasks(); + }); + expect(container.textContent ?? "").not.toContain( + "Live editor connection failed", + ); + expect(container.textContent ?? "").not.toContain("Preparing live editor"); + expect(container.querySelector("iframe")?.getAttribute("src")).toContain( + "/live-edit", + ); + }); + + it("does not loop forever when the bridge never confirms (attempt cap)", async () => { + // /health always reports a fresh, distinct instance id — a pathological + // bridge that appears to restart on every single probe. The retry budget + // (MAX_LIVE_EDIT_RESTART_ATTEMPTS) must still cut this off with a visible + // error rather than polling forever. + let healthCallCounter = 0; + fetchMock.mockImplementation((input: RequestInfo | URL) => { + const url = String(input); + if (url.startsWith(`${BRIDGE_URL}/live-edit-bridge`)) { + return jsonResponse({ ok: true, bridgeInstanceId: "instance-1" }); + } + if (url.startsWith(`${BRIDGE_URL}/health`)) { + healthCallCounter += 1; + return jsonResponse({ + ok: true, + bridgeInstanceId: `instance-restart-${healthCallCounter}`, + }); + } + throw new Error(`unexpected fetch: ${url}`); + }); + + await renderLiveEditCanvas(); + + // Fire the watchdog repeatedly — each cycle re-registers, remounts, and + // (since ready never arrives) times out again. + for (let cycle = 0; cycle < 6; cycle += 1) { + await act(async () => { + await vi.advanceTimersByTimeAsync(4200); + await flushMicrotasks(); + }); + } + + expect(container.textContent ?? "").toContain( + "Live editor connection failed", + ); + }); +}); diff --git a/templates/design/app/components/design/DesignCanvas.creation-overlay.test.ts b/templates/design/app/components/design/DesignCanvas.creation-overlay.test.ts index 8926c40ccf..68be056695 100644 --- a/templates/design/app/components/design/DesignCanvas.creation-overlay.test.ts +++ b/templates/design/app/components/design/DesignCanvas.creation-overlay.test.ts @@ -57,6 +57,37 @@ describe("getScreenContentPointFromClient", () => { ); expect(point).toEqual({ x: 100, y: 50 }); }); + + it("lands a shape at the correct content position on a scrolled screen (unscaled)", () => { + // A click 25px into an unzoomed iframe, with the embedded screen + // scrolled 600px down internally, must resolve to content y = 620 — + // matching where that click actually landed in the full document, not + // just the currently-visible viewport slice. + const point = getScreenContentPointFromClient( + 325, + 420, + { left: 300, top: 400, width: 800, height: 600 }, + { width: 800, height: 600 }, + { left: 0, top: 600 }, + ); + expect(point).toEqual({ x: 25, y: 620 }); + }); + + it("adds a scrolled screen's scroll offset unscaled, not divided by the outer zoom", () => { + // 50% host zoom (rect half the content size). A click at the iframe's + // top edge on a screen scrolled 600px down must map to content y = 600: + // the (clientY - rect.top) term is 0 here, so the entire result comes + // from the scroll offset, proving it is added as-is rather than being + // divided by scale (which would wrongly give 1200). + const point = getScreenContentPointFromClient( + 100, + 50, + { left: 100, top: 50, width: 400, height: 300 }, + { width: 800, height: 600 }, + { left: 0, top: 600 }, + ); + expect(point).toEqual({ x: 0, y: 600 }); + }); }); describe("collapseDoubleClickPenAnchor", () => { diff --git a/templates/design/app/components/design/DesignCanvas.embedded-frame-live.test.tsx b/templates/design/app/components/design/DesignCanvas.embedded-frame-live.test.tsx new file mode 100644 index 0000000000..c73f849ba1 --- /dev/null +++ b/templates/design/app/components/design/DesignCanvas.embedded-frame-live.test.tsx @@ -0,0 +1,150 @@ +// @vitest-environment happy-dom + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, expect, it, vi } from "vitest"; + +import { DesignCanvas } from "./DesignCanvas"; + +vi.mock("@agent-native/core/client", async (importOriginal) => { + const original = + await importOriginal(); + return { + ...original, + useT: () => (key: string) => key, + }; +}); + +describe("DesignCanvas live embedded-frame offset", () => { + it("keeps the iframe identity stable when a board render window reanchors", async () => { + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + const content = `
`; + + const render = (offset: number) => ( + {}} + onElementHover={() => {}} + tweakValues={{}} + boardSurface + editMode={false} + embeddedFrame={{ + viewportWidth: 8192, + viewportHeight: 8192, + displayWidth: 8192, + displayHeight: 8192, + fluid: true, + contentOffsetX: offset, + contentOffsetY: offset, + }} + /> + ); + + try { + await act(async () => root.render(render(4096))); + const before = container.querySelector( + "iframe[data-design-preview-iframe]", + ); + expect(before).not.toBeNull(); + expect(before!.srcdoc).toContain("translate:4096px 4096px"); + + await act(async () => root.render(render(8192))); + const after = container.querySelector( + "iframe[data-design-preview-iframe]", + ); + expect(after).toBe(before); + // srcdoc intentionally stays keyed to the existing browsing context; + // the live offset effect updates the document/bridge in place. + expect(after!.srcdoc).toContain("translate:4096px 4096px"); + const liveOffsetStyle = after!.contentDocument?.querySelector( + "style[data-agent-native-content-offset]", + ); + if (liveOffsetStyle) { + expect(liveOffsetStyle.textContent).toContain( + "translate:8192px 8192px", + ); + } + } finally { + await act(async () => root.unmount()); + container.remove(); + } + }); + + it("queues and deduplicates runtime structure move requests until the bridge is ready", async () => { + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + const request = { + requestId: 41, + subject: { + selector: ".repeated", + sourceId: "runtime-subject", + }, + anchor: { + selector: ".repeated", + sourceId: "runtime-anchor", + }, + placement: "inside" as const, + }; + const render = (runtimeRequest: typeof request | null) => ( + {}} + onElementHover={() => {}} + tweakValues={{}} + editMode + /> + ); + + try { + await act(async () => root.render(render(null))); + const iframe = container.querySelector( + "iframe[data-design-preview-iframe]", + ); + expect(iframe?.contentWindow).toBeTruthy(); + const postMessage = vi.spyOn(iframe!.contentWindow!, "postMessage"); + + await act(async () => root.render(render(request))); + await act(async () => { + iframe!.dispatchEvent(new Event("load")); + }); + + const expected = { + type: "runtime-structure-move", + subjectSelector: ".repeated", + subjectSourceId: "runtime-subject", + anchorSelector: ".repeated", + anchorSourceId: "runtime-anchor", + placement: "inside", + }; + expect(postMessage).toHaveBeenCalledWith(expected, "*"); + + const matchingCallsBefore = postMessage.mock.calls.filter( + ([message]) => + (message as { type?: string }).type === "runtime-structure-move", + ).length; + await act(async () => root.render(render(request))); + const matchingCallsAfter = postMessage.mock.calls.filter( + ([message]) => + (message as { type?: string }).type === "runtime-structure-move", + ).length; + expect(matchingCallsAfter).toBe(matchingCallsBefore); + } finally { + await act(async () => root.unmount()); + container.remove(); + } + }); +}); diff --git a/templates/design/app/components/design/DesignCanvas.localhost-preview.test.tsx b/templates/design/app/components/design/DesignCanvas.localhost-preview.test.tsx index e9bf089e65..77d7b4532c 100644 --- a/templates/design/app/components/design/DesignCanvas.localhost-preview.test.tsx +++ b/templates/design/app/components/design/DesignCanvas.localhost-preview.test.tsx @@ -47,6 +47,127 @@ afterEach(async () => { }); describe("DesignCanvas authenticated localhost source hydration", () => { + it("hands a successful overview registration to Full view without a placeholder reload or URL-only frame", async () => { + iframeServer = http.createServer((_request, response) => { + response.writeHead(200, { "content-type": "text/html; charset=utf-8" }); + response.end("Live chat"); + }); + const iframePort = await new Promise((resolve, reject) => { + iframeServer!.once("error", reject); + iframeServer!.listen(0, "127.0.0.1", () => { + const address = iframeServer!.address(); + resolve(typeof address === "object" && address ? address.port : 0); + }); + }); + const bridgeUrl = `http://127.0.0.1:${iframePort}`; + const previewUrl = "http://localhost:5173/chat"; + let resolveSecondRegistration!: (response: Response) => void; + const secondRegistration = new Promise((resolve) => { + resolveSecondRegistration = resolve; + }); + let registrationCount = 0; + const fetchMock = vi.fn((input: RequestInfo | URL) => { + const url = requestInfoUrl(input); + if (!url.endsWith("/live-edit-bridge")) { + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + } + registrationCount += 1; + if (registrationCount === 1) { + return Promise.resolve( + new Response( + JSON.stringify({ ok: true, bridgeInstanceId: "instance-1" }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + } + return secondRegistration; + }); + vi.stubGlobal("fetch", fetchMock); + + const renderCanvas = async (overview: boolean) => { + await act(async () => { + root.render( + {}} + onElementHover={() => {}} + tweakValues={{}} + />, + ); + }); + }; + + await renderCanvas(true); + await vi.waitFor(() => { + expect( + container.querySelector( + "[data-design-preview-iframe]", + )?.src, + ).toContain("/live-edit?"); + }); + + await act(async () => root.unmount()); + root = createRoot(container); + await renderCanvas(false); + + // The second registration is deliberately unresolved. Full view must + // still mount the one real live-edit URL immediately from the successful + // overview handoff, never an empty srcdoc that is replaced later. + const focusedIframe = container.querySelector( + "[data-design-preview-iframe]", + ); + expect(focusedIframe?.getAttribute("src")).toContain("/live-edit?"); + expect(focusedIframe?.getAttribute("srcdoc")).toBeNull(); + const fallback = container.querySelector( + "[data-live-edit-transition-fallback]", + ); + expect(fallback?.getAttribute("srcdoc")).toContain("Chat preview"); + expect(fallback?.getAttribute("srcdoc")).not.toBe(previewUrl); + + await act(async () => { + window.dispatchEvent( + new MessageEvent("message", { + data: { type: "agent-native:editor-chrome-ready" }, + origin: bridgeUrl, + source: focusedIframe?.contentWindow, + }), + ); + }); + expect( + container.querySelector("[data-live-edit-transition-fallback]"), + ).toBeNull(); + expect(container.querySelector("[data-design-preview-iframe]")).toBe( + focusedIframe, + ); + + resolveSecondRegistration( + new Response( + JSON.stringify({ ok: true, bridgeInstanceId: "instance-1" }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + }); + it("hydrates source HTML in parallel without replacing the keyed live iframe", async () => { iframeServer = http.createServer((_request, response) => { response.writeHead(200, { "content-type": "text/html; charset=utf-8" }); diff --git a/templates/design/app/components/design/DesignCanvas.refreshBoundary.test.ts b/templates/design/app/components/design/DesignCanvas.refreshBoundary.test.ts index 4895769c05..12123bdcec 100644 --- a/templates/design/app/components/design/DesignCanvas.refreshBoundary.test.ts +++ b/templates/design/app/components/design/DesignCanvas.refreshBoundary.test.ts @@ -19,10 +19,19 @@ describe("DesignCanvas Fast Refresh boundary", () => { const hasModifier = (statement: ts.Statement, kind: ts.SyntaxKind) => ts.canHaveModifiers(statement) && Boolean(ts.getModifiers(statement)?.some((item) => item.kind === kind)); + // Type-only declarations (interfaces, type aliases) are erased entirely + // during compilation — they produce zero runtime exports, so an + // `export interface`/`export type` can never affect the Fast Refresh + // component boundary this test actually guards. Only count statements + // that still exist at runtime after TypeScript strips types. + const isTypeOnlyDeclaration = (statement: ts.Statement) => + ts.isInterfaceDeclaration(statement) || + ts.isTypeAliasDeclaration(statement); const exportedStatements = sourceFile.statements.filter( (statement) => - ts.isExportDeclaration(statement) || - hasModifier(statement, ts.SyntaxKind.ExportKeyword), + !isTypeOnlyDeclaration(statement) && + (ts.isExportDeclaration(statement) || + hasModifier(statement, ts.SyntaxKind.ExportKeyword)), ); expect(exportedStatements).toHaveLength(1); diff --git a/templates/design/app/components/design/DesignCanvas.tsx b/templates/design/app/components/design/DesignCanvas.tsx index 633d4c6d25..14498f476a 100644 --- a/templates/design/app/components/design/DesignCanvas.tsx +++ b/templates/design/app/components/design/DesignCanvas.tsx @@ -14,6 +14,7 @@ import { createSmoothNode, isPenCloseTarget, serializePenPath, + translatePenPath, type PenPath, type PenPoint, } from "@shared/pen-path"; @@ -31,7 +32,7 @@ import { CanvasCommentPins, type CanvasPin, } from "@/components/visual-editor"; -import { sendToDesignAgentChat } from "@/lib/agent-chat"; +import { sendToDesignAgentChatAndConfirm } from "@/lib/agent-chat"; import { cn } from "@/lib/utils"; import { editorChromeBridgeScript } from "../../../.generated/bridge/editor-chrome.generated"; @@ -43,6 +44,7 @@ import { shaderRuntimeBridgeScript } from "../../../.generated/bridge/shader-run import { tweakBridgeScript } from "../../../.generated/bridge/tweak.generated"; import { zoomBridgeScript } from "../../../.generated/bridge/zoom.generated"; import { isTrustedCanvasBridgeMessage } from "./bridge-security"; +import { captureAnnotatedScreenshot } from "./design-canvas/annotation-snapshot"; import { submitDesignAnnotations } from "./design-canvas/annotation-submit"; import { getScreenContentPointFromClient, @@ -55,6 +57,7 @@ import { } from "./design-canvas/creation"; import { isElementInfoPayload } from "./design-canvas/element-payload"; import { + embeddedContentOffsetCss, embeddedContentOffsetStyle, getEmbeddedFrameBackgroundStyle, getEmbeddedFrameDocumentContent, @@ -91,6 +94,7 @@ import type { ElementInfo, ElementSelectionIntent, DeviceFrameType, + RuntimeStructureMoveRequest, } from "./types"; /** @@ -320,6 +324,10 @@ interface DesignCanvasProps { status?: number; contentType?: string; }) => void; + onRuntimeLayerSnapshot?: (snapshot: { + html: string; + nodeCount: number; + }) => void; /** * Explicit Builder-hosted app URL for fusion source rendering. * @@ -381,6 +389,8 @@ interface DesignCanvasProps { requestId: number; acks: Array<{ requestId: string; applied: boolean }>; } | null; + /** One-shot host request to optimistically move a runtime-only layer. */ + runtimeStructureMoveRequest?: RuntimeStructureMoveRequest | null; embeddedFrameBackground?: string; transparentBackground?: boolean; editorChromeScaleX?: number; @@ -432,8 +442,10 @@ interface DesignCanvasProps { anchorSourceId?: string; requestId?: string; dropMode?: "flow-insert" | "absolute-container"; + forceFlowPositionOverride?: boolean; sourceRect?: { x: number; y: number; width: number; height: number }; anchorRect?: { x: number; y: number; width: number; height: number }; + anchorElementInfo?: ElementInfo; }, ) => boolean | "pending" | void; onVisualDuplicateChange?: ( @@ -452,6 +464,20 @@ interface DesignCanvasProps { drawMode?: boolean; /** Called when the user exits draw mode (X / Escape / after Send). */ onExitDrawMode?: () => void; + /** + * Bumped by the parent exactly when the user deliberately discards the + * current annotation batch (the same moment `onExitDrawMode` fires from + * the overlay's own X button or a confirmed Send). SharedDrawOverlay only + * clears its strokes/text annotations when this changes — NOT merely + * because `drawMode` toggled off for some other reason (switching tools, + * views, or side panels) — so unsent annotation work survives an + * unrelated exit instead of being silently discarded. + */ + drawOverlayResetSignal?: number; + /** Keeps the active focused overlay bitmap alive across mode/view hides. */ + retainDrawOverlayWhenHidden?: boolean; + /** Reports focused annotation delivery state so global Escape stays inert too. */ + onAnnotationSendingChange?: (sending: boolean) => void; /** Whether comment-pin drop mode is active. */ pinMode?: boolean; /** @@ -685,6 +711,120 @@ function snapshotEndpointUrl(bridgeUrl: string, previewUrl: string): string { return endpoint.toString(); } +function healthEndpointUrl(bridgeUrl: string): string { + return new URL("/health", bridgeUrl).toString(); +} + +/** + * Classifies a suspected `unknown-bridge-key` failure on the localhost + * live-edit bridge, based on a `/health` probe fired when the + * "agent-native:editor-chrome-ready" handshake hasn't arrived in time. + * + * The bridge mints a fresh `bridgeInstanceId` every time its process boots + * (see `startDesignConnectBridge` in packages/core/src/cli/design-connect.ts) + * and echoes it on `/health`, on a successful `/live-edit-bridge` + * registration, and on the "unknown bridge key" 409 from `/live-edit`. The + * 409 itself is usually unreadable here: the live iframe navigates directly + * to the bridge's `/live-edit` URL (a real cross-origin request), so this + * component can't inspect the response body or status code the way a + * same-origin `fetch` could. Instead, callers probe `/health` — a route that + * never requires the preview token — and pass its `bridgeInstanceId` in as + * `responseBridgeInstanceId`. + * + * - Different id than what we last registered with ⇒ the bridge PROCESS + * restarted (crash, machine sleep/wake, manual restart) since our last + * successful registration. That's not a caller bug: silently re-POST the + * same script/key and reload the frame with no user-facing error + * ("reregister"). + * - Same id ⇒ the process that accepted our registration is still running + * and reachable — the iframe just hasn't finished loading yet (e.g. a + * 6-10s cold dev-server compile), NOT a genuine failure. Callers must NOT + * tear the iframe down for this outcome: re-arm the ready-handshake + * watchdog with a longer wait instead ("escalate") — see + * handleSuspectedBridgeRestart's escalation loop, which only gives up and + * shows a (non-destructive) error after a generous total ceiling. + * - No id at all (missing from either side) ⇒ inconclusive — `/health` + * responded but we can't confirm process identity either way. Treat this + * like a real, unresolved failure and surface the existing error/Retry UI + * ("error"). + * + * Deliberately NOT exported: every other pure helper in this file + * (getExternalPreviewUrl, snapshotEndpointUrl, buildEditorChromeBridgeScript, + * contentHash, isAllowedFusionOrigin, originFromUrl, ...) stays module-private + * so DesignCanvas.tsx keeps exporting only the DesignCanvas component itself + * — see DesignCanvas.refreshBoundary.test.ts, which guards the Fast Refresh + * boundary this file relies on given how often it's edited during live design + * sessions. Its decision logic is covered by behavioral tests in + * DesignCanvas.bridge-restart.test.tsx instead of a direct unit import. + */ +function classifyLiveEditHealthProbe( + cachedBridgeInstanceId: string | null | undefined, + responseBridgeInstanceId: string | null | undefined, +): "reregister" | "escalate" | "error" { + if (!cachedBridgeInstanceId || !responseBridgeInstanceId) return "error"; + return cachedBridgeInstanceId === responseBridgeInstanceId + ? "escalate" + : "reregister"; +} + +// Grace period after the live-edit bridge is (re)registered and the real +// iframe document is mounted before a missing "agent-native:editor-chrome-ready" +// handshake is treated as a suspected bridge restart rather than an in-flight +// page load. Generous relative to typical localhost load times so a slow but +// healthy dev server is never misdiagnosed as restarted. +const LIVE_EDIT_READY_TIMEOUT_MS = 4000; +// Caps the silent auto-reregister loop below so a pathological bridge that +// keeps minting a new bridgeInstanceId on every probe (or that never manages +// to actually register) can't retry forever without ever surfacing an error. +const MAX_LIVE_EDIT_RESTART_ATTEMPTS = 3; +// When a /health probe confirms the SAME bridgeInstanceId as last registered +// (bridge process healthy, page just slow to post ready), each re-arm of the +// watchdog waits longer than the last — 4s, 8s, 16s — capped here so the +// backoff doesn't grow unbounded on a very long-running compile. +const LIVE_EDIT_SAME_INSTANCE_MAX_REARM_DELAY_MS = 16_000; +// Total cumulative time to keep silently re-arming on a same-instance-id +// "still healthy" response before finally surfacing a non-destructive error +// affordance. Generous relative to even a slow cold dev-server compile so a +// genuinely-loading page is never misdiagnosed as failed; a legitimately +// long compile that finishes after this point still clears the error the +// moment the ready handshake arrives (see the message-handler branch below). +const LIVE_EDIT_SAME_INSTANCE_ERROR_CEILING_MS = 48_000; + +// Successful bridge registrations belong to the localhost bridge process, +// not to one React DesignCanvas instance. Overview -> Full view currently +// replaces the overview canvas component with a focused canvas component; a +// component-local registration flag made that transition mount an empty +// srcdoc first and then replace it with the live URL after an identical POST. +// Keep a deliberately short handoff cache so the focused instance can mount +// the already-registered live document exactly once. A stale entry during +// HMR is harmless: the ready watchdog probes the bridge instance and silently +// re-registers, while the ready-gated fallback below prevents blank content. +const LIVE_EDIT_REGISTRATION_HANDOFF_TTL_MS = 30_000; +const liveEditRegistrationHandoff = new Map(); + +function liveEditRegistrationHandoffKey( + bridgeUrl: string | undefined, + bridgeKey: string, +): string | null { + if (!bridgeUrl) return null; + try { + return `${new URL(bridgeUrl).origin}|${bridgeKey}`; + } catch { + return null; + } +} + +function hasRecentLiveEditRegistration(key: string | null): boolean { + if (!key) return false; + const registeredAt = liveEditRegistrationHandoff.get(key); + if (registeredAt === undefined) return false; + if (Date.now() - registeredAt <= LIVE_EDIT_REGISTRATION_HANDOFF_TTL_MS) { + return true; + } + liveEditRegistrationHandoff.delete(key); + return false; +} + function originFromUrl(value: string | undefined): string | null { if (!value) return null; try { @@ -701,6 +841,9 @@ function buildEditorChromeBridgeScript(args: { editorChromeScaleY: number; screenId: string; boardSurface: boolean; + contentOffsetX: number; + contentOffsetY: number; + runtimeLayerSnapshotEnabled: boolean; }) { return ( createEditorBridgeThemeScript(readEditorBridgeThemeVars()) + @@ -716,6 +859,18 @@ function buildEditorChromeBridgeScript(args: { "__DESIGN_CANVAS_BOARD_SURFACE__", args.boardSurface ? "true" : "false", ) + .replace( + "__DESIGN_CANVAS_CONTENT_OFFSET_X__", + String(Math.round(args.contentOffsetX)), + ) + .replace( + "__DESIGN_CANVAS_CONTENT_OFFSET_Y__", + String(Math.round(args.contentOffsetY)), + ) + .replace( + "__RUNTIME_LAYER_SNAPSHOT_ENABLED__", + args.runtimeLayerSnapshotEnabled ? "true" : "false", + ) ); } @@ -727,6 +882,29 @@ function contentHash(value: string): string { return `${value.length}:${hash >>> 0}`; } +/** + * Safely reads an embedded screen iframe's own internal document scroll + * position, in the iframe's own unscaled content pixels — the same units + * `getScreenContentPointFromClient`'s `scrollOffset` param folds in. Inline + * (srcdoc) screens are same-origin, so the happy path below is the common + * case, but externally-hosted localhost/fusion previews can be cross-origin, + * and `contentWindow.scrollX`/`scrollY` THROWS (it is not merely + * `undefined`) when accessed cross-origin. Always degrade to `{0, 0}` rather + * than letting that throw escape into a render or event handler. + */ +function readIframeScrollOffset(iframe: HTMLIFrameElement | null | undefined): { + left: number; + top: number; +} { + try { + const win = iframe?.contentWindow; + if (!win) return { left: 0, top: 0 }; + return { left: win.scrollX || 0, top: win.scrollY || 0 }; + } catch { + return { left: 0, top: 0 }; + } +} + export function DesignCanvas({ content, contentKey, @@ -734,6 +912,7 @@ export function DesignCanvas({ bridgeUrl, externalSnapshotHtml, onExternalContentSnapshot, + onRuntimeLayerSnapshot, fusionUrl, previewToken, zoom, @@ -747,6 +926,7 @@ export function DesignCanvas({ styleBaselineResetRequest, textRevertRequest, structureAckRequest, + runtimeStructureMoveRequest, embeddedFrameBackground, transparentBackground = false, editorChromeScaleX = 1, @@ -773,6 +953,9 @@ export function DesignCanvas({ tweakValues, drawMode, onExitDrawMode, + drawOverlayResetSignal, + retainDrawOverlayWhenHidden = false, + onAnnotationSendingChange, pinMode, commentPinsHidden, selectedSelector, @@ -847,6 +1030,8 @@ export function DesignCanvas({ // fallback for older/interact-mode documents that never inject the chrome // bridge and thus never post ready) and flush in order. const bridgeReadyRef = useRef(false); + const [readyIframeDocumentIdentity, setReadyIframeDocumentIdentity] = + useState(null); const previousIframeDocumentIdentityRef = useRef(null); const pendingOneShotMessagesRef = useRef([]); const flushPendingOneShotMessages = useCallback(() => { @@ -861,8 +1046,11 @@ export function DesignCanvas({ const postOneShotBridgeMessage = useCallback((message: unknown) => { const iframe = iframeRef.current; const win = iframe?.contentWindow; - if (!win) return false; - if (!bridgeReadyRef.current) { + // A one-shot prop can arrive in the same render that first creates the + // iframe. Keep it queued even when the ref/contentWindow is not attached + // yet; otherwise the effect records its request id as handled and the + // command is lost permanently before the bridge can announce readiness. + if (!win || !bridgeReadyRef.current) { pendingOneShotMessagesRef.current.push(message); return true; } @@ -872,6 +1060,12 @@ export function DesignCanvas({ const [renderedContent, setRenderedContent] = useState(content); const [annotationPins, setAnnotationPins] = useState([]); const [pinSubmitSignal, setPinSubmitSignal] = useState(0); + // True while a drawing send is capturing/compositing/uploading the + // annotated screenshot (see design-canvas/annotation-snapshot.ts). Drives + // SharedDrawOverlay's busy Send state so a slow capture can't be triggered + // twice from the same drawing. + const [annotationCaptureBusy, setAnnotationCaptureBusy] = useState(false); + const annotationCaptureBusyRef = useRef(false); const [fetchedExternalSnapshot, setFetchedExternalSnapshot] = useState<{ url: string; html: string; @@ -891,6 +1085,83 @@ export function DesignCanvas({ // manual retry click (see handleManualSnapshotRetry) so the next failure // starts the backoff over rather than continuing to climb. const snapshotRetryAttemptRef = useRef(0); + // Mirrors the snapshot fetch's own retry/error state above: the + // /live-edit-bridge registration POST used to have no retry/backoff or + // error surface at all — a transient failure (dev server hiccup, brief + // network blip) left registeredLiveEditBridgeKey stuck at null forever, + // which pins waitingForLiveEditBridge true and the user stares at + // "Preparing live editor..." with no explanation and no way to recover + // short of reloading the whole page. + const bridgeRegistrationRetryAttemptRef = useRef(0); + const [bridgeRegistrationRetryNonce, setBridgeRegistrationRetryNonce] = + useState(0); + const [bridgeRegistrationError, setBridgeRegistrationError] = useState<{ + bridgeKey: string; + message: string; + } | null>(null); + // Cache of the bridgeInstanceId returned by the client's LAST successful + // /live-edit-bridge registration POST (see the registration effect below). + // Compared against a later /health probe's bridgeInstanceId to tell "the + // bridge process restarted since we registered" apart from "the same + // process really doesn't know this key" — see classifyLiveEditHealthProbe + // above. + const bridgeInstanceIdRef = useRef(null); + // A destructive watchdog outcome replaces the live iframe with the neutral + // pending document. The live document can still have queued its ready + // handshake immediately before that replacement; once unmounted, its + // WindowProxy no longer equals iframeRef.current.contentWindow and would be + // rejected by the normal bridge trust check. Preserve exactly that retired + // generation so its late ready can restore the matching bridge key. Both + // source-window identity and bridge-key identity must match, preventing a + // stale document from reviving a different screen/script generation. + const lateLiveEditReadyRecoveryRef = useRef<{ + source: Window; + bridgeKey: string; + registrationHandoffKey: string | null; + } | null>(null); + // Guards the auto-reregister probe below against overlapping runs and bounds + // how many times it will silently retry before giving up and surfacing the + // existing error/Retry UI (MAX_LIVE_EDIT_RESTART_ATTEMPTS). This budget is + // specific to the "reregister" (different bridgeInstanceId, genuine bridge + // restart) loop — the same-instance-id "escalate" loop below has its own, + // separate ceiling and does not consume this budget. + const liveEditRestartInFlightRef = useRef(false); + const liveEditRestartAttemptRef = useRef(0); + // Same-instance-id ("escalate") loop state: how long we've cumulatively + // waited on THIS bridge key while /health keeps confirming the bridge + // process is healthy, the current per-arm wait, and the pending re-arm + // timer (so it can be cancelled on ready/unmount/key-change). See + // handleSuspectedBridgeRestart and classifyLiveEditHealthProbe above. + const liveEditSameInstanceElapsedMsRef = useRef(0); + const liveEditSameInstanceDelayRef = useRef(LIVE_EDIT_READY_TIMEOUT_MS); + const liveEditSameInstanceRearmTimerRef = useRef( + undefined, + ); + // Non-destructive counterpart to bridgeRegistrationError: shown as an + // overlay error/Retry card WITHOUT nulling registeredLiveEditBridgeKey, so + // the still-loading iframe is never torn down while it's shown. Only set + // once the same-instance-id escalation loop above exceeds its ceiling; a + // late ready handshake clears it (see the message-handler branch below). + const [ + liveEditSameInstanceStalledError, + setLiveEditSameInstanceStalledError, + ] = useState<{ + bridgeKey: string; + message: string; + } | null>(null); + // Set on unmount so the async /health probe (and its escalation re-arm + // timer) never touches state after this component is gone. + const isUnmountedRef = useRef(false); + useEffect( + () => () => { + isUnmountedRef.current = true; + if (liveEditSameInstanceRearmTimerRef.current !== undefined) { + window.clearTimeout(liveEditSameInstanceRearmTimerRef.current); + liveEditSameInstanceRearmTimerRef.current = undefined; + } + }, + [], + ); const onExternalContentSnapshotRef = useRef(onExternalContentSnapshot); const isEmbeddedFrame = Boolean(embeddedFrame); // Resolve the URL to render in the iframe: @@ -912,6 +1183,10 @@ export function DesignCanvas({ } return getExternalPreviewUrl(renderedContent); }, [fusionUrl, renderedContent, sourceType]); + const runtimeLayerSnapshotEnabled = + sourceType === "localhost" && + Boolean(rawExternalPreviewUrl) && + Boolean(onRuntimeLayerSnapshot); const activeExternalSnapshotHtml = externalSnapshotHtml ?? (fetchedExternalSnapshot?.url === rawExternalPreviewUrl @@ -921,6 +1196,21 @@ export function DesignCanvas({ // registered with the localhost bridge via a large POST, so folding live zoom // in would re-fire the registration effect on every zoom tick. Live scale is // pushed separately over the `set-editor-chrome-scale` postMessage below. + // + // readOnly and editMode are ALSO intentionally excluded from the deps below + // (only read for their FIRST-render baked value) — mirroring the srcdoc + // useMemo's own "readOnly and editMode are intentionally NOT deps" comment + // for inline screens further down this file. Before this fix, this memo + // (unlike the inline srcdoc one) DID list them as deps: every readOnly/ + // editMode change rebuilt this script, which changed liveEditBridgeKey + // (its contentHash), which is embedded in the localhost iframe's `src` via + // resolveLiveEditPreviewUrl — so a routine Edit ⇄ Preview toggle (or a + // canEditDesign permission flip) forced a real cross-origin navigation of + // the embedded dev-server iframe, losing its in-app route/scroll/state. + // Live readOnly/editMode changes flow through the set-read-only and + // set-text-editing-enabled postMessages (see the useEffects below) exactly + // like the inline path, so no live-update behavior is lost by removing + // them here. const editorChromeBridgeForCurrentState = useMemo( () => buildEditorChromeBridgeScript({ @@ -930,19 +1220,24 @@ export function DesignCanvas({ editorChromeScaleY: 1, screenId: screenId ?? contentKey ?? "", boardSurface, + contentOffsetX: embeddedFrame?.contentOffsetX ?? 0, + contentOffsetY: embeddedFrame?.contentOffsetY ?? 0, + runtimeLayerSnapshotEnabled, }), - [boardSurface, contentKey, editMode, readOnly, screenId], + // eslint-disable-next-line react-hooks/exhaustive-deps + [boardSurface, contentKey, runtimeLayerSnapshotEnabled, screenId], ); + // Keep the installed gesture script identical between overview and focused + // mode. The live flags are posted below; baking `isEmbeddedFrame` into the + // script changed the bridge key during Full view and defeated the + // registration handoff cache, forcing an avoidable iframe navigation. const embeddedGestureBridgeForCurrentState = useMemo( () => EMBEDDED_WHEEL_BRIDGE_SCRIPT.replace( "__EMBEDDED_WHEEL_FORWARDING_ENABLED__", - isEmbeddedFrame ? "true" : "false", - ).replace( - "__EMBEDDED_SPACE_KEY_FORWARDING_ENABLED__", - interactMode || readOnly ? "true" : "false", - ), - [interactMode, isEmbeddedFrame, readOnly], + "false", + ).replace("__EMBEDDED_SPACE_KEY_FORWARDING_ENABLED__", "false"), + [], ); const includeLiveEditEditorChrome = !interactMode && !readOnly; const liveEditBridgeScript = useMemo( @@ -966,6 +1261,10 @@ export function DesignCanvas({ () => contentHash(liveEditBridgeScript), [liveEditBridgeScript], ); + const registrationHandoffKey = liveEditRegistrationHandoffKey( + bridgeUrl, + liveEditBridgeKey, + ); const hasLiveEditExternalFrame = sourceType === "localhost" && Boolean(bridgeUrl && rawExternalPreviewUrl); const hasAuthenticatedLiveEditExternalFrame = @@ -975,9 +1274,14 @@ export function DesignCanvas({ Boolean(bridgeUrl && previewToken && rawExternalPreviewUrl); const usesLiveEditEditorBridge = usesLiveEditInjectedBridge && includeLiveEditEditorChrome; + const effectiveRegisteredLiveEditBridgeKey = + registeredLiveEditBridgeKey ?? + (hasRecentLiveEditRegistration(registrationHandoffKey) + ? liveEditBridgeKey + : null); const liveEditBridgeRegistered = usesLiveEditInjectedBridge && - registeredLiveEditBridgeKey === liveEditBridgeKey; + effectiveRegisteredLiveEditBridgeKey === liveEditBridgeKey; // The live iframe and the editable source model are deliberately separate: // render exactly one authenticated /live-edit document while fetching // /snapshot in parallel so DesignEditor patches real HTML rather than the @@ -995,7 +1299,7 @@ export function DesignCanvas({ previewToken, previewUrl: rawExternalPreviewUrl, bridgeKey: liveEditBridgeKey, - registeredBridgeKey: registeredLiveEditBridgeKey, + registeredBridgeKey: effectiveRegisteredLiveEditBridgeKey, }); const iframeRenderContent = !hasAuthenticatedLiveEditExternalFrame && activeExternalSnapshotHtml @@ -1024,16 +1328,45 @@ export function DesignCanvas({ // Register the editor bridge script with the localhost live-edit bridge so // it gets injected into the proxied preview. Re-runs only when the script - // content (liveEditBridgeKey) or the bridge target changes. + // content (liveEditBridgeKey) or the bridge target changes (or a retry — + // auto or manual — bumps bridgeRegistrationRetryNonce). + // + // Mirrors the external-source-snapshot fetch effect's own retry/backoff: + // a transient failure used to leave registeredLiveEditBridgeKey stuck at + // null forever (console.warn only, no retry, no error UI), pinning + // waitingForLiveEditBridge true with no way to recover short of a full + // page reload. useEffect(() => { if (!usesLiveEditInjectedBridge || !bridgeUrl || !previewToken) { + bridgeRegistrationRetryAttemptRef.current = 0; + liveEditRestartAttemptRef.current = 0; + liveEditSameInstanceElapsedMsRef.current = 0; + liveEditSameInstanceDelayRef.current = LIVE_EDIT_READY_TIMEOUT_MS; + if (liveEditSameInstanceRearmTimerRef.current !== undefined) { + window.clearTimeout(liveEditSameInstanceRearmTimerRef.current); + liveEditSameInstanceRearmTimerRef.current = undefined; + } setRegisteredLiveEditBridgeKey(null); + setBridgeRegistrationError(null); + setLiveEditSameInstanceStalledError(null); + lateLiveEditReadyRecoveryRef.current = null; return; } let cancelled = false; + let retryTimer: number | undefined; setRegisteredLiveEditBridgeKey((current) => current === liveEditBridgeKey ? current : null, ); + const scheduleRetry = () => { + if (cancelled) return; + const delay = getSnapshotRetryDelayMs( + bridgeRegistrationRetryAttemptRef.current, + ); + bridgeRegistrationRetryAttemptRef.current += 1; + retryTimer = window.setTimeout(() => { + setBridgeRegistrationRetryNonce((nonce) => nonce + 1); + }, delay); + }; void (async () => { const endpoint = new URL("/live-edit-bridge", bridgeUrl).toString(); try { @@ -1051,25 +1384,320 @@ export function DesignCanvas({ if (!response.ok) { throw new Error(`Bridge registration failed (${response.status})`); } - if (!cancelled) setRegisteredLiveEditBridgeKey(liveEditBridgeKey); + const payload = (await response.json().catch(() => null)) as { + bridgeInstanceId?: string; + } | null; + if (payload && typeof payload.bridgeInstanceId === "string") { + // Cache the instance id from THIS successful registration so a + // later suspected-restart probe (see handleSuspectedBridgeRestart) + // can tell a genuinely restarted bridge process apart from the same + // process rejecting a stale key. + bridgeInstanceIdRef.current = payload.bridgeInstanceId; + } + if (cancelled) return; + bridgeRegistrationRetryAttemptRef.current = 0; + if (registrationHandoffKey) { + liveEditRegistrationHandoff.set(registrationHandoffKey, Date.now()); + } + // liveEditRestartAttemptRef is intentionally NOT reset here: a + // successful registration POST only proves the bridge accepted the + // script, not that the live document actually loaded it (that's what + // the ready-handshake watchdog below still has to confirm). Resetting + // the restart budget on every registration success — rather than only + // on a genuine agent-native:editor-chrome-ready — would let a + // pathological bridge that keeps minting a new bridgeInstanceId + // reregister forever, defeating MAX_LIVE_EDIT_RESTART_ATTEMPTS. + setBridgeRegistrationError(null); + lateLiveEditReadyRecoveryRef.current = null; + setRegisteredLiveEditBridgeKey(liveEditBridgeKey); } catch (error) { if (!cancelled) { + if (registrationHandoffKey) { + liveEditRegistrationHandoff.delete(registrationHandoffKey); + } console.warn("live-edit bridge registration failed", error); setRegisteredLiveEditBridgeKey(null); + setBridgeRegistrationError({ + bridgeKey: liveEditBridgeKey, + message: error instanceof Error ? error.message : String(error), + }); + scheduleRetry(); } } })(); return () => { cancelled = true; + if (retryTimer) window.clearTimeout(retryTimer); }; }, [ + bridgeRegistrationRetryNonce, bridgeUrl, liveEditBridgeKey, liveEditBridgeScript, previewToken, + registrationHandoffKey, usesLiveEditInjectedBridge, ]); + // A liveEditBridgeKey change means the registered script content changed + // (different screen, mode, or scale bake) — a completely new registration + // target. Reset the reregister-attempt budget and the same-instance-id + // escalation state (elapsed wait, backoff delay, pending re-arm timer, and + // any stalled-error card) so a previous screen's exhausted retries/backoff + // never leak into this new screen's fresh watchdog cycle. Deliberately + // separate from the watchdog-arm effect below, which resets the escalation + // state on every successful (re)registration INCLUDING same-key reregister + // cycles — liveEditRestartAttemptRef must NOT reset on those, or the + // MAX_LIVE_EDIT_RESTART_ATTEMPTS budget it guards would never trip (see + // that effect's own comment). + useEffect(() => { + liveEditRestartAttemptRef.current = 0; + liveEditSameInstanceElapsedMsRef.current = 0; + liveEditSameInstanceDelayRef.current = LIVE_EDIT_READY_TIMEOUT_MS; + if (liveEditSameInstanceRearmTimerRef.current !== undefined) { + window.clearTimeout(liveEditSameInstanceRearmTimerRef.current); + liveEditSameInstanceRearmTimerRef.current = undefined; + } + setLiveEditSameInstanceStalledError(null); + lateLiveEditReadyRecoveryRef.current = null; + }, [liveEditBridgeKey]); + + // Manual retry (offline-state "Retry" button): reset the backoff so the + // user-initiated attempt fires immediately, mirroring + // handleManualSnapshotRetry below. Also clears the non-destructive + // same-instance-id stalled-error card and its escalation state, since a + // manual retry (from either error card) should start every counter fresh. + const handleManualBridgeRegistrationRetry = useCallback(() => { + bridgeRegistrationRetryAttemptRef.current = 0; + liveEditRestartAttemptRef.current = 0; + liveEditSameInstanceElapsedMsRef.current = 0; + liveEditSameInstanceDelayRef.current = LIVE_EDIT_READY_TIMEOUT_MS; + if (liveEditSameInstanceRearmTimerRef.current !== undefined) { + window.clearTimeout(liveEditSameInstanceRearmTimerRef.current); + liveEditSameInstanceRearmTimerRef.current = undefined; + } + setLiveEditSameInstanceStalledError(null); + setBridgeRegistrationRetryNonce((nonce) => nonce + 1); + }, []); + + // The registered iframe's `src` is a real cross-origin navigation straight + // to the bridge's authenticated /live-edit URL, so this component can never + // read that response's status or body (a 409 "unknown-bridge-key" looks + // identical to any other document from here). What IS observable is + // whether the editor-chrome bridge's "agent-native:editor-chrome-ready" + // handshake (see the message handler below) arrives within a reasonable + // window after we mounted the real document. A healthy load posts ready + // almost immediately; a 409 never injects the bridge script at all, so + // ready never arrives. Treat a stuck non-ready state as a suspected restart + // and settle it with a /health probe instead of guessing from the error UI. + const handleSuspectedBridgeRestart = useCallback(async () => { + if (!bridgeUrl || !previewToken) return; + if (liveEditRestartInFlightRef.current) return; + liveEditRestartInFlightRef.current = true; + try { + const response = await fetch(healthEndpointUrl(bridgeUrl)); + const payload = (await response.json().catch(() => null)) as { + bridgeInstanceId?: string; + } | null; + if (isUnmountedRef.current) return; + const responseBridgeInstanceId = + payload && typeof payload.bridgeInstanceId === "string" + ? payload.bridgeInstanceId + : null; + const decision = classifyLiveEditHealthProbe( + bridgeInstanceIdRef.current, + responseBridgeInstanceId, + ); + if (decision === "reregister") { + if ( + liveEditRestartAttemptRef.current >= MAX_LIVE_EDIT_RESTART_ATTEMPTS + ) { + if (registrationHandoffKey) { + liveEditRegistrationHandoff.delete(registrationHandoffKey); + } + const lateReadySource = iframeRef.current?.contentWindow; + lateLiveEditReadyRecoveryRef.current = lateReadySource + ? { + source: lateReadySource, + bridgeKey: liveEditBridgeKey, + registrationHandoffKey, + } + : null; + setRegisteredLiveEditBridgeKey(null); + setBridgeRegistrationError({ + bridgeKey: liveEditBridgeKey, + message: t("designCanvas.localBridge.confirmationRetryExhausted"), + }); + return; + } + liveEditRestartAttemptRef.current += 1; + if (registrationHandoffKey) { + liveEditRegistrationHandoff.delete(registrationHandoffKey); + } + // The bridge process restarted since our last registration — silently + // re-POST the same script/key and reload the frame. Resetting + // registeredLiveEditBridgeKey (without setting an error) re-arms the + // "Preparing live editor..." placeholder — the same neutral state + // already shown on first connect — and bumping the retry nonce + // re-triggers the registration effect above. + bridgeInstanceIdRef.current = responseBridgeInstanceId; + setRegisteredLiveEditBridgeKey(null); + setBridgeRegistrationError(null); + setLiveEditSameInstanceStalledError(null); + // A genuine restart makes any same-instance-id wait we'd accumulated + // against the OLD process meaningless — reset the escalation clock so + // the fresh document this reregister produces gets the full ceiling, + // not whatever was left over from the previous one. + liveEditSameInstanceElapsedMsRef.current = 0; + liveEditSameInstanceDelayRef.current = LIVE_EDIT_READY_TIMEOUT_MS; + if (liveEditSameInstanceRearmTimerRef.current !== undefined) { + window.clearTimeout(liveEditSameInstanceRearmTimerRef.current); + liveEditSameInstanceRearmTimerRef.current = undefined; + } + setBridgeRegistrationRetryNonce((nonce) => nonce + 1); + return; + } + if (decision === "escalate") { + // Same bridge process, still healthy and reachable — the page is + // just slow (e.g. a cold dev-server compile), not actually failing. + // Do NOT touch registeredLiveEditBridgeKey or bridgeRegistrationError + // here: that would tear down the still-legitimately-loading iframe + // (the regression this fix addresses). Re-arm with a longer wait + // instead, up to a generous total ceiling, before finally surfacing a + // separate NON-destructive error card that leaves the iframe alone. + liveEditSameInstanceElapsedMsRef.current += + liveEditSameInstanceDelayRef.current; + if ( + liveEditSameInstanceElapsedMsRef.current >= + LIVE_EDIT_SAME_INSTANCE_ERROR_CEILING_MS + ) { + setLiveEditSameInstanceStalledError({ + bridgeKey: liveEditBridgeKey, + message: t("designCanvas.localBridge.connectionNotConfirmed"), + }); + return; + } + const nextDelay = Math.min( + liveEditSameInstanceDelayRef.current * 2, + LIVE_EDIT_SAME_INSTANCE_MAX_REARM_DELAY_MS, + ); + liveEditSameInstanceDelayRef.current = nextDelay; + if (liveEditSameInstanceRearmTimerRef.current !== undefined) { + window.clearTimeout(liveEditSameInstanceRearmTimerRef.current); + } + liveEditSameInstanceRearmTimerRef.current = window.setTimeout(() => { + liveEditSameInstanceRearmTimerRef.current = undefined; + if (isUnmountedRef.current || bridgeReadyRef.current) return; + void handleSuspectedBridgeRestart(); + }, nextDelay); + return; + } + // decision === "error": no bridgeInstanceId on one or both sides, so we + // can't confirm the same-process-still-healthy story above. Treat this + // as a genuine, unresolved failure and surface the existing + // (destructive) error/Retry UI instead of looping forever. + if (registrationHandoffKey) { + liveEditRegistrationHandoff.delete(registrationHandoffKey); + } + const lateReadySource = iframeRef.current?.contentWindow; + lateLiveEditReadyRecoveryRef.current = lateReadySource + ? { + source: lateReadySource, + bridgeKey: liveEditBridgeKey, + registrationHandoffKey, + } + : null; + setRegisteredLiveEditBridgeKey(null); + setBridgeRegistrationError({ + bridgeKey: liveEditBridgeKey, + message: t("designCanvas.localBridge.connectionNotConfirmed"), + }); + } catch (error) { + if (isUnmountedRef.current) return; + // /health itself is unreachable (network error / thrown before a + // response) — the dev server process is actually down, not just slow. + // This destructive path (tear down + surface the error) is justified. + if (registrationHandoffKey) { + liveEditRegistrationHandoff.delete(registrationHandoffKey); + } + const lateReadySource = iframeRef.current?.contentWindow; + lateLiveEditReadyRecoveryRef.current = lateReadySource + ? { + source: lateReadySource, + bridgeKey: liveEditBridgeKey, + registrationHandoffKey, + } + : null; + setRegisteredLiveEditBridgeKey(null); + setBridgeRegistrationError({ + bridgeKey: liveEditBridgeKey, + message: error instanceof Error ? error.message : String(error), + }); + } finally { + liveEditRestartInFlightRef.current = false; + } + }, [bridgeUrl, liveEditBridgeKey, previewToken, registrationHandoffKey, t]); + + // Manual retry for the NON-destructive same-instance-id stalled card only + // (see liveEditSameInstanceStalledError below): unlike + // handleManualBridgeRegistrationRetry, registeredLiveEditBridgeKey was + // never nulled here, so bumping bridgeRegistrationRetryNonce would not + // reschedule a fresh watchdog probe (liveEditBridgeRegistered never flips + // false→true to rearm that effect). Reset the backoff and probe /health + // again directly instead. + const handleManualLiveEditSameInstanceRetry = useCallback(() => { + liveEditSameInstanceElapsedMsRef.current = 0; + liveEditSameInstanceDelayRef.current = LIVE_EDIT_READY_TIMEOUT_MS; + if (liveEditSameInstanceRearmTimerRef.current !== undefined) { + window.clearTimeout(liveEditSameInstanceRearmTimerRef.current); + liveEditSameInstanceRearmTimerRef.current = undefined; + } + setLiveEditSameInstanceStalledError(null); + void handleSuspectedBridgeRestart(); + }, [handleSuspectedBridgeRestart]); + + // Arm the ready-handshake watchdog whenever the real authenticated live-edit + // document is (re)mounted in editor-chrome mode (the only mode that ever + // posts agent-native:editor-chrome-ready — see usesLiveEditEditorBridge). + // liveEditBridgeRegistered flips false→true on every fresh mount (including + // the reregister cycle above), so this effect reliably rearms each time. + // + // NOTE: deliberately does NOT reset the same-instance-id escalation refs + // (liveEditSameInstance*) here even though this effect conceptually marks a + // fresh mount: handleSuspectedBridgeRestart depends on `t`, whose identity + // is not guaranteed stable across renders (see useT), so this effect's own + // dependency array can cause it to re-run on unrelated re-renders — not + // only on a genuine fresh mount. Resetting escalation state here would + // then race with (and can clobber) the very state update that triggered + // that extra re-render, e.g. immediately wiping the non-destructive stalled + // error the moment it's set. The escalation refs are instead reset from + // known-good, less-churny signals: the liveEditBridgeKey-keyed effect below + // (genuinely new script/screen) and the "reregister" branch inside + // handleSuspectedBridgeRestart itself (genuine bridge-process restart). + useEffect(() => { + if ( + !usesLiveEditEditorBridge || + !liveEditBridgeRegistered || + !externalPreviewUrl + ) { + return; + } + let cancelled = false; + const timer = window.setTimeout(() => { + if (cancelled || bridgeReadyRef.current) return; + void handleSuspectedBridgeRestart(); + }, LIVE_EDIT_READY_TIMEOUT_MS); + return () => { + cancelled = true; + window.clearTimeout(timer); + }; + }, [ + usesLiveEditEditorBridge, + liveEditBridgeRegistered, + externalPreviewUrl, + handleSuspectedBridgeRestart, + ]); + useEffect(() => { const previewUrl = rawExternalPreviewUrl; if ( @@ -1326,7 +1954,16 @@ export function DesignCanvas({ .replace( "__DESIGN_CANVAS_BOARD_SURFACE__", boardSurface ? "true" : "false", - ); + ) + .replace( + "__DESIGN_CANVAS_CONTENT_OFFSET_X__", + String(Math.round(embeddedFrame?.contentOffsetX ?? 0)), + ) + .replace( + "__DESIGN_CANVAS_CONTENT_OFFSET_Y__", + String(Math.round(embeddedFrame?.contentOffsetY ?? 0)), + ) + .replace("__RUNTIME_LAYER_SNAPSHOT_ENABLED__", "false"); // ALWAYS injected (like the other always-on bridges above) so // MultiScreenCanvas's cross-screen drag hit-testing // (agent-native:hit-test / agent-native:hit-test-result) resolves an @@ -1380,8 +2017,6 @@ export function DesignCanvas({ externalPreviewUrl, interactMode, isEmbeddedFrame, - embeddedFrame?.contentOffsetX, - embeddedFrame?.contentOffsetY, embeddedFrameBackground, embeddedGestureBridgeForCurrentState, iframeRenderContent, @@ -1398,11 +2033,22 @@ export function DesignCanvas({ previousIframeDocumentIdentityRef.current = iframeDocumentIdentity; bridgeReadyRef.current = false; } + const liveEditDocumentPending = + usesLiveEditEditorBridge && + Boolean(externalPreviewUrl) && + readyIframeDocumentIdentity !== iframeDocumentIdentity; + const liveEditTransitionFallbackHtml = liveEditDocumentPending + ? activeExternalSnapshotHtml + : undefined; // Listen for messages from the iframe useEffect(() => { function handleMessage(e: MessageEvent) { const iframeWindow = iframeRef.current?.contentWindow; + const lateReadyRecovery = + e.data?.type === "agent-native:editor-chrome-ready" + ? lateLiveEditReadyRecoveryRef.current + : null; // For fusion sources the Builder-hosted app is cross-origin, so the strict // `origin === parentOrigin` check can never match. We still require window // identity (the message must come from our own iframe window, not any @@ -1411,7 +2057,7 @@ export function DesignCanvas({ // *.builder.io family) before relaxing the origin check. If the origin is // not on the allowlist we keep the strict check so a hostile frame that // somehow shares our window reference still can't be trusted. - const trusted = + const trustedCurrentFrame = sourceType === "fusion" ? iframeWindow !== null && e.source === iframeWindow && @@ -1428,12 +2074,80 @@ export function DesignCanvas({ ) : [], }); + const trustedLateLiveEditReady = + sourceType === "localhost" && + lateReadyRecovery !== null && + lateReadyRecovery.bridgeKey === liveEditBridgeKey && + isTrustedCanvasBridgeMessage({ + source: e.source, + origin: e.origin, + iframeWindow: lateReadyRecovery.source, + parentOrigin: window.location.origin, + allowedOrigins: [originFromUrl(bridgeUrl)].filter( + (origin): origin is string => Boolean(origin), + ), + }); + const trusted = trustedCurrentFrame || trustedLateLiveEditReady; if (!trusted) { return; } if (!e.data || !e.data.type) return; + if (e.data.type === "agent-native:runtime-layer-snapshot") { + const payload = e.data.payload; + if ( + payload && + typeof payload.html === "string" && + payload.html.length <= 2_000_000 && + Number.isFinite(payload.nodeCount) + ) { + onRuntimeLayerSnapshot?.({ + html: payload.html, + nodeCount: Math.max(0, Math.floor(payload.nodeCount)), + }); + } + return; + } if (e.data.type === "agent-native:editor-chrome-ready") { + if (trustedLateLiveEditReady && lateReadyRecovery) { + // This handshake belongs to the one retired live document saved by + // the destructive watchdog path, not the pending iframe now in the + // DOM. Restore its exact registration generation and let the newly + // mounted live document send the normal ready signal; do not mark + // the pending document ready or flush one-shot messages into it. + lateLiveEditReadyRecoveryRef.current = null; + if (lateReadyRecovery.registrationHandoffKey) { + liveEditRegistrationHandoff.set( + lateReadyRecovery.registrationHandoffKey, + Date.now(), + ); + } + setBridgeRegistrationError((current) => + current?.bridgeKey === lateReadyRecovery.bridgeKey ? null : current, + ); + setRegisteredLiveEditBridgeKey(lateReadyRecovery.bridgeKey); + return; + } + lateLiveEditReadyRecoveryRef.current = null; bridgeReadyRef.current = true; + setReadyIframeDocumentIdentity(iframeDocumentIdentity); + // A confirmed ready handshake proves this bridgeInstanceId/key pair + // is genuinely live — clear the suspected-restart attempt counter so + // a later transient hiccup gets the full retry budget again instead + // of inheriting an already-elevated count from an unrelated earlier + // load. + liveEditRestartAttemptRef.current = 0; + // A late ready handshake wins even after the same-instance-id + // escalation loop gave up and showed its non-destructive error card: + // cancel any pending re-arm timer, reset the backoff, and clear the + // error so the now-loaded iframe (which was never torn down) reads + // as fully connected. + if (liveEditSameInstanceRearmTimerRef.current !== undefined) { + window.clearTimeout(liveEditSameInstanceRearmTimerRef.current); + liveEditSameInstanceRearmTimerRef.current = undefined; + } + liveEditSameInstanceElapsedMsRef.current = 0; + liveEditSameInstanceDelayRef.current = LIVE_EDIT_READY_TIMEOUT_MS; + setLiveEditSameInstanceStalledError(null); flushPendingOneShotMessages(); return; } @@ -1577,8 +2291,13 @@ export function DesignCanvas({ sourceId, anchorSourceId, dropMode, + forceFlowPositionOverride: + e.data.forceFlowPositionOverride === true, sourceRect, anchorRect, + anchorElementInfo: isElementInfoPayload(e.data.anchorPayload) + ? e.data.anchorPayload + : undefined, }, ); if (requestId && applied !== "pending") { @@ -1705,11 +2424,39 @@ export function DesignCanvas({ ? iframeRect.height / iframe.clientHeight : 1; onIframeContextMenu?.({ + screenId: + typeof e.data.screenId === "string" ? e.data.screenId : undefined, clientX, clientY, viewportClientX: (iframeRect?.left ?? 0) + clientX * scaleX, viewportClientY: (iframeRect?.top ?? 0) + clientY * scaleY, info: e.data.payload ?? null, + layerCandidates: Array.isArray(e.data.layerCandidates) + ? e.data.layerCandidates + .filter( + (candidate: unknown) => + candidate && + typeof candidate === "object" && + isElementInfoPayload( + (candidate as { info?: unknown }).info, + ), + ) + .map((candidate: any, index: number) => ({ + key: + typeof candidate.key === "string" + ? candidate.key + : `layer-hit-${index}`, + label: + typeof candidate.label === "string" + ? candidate.label.slice(0, 80) + : "Layer", + screenId: + typeof e.data.screenId === "string" + ? e.data.screenId + : undefined, + info: candidate.info, + })) + : [], }); } return; @@ -1842,6 +2589,7 @@ export function DesignCanvas({ return () => window.removeEventListener("message", handleMessage); }, [ onElementSelect, + onRuntimeLayerSnapshot, onElementMarqueeSelect, onElementHover, onClearSelection, @@ -1863,6 +2611,7 @@ export function DesignCanvas({ isEmbeddedFrame, sourceType, bridgeUrl, + liveEditBridgeKey, fusionUrl, flushPendingOneShotMessages, postOneShotBridgeMessage, @@ -2127,6 +2876,66 @@ export function DesignCanvas({ postOneShotBridgeMessage, ]); + // Overview/focused placement is presentation state, not document identity. + // Update gesture routing in place so entering Full view reuses the same + // registered bridge key instead of rebuilding the injected script. + useEffect(() => { + postOneShotBridgeMessage({ + type: "embedded-canvas-gesture-mode", + wheelEnabled: isEmbeddedFrame, + spaceKeyForwardingEnabled: interactMode || readOnly, + }); + }, [interactMode, isEmbeddedFrame, postOneShotBridgeMessage, readOnly]); + + // The board iframe is a finite paint window over an infinite logical + // canvas. Re-centering that window must update its CSS/bridge coordinate + // offset in place; rebuilding srcdoc here would reload the iframe, flash, + // and discard transient Alpine state on every chunk crossing. + const embeddedContentOffsetX = embeddedFrame?.contentOffsetX ?? 0; + const embeddedContentOffsetY = embeddedFrame?.contentOffsetY ?? 0; + useEffect(() => { + const iframe = iframeRef.current; + if (!iframe) return; + const applyOffset = () => { + try { + const doc = iframe.contentDocument; + if (doc) { + const css = embeddedContentOffsetCss( + embeddedContentOffsetX, + embeddedContentOffsetY, + ); + let style = doc.querySelector( + "style[data-agent-native-content-offset]", + ); + if (!css) { + style?.remove(); + } else { + if (!style) { + style = doc.createElement("style"); + style.setAttribute("data-agent-native-content-offset", ""); + (doc.head ?? doc.documentElement).appendChild(style); + } + style.textContent = css; + } + } + } catch { + // Cross-origin localhost/fusion frames are updated only through their + // own bridge below; direct document access is intentionally optional. + } + iframe.contentWindow?.postMessage( + { + type: "set-content-offset", + x: embeddedContentOffsetX, + y: embeddedContentOffsetY, + }, + "*", + ); + }; + applyOffset(); + iframe.addEventListener("load", applyOffset); + return () => iframe.removeEventListener("load", applyOffset); + }, [embeddedContentOffsetX, embeddedContentOffsetY]); + // Sync readOnly to the bridge IN-PLACE via postMessage so switching the active // surface (board ↔ screen) does not rebuild srcdoc / reload the iframe. // The initial baked __READ_ONLY__ placeholder covers first paint; subsequent @@ -2372,6 +3181,27 @@ export function DesignCanvas({ } }, [postOneShotBridgeMessage, structureAckRequest]); + const lastRuntimeStructureMoveRequestIdRef = useRef(null); + useEffect(() => { + if (!runtimeStructureMoveRequest) return; + if ( + lastRuntimeStructureMoveRequestIdRef.current === + runtimeStructureMoveRequest.requestId + ) { + return; + } + lastRuntimeStructureMoveRequestIdRef.current = + runtimeStructureMoveRequest.requestId; + postOneShotBridgeMessage({ + type: "runtime-structure-move", + subjectSelector: runtimeStructureMoveRequest.subject.selector, + subjectSourceId: runtimeStructureMoveRequest.subject.sourceId, + anchorSelector: runtimeStructureMoveRequest.anchor.selector, + anchorSourceId: runtimeStructureMoveRequest.anchor.sourceId, + placement: runtimeStructureMoveRequest.placement, + }); + }, [postOneShotBridgeMessage, runtimeStructureMoveRequest]); + /** * Send a motion-preview scrub tick to the iframe. `t` is the normalised * playhead position in [0, 1]. Tracks must have been loaded first via the @@ -2747,10 +3577,13 @@ export function DesignCanvas({ const rect = iframe?.getBoundingClientRect(); const screenContentPoint = iframe && rect - ? getScreenContentPointFromClient(e.clientX, e.clientY, rect, { - width: iframe.clientWidth, - height: iframe.clientHeight, - }) + ? getScreenContentPointFromClient( + e.clientX, + e.clientY, + rect, + { width: iframe.clientWidth, height: iframe.clientHeight }, + readIframeScrollOffset(iframe), + ) : { x: e.clientX, y: e.clientY }; onDropFiles(files, { screenContentPoint, screenId }); }, @@ -2810,6 +3643,21 @@ export function DesignCanvas({ : null), }} > + {liveEditTransitionFallbackHtml ? ( + + + + + + + +
+
+ `, + logicalGeometry: logical, + viewport: staticViewport, + }); + + expect(content).toContain("transform:scale(0.03125)!important"); + expect(content).toContain("translate:65536px 65536px!important"); + expect(content).toContain('data-agent-native-node-id="left"'); + expect(content).toContain('data-agent-native-node-id="right"'); + expect(content).not.toMatch(/ { + const renderGeometry = makeGeom(-4096, -4096, 8192, 8192); + for (const boardPoint of [ + { x: -165, y: -90 }, + { x: 329, y: 210 }, + ]) { + const localPoint = boardPointToBoardSurfaceLocalPoint( + boardPoint, + renderGeometry, + ); + expect(localPoint.x).toBeGreaterThanOrEqual(0); + expect(localPoint.y).toBeGreaterThanOrEqual(0); + expect(localPoint.x).toBeLessThan(renderGeometry.width); + expect(localPoint.y).toBeLessThan(renderGeometry.height); + expect( + boardSurfaceLocalPointToBoardPoint(localPoint, renderGeometry), + ).toEqual(boardPoint); + } + }); + it("treats empty board documents as having no surface content", () => { expect( hasBoardSurfaceContent( @@ -640,6 +810,33 @@ describe("parsePrimitivesFromScreen cache key", () => { // the full content string and return the memoized result directly. // --------------------------------------------------------------------------- describe("parsePrimitivesFromScreen identity cache", () => { + it("treats a plain canvas frame as a child-drop container before Auto layout", () => { + expect( + isPrimitiveContainer({ + tagName: "div", + primitiveKind: "frame", + display: "", + borderRadius: "", + }), + ).toBe(true); + expect( + isPrimitiveContainer({ + tagName: "div", + primitiveKind: "text", + display: "inline-block", + borderRadius: "", + }), + ).toBe(false); + expect( + isPrimitiveContainer({ + tagName: "div", + primitiveKind: "ellipse", + display: "", + borderRadius: "50%", + }), + ).toBe(false); + }); + it("returns the same result reference for repeated calls with unchanged content", () => { const screen: ScreenStub = { id: "identity-screen", diff --git a/templates/design/app/components/design/MultiScreenCanvas.tsx b/templates/design/app/components/design/MultiScreenCanvas.tsx index f5cb043922..0b3255a2e2 100644 --- a/templates/design/app/components/design/MultiScreenCanvas.tsx +++ b/templates/design/app/components/design/MultiScreenCanvas.tsx @@ -15,7 +15,7 @@ import { getResizeCursorForHandle, resizeFrameGroupFromDelta, resizeFrameGroupToBounds, - resizeRotatedFrameFromDelta, + resizeRotatedFrameFromDeltaWithSnap, rotateFrameGroupAroundCenter, rotatedRectIntersects, screenToCanvasPoint, @@ -152,6 +152,7 @@ const EMPTY_SELECTED_LAYER_SELECTOR_GROUPS_BY_SCREEN: Record< string, string[][] > = {}; +const EMPTY_SCREEN_IDS: readonly string[] = []; // Shared with canvas-math.ts (DEFAULT_CANVAS_MIN_ZOOM/DEFAULT_CANVAS_MAX_ZOOM) // so this surface's zoom clamp lives in one place instead of being // redeclared locally and drifting from the shared constant. @@ -165,7 +166,9 @@ import { BOARD_SURFACE_BACKGROUND, getBoardContentKey, getBoardContentLayerSignature, + getBoardSurfaceContentBounds, getBoardSurfaceRenderContent, + getBoardSurfaceStaticPreviewContent, hasBoardSurfaceContent, } from "./multi-screen/board-surface-html"; import { @@ -197,9 +200,14 @@ import { isBreakpointSelectionTarget, } from "./multi-screen/iframe-targeting"; import { + boardPointToBoardSurfaceLocalPoint, + boardSurfaceLocalPointToBoardPoint, + getBoardSurfaceRenderGeometry, getBoardSurfaceLayerStyle, + getBoardSurfaceStaticPreviewViewport, isLineupShrinkOnlyChange, OVERVIEW_FRAME_WIDTH, + shouldRenderBoardSurfaceStaticPreview, shouldSuppressLineupRecenter, SURFACE_PADDING, type LineupRecenterDuplicateArm, @@ -280,6 +288,7 @@ import { findTopFrameEntryAtPoint, frameGeometryWithOverrides, frameStyleLeftTop, + geometryContainsPoint, getBreakpointFrameGeometry, getFrameCenter, getInitialFrameGeometry, @@ -302,17 +311,84 @@ import { } from "./multi-screen/gradient-overlay-geometry"; import { getPrimitiveDropTargetForPoint, + getPrimitiveLowZoomHitRect, + parsePrimitivesFromScreen, resolveNodeScreenId, type PrimitiveDropTarget, } from "./multi-screen/primitive-drop-target"; import type { AlignmentGuide, CanvasFrameEntry } from "./multi-screen/types"; import { vectorEditCanvasToLocalPoint } from "./multi-screen/vector-edit-geometry"; +/** + * Imperatively writes a draft primitive's full visual state onto its cached + * DOM node — the outer box (left/top/width/height/rotation, matching + * DraftPrimitiveLayer's own inline style) plus, for kinds whose rendered + * content depends on geometry rather than plain CSS 100%-sizing (path/line/ + * arrow's +viewBox, polygon/star's +viewBox), the + * inner SVG content too. Used by beginDraftResize's live mousemove tick (PERF9: + * ref-only writes instead of setDraftPrimitives per tick) and by + * cancelActiveDrag's Escape-cancel restore, so both paths stay in sync with + * exactly what DraftPrimitiveLayer/DraftPrimitiveContent would have rendered. + * rect/ellipse/text/frame kinds need no extra work here: their content divs + * are `size-full` and already track the outer box via normal CSS layout. + */ +function applyDraftPrimitiveToDom( + element: HTMLElement, + draft: DraftPrimitive, +): void { + const { geometry } = draft; + const { left, top } = frameStyleLeftTop(geometry); + element.style.left = `${left}px`; + element.style.top = `${top}px`; + element.style.width = `${geometry.width}px`; + element.style.height = `${geometry.height}px`; + element.style.transform = geometry.rotation + ? `rotate(${geometry.rotation}deg)` + : ""; + + if ( + draft.kind === "path" || + draft.kind === "line" || + draft.kind === "arrow" + ) { + const svgEl = element.querySelector("svg"); + const pathEl = element.querySelector("path"); + svgEl?.setAttribute( + "viewBox", + `${geometry.x} ${geometry.y} ${geometry.width} ${geometry.height}`, + ); + if (pathEl) { + const pathData = + draft.pathData ?? + (draft.penPath + ? serializePenPath(draft.penPath) + : pointsToPath(draft.points ?? [])); + pathEl.setAttribute("d", pathData); + if (draft.strokeWidth !== undefined) { + pathEl.setAttribute("stroke-width", String(draft.strokeWidth)); + } + } + } else if (draft.kind === "polygon" || draft.kind === "star") { + const svgEl = element.querySelector("svg"); + const polygonEl = element.querySelector("polygon"); + svgEl?.setAttribute( + "viewBox", + `0 0 ${Math.max(1, geometry.width)} ${Math.max(1, geometry.height)}`, + ); + polygonEl?.setAttribute( + "points", + polygonPointsForBox(draft.kind, geometry.width, geometry.height), + ); + } +} + export const MultiScreenCanvas = memo(function MultiScreenCanvas({ screens, zoom, activeId, selectedScreenIds, + hiddenScreenIds = EMPTY_SCREEN_IDS, + lockedScreenIds = EMPTY_SCREEN_IDS, fullViewScreenIds, activeScreenHasHoveredChild = false, hoveredChildScreenId, @@ -431,9 +507,179 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ // error, not just a stale-closure risk). Kept in sync by the effect right // below the other prop/state-mirroring refs (see onGeometryChangeRef etc.). const effectiveToolRef = useRef("move"); - const [selectedIds, setSelectedIds] = useState( - selectedScreenIds ?? [], + const hiddenScreenIdSet = useMemo( + () => new Set(hiddenScreenIds), + [hiddenScreenIds], + ); + const lockedScreenIdSet = useMemo( + () => new Set(lockedScreenIds), + [lockedScreenIds], + ); + const renderedScreens = useMemo( + () => screens.filter((screen) => !hiddenScreenIdSet.has(screen.id)), + [hiddenScreenIdSet, screens], + ); + const selectableScreens = useMemo( + () => renderedScreens.filter((screen) => !lockedScreenIdSet.has(screen.id)), + [lockedScreenIdSet, renderedScreens], + ); + const [selectedIds, setSelectedIds] = useState(() => + (selectedScreenIds ?? []).filter((id) => + selectableScreens.some((screen) => screen.id === id), + ), + ); + const screenIndexById = useMemo( + () => new Map(screens.map((screen, index) => [screen.id, index] as const)), + [screens], + ); + const [boardSurfaceFocusPoint, setBoardSurfaceFocusPoint] = + useState(null); + const pendingStaticBoardSelectionRef = useRef<{ + nodeId: string; + point: Point; + } | null>(null); + const cancelPendingStaticBoardSelection = useCallback(() => { + pendingStaticBoardSelectionRef.current = null; + setBoardSurfaceFocusPoint(null); + }, []); + const boardSurfaceContentBounds = useMemo( + () => getBoardSurfaceContentBounds(boardFileContent), + [boardFileContent], + ); + const boardViewportGeometry = useMemo((): FrameGeometry | undefined => { + if (surfaceSize.width <= 0 || surfaceSize.height <= 0) return undefined; + const scale = Math.max(0.0001, canvasZoom / 100); + return { + x: -pan.x / scale - SURFACE_PADDING, + y: -pan.y / scale - SURFACE_PADDING, + width: surfaceSize.width / scale, + height: surfaceSize.height / scale, + }; + }, [canvasZoom, pan.x, pan.y, surfaceSize.height, surfaceSize.width]); + const boardSurfaceRenderGeometry = useMemo(() => { + if (!boardFrameGeometry) return undefined; + const focusGeometry = boardSurfaceFocusPoint + ? { + x: boardSurfaceFocusPoint.x, + y: boardSurfaceFocusPoint.y, + width: 1, + height: 1, + } + : (boardViewportGeometry ?? boardSurfaceContentBounds); + return getBoardSurfaceRenderGeometry({ + logicalGeometry: boardFrameGeometry, + contentBounds: boardSurfaceContentBounds, + screenGeometries: [ + ...Object.values(frameGeometry), + ...(boardViewportGeometry ? [boardViewportGeometry] : []), + ], + focus: focusGeometry ? getFrameCenter(focusGeometry) : undefined, + }); + }, [ + boardFrameGeometry, + boardSurfaceContentBounds, + boardSurfaceFocusPoint, + boardViewportGeometry, + frameGeometry, + ]); + const boardStaticPreviewViewport = useMemo( + () => + boardFrameGeometry + ? getBoardSurfaceStaticPreviewViewport(boardFrameGeometry) + : null, + [boardFrameGeometry], ); + const boardStaticPreviewContent = useMemo(() => { + if ( + !boardFrameGeometry || + !boardStaticPreviewViewport || + !boardFileContent + ) { + return null; + } + return getBoardSurfaceStaticPreviewContent({ + html: boardFileContent, + logicalGeometry: boardFrameGeometry, + viewport: boardStaticPreviewViewport, + }); + }, [boardFileContent, boardFrameGeometry, boardStaticPreviewViewport]); + const showBoardStaticPreview = Boolean( + boardFrameGeometry && + boardSurfaceRenderGeometry && + boardStaticPreviewContent && + shouldRenderBoardSurfaceStaticPreview({ + zoom: canvasZoom, + viewportGeometry: boardViewportGeometry, + renderGeometry: boardSurfaceRenderGeometry, + }), + ); + const boardStaticPrimitives = useMemo(() => { + if (!boardFileId || !boardFileContent) return []; + return parsePrimitivesFromScreen({ + id: boardFileId, + filename: "__board__.html", + content: boardFileContent, + }); + }, [boardFileContent, boardFileId]); + useEffect(() => { + cancelPendingStaticBoardSelection(); + }, [cancelPendingStaticBoardSelection, canvasZoom, pan.x, pan.y]); + useEffect(() => { + cancelPendingStaticBoardSelection(); + }, [ + activeTool, + boardFileContent, + boardFileId, + boardFrameGeometry?.height, + boardFrameGeometry?.width, + boardFrameGeometry?.x, + boardFrameGeometry?.y, + cancelPendingStaticBoardSelection, + localActiveTool, + ]); + useEffect(() => { + if (!showBoardStaticPreview) cancelPendingStaticBoardSelection(); + }, [cancelPendingStaticBoardSelection, showBoardStaticPreview]); + useEffect(() => { + const pending = pendingStaticBoardSelectionRef.current; + if ( + !pending || + !boardFileId || + !boardSurfaceRenderGeometry || + !geometryContainsPoint(boardSurfaceRenderGeometry, pending.point) + ) { + return; + } + const selector = `[data-agent-native-node-id="${CSS.escape(pending.nodeId)}"]`; + let secondFrame: number | null = null; + const firstFrame = window.requestAnimationFrame(() => { + secondFrame = window.requestAnimationFrame(() => { + if (pendingStaticBoardSelectionRef.current !== pending) return; + const iframe = findCanvasIframeForScreen( + surfaceRef.current, + boardFileId, + boardFileId, + ); + const targetWindow = iframe?.contentWindow; + if (!targetWindow) return; + targetWindow.postMessage( + { + type: "select-element", + selector, + selectorCandidates: [selector], + }, + "*", + ); + if (pendingStaticBoardSelectionRef.current === pending) { + pendingStaticBoardSelectionRef.current = null; + } + }); + }); + return () => { + window.cancelAnimationFrame(firstFrame); + if (secondFrame !== null) window.cancelAnimationFrame(secondFrame); + }; + }, [boardFileId, boardSurfaceRenderGeometry]); const selectedIdsRef = useRef(selectedIds); const dragState = useRef(null); const dragCleanup = useRef<(() => void) | null>(null); @@ -508,6 +754,12 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ ); const [duplicatePreview, setDuplicatePreview] = useState(null); + // PERF9: DOM node backing the alt-drag duplicate ghost, cached so + // beginDuplicateGesture's live mousemove tick can write left/top directly + // instead of calling setDuplicatePreview (a full re-render) every frame — + // same "mutate the DOM now, commit React state only when something + // conditional actually changes" discipline as the frame/draft drag paths. + const duplicatePreviewElRef = useRef(null); const [transformBadge, setTransformBadge] = useState( null, ); @@ -965,7 +1217,12 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ // to the first selected/first-on-board screen in that case. Mirror the // same fallback chain so the anchor matches whichever frame actually // drove the zoom-scale recompute. - const referenceId = activeId ?? selectedIds[0] ?? screens[0]?.id; + const referenceId = + (activeId && renderedScreens.some((screen) => screen.id === activeId) + ? activeId + : undefined) ?? + selectedIds[0] ?? + renderedScreens[0]?.id; const rect = surfaceRef.current?.getBoundingClientRect(); // Prefer the `geometryById` prop over `frameGeometryRef` here: a screen // just created by the frame tool has its geometry committed straight @@ -1018,10 +1275,10 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ // P18: an externally-driven zoom change (toolbar/keyboard) also moves // the canvas-space mapping the pen ghost preview was computed from. recomputePenPointerForViewChangeRef.current(); - }, [zoom, activeId]); + }, [zoom, activeId, renderedScreens, selectedIds]); useEffect(() => { - const currentIds = new Set(screens.map((screen) => screen.id)); + const selectableIds = new Set(selectableScreens.map((screen) => screen.id)); // B5-9: see resolveFrameGeometrySync's doc comment — this used to notify // the parent (onGeometryChange -> queueFrameGeometrySave) with a brand // new screen's disposable getInitialFrameGeometry() fallback before its @@ -1046,13 +1303,14 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ } } updateSelectedIds((current) => { - const next = current.filter((id) => currentIds.has(id)); + const next = current.filter((id) => selectableIds.has(id)); return next.length === current.length ? current : next; }); }, [ geometryById, getResolvedMetadata, screens, + selectableScreens, updateFrameGeometry, updateFrameGeometryRefOnly, updateSelectedIds, @@ -1087,11 +1345,15 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ useEffect(() => { if (!selectedScreenIds) return; + const selectableIds = new Set(selectableScreens.map((screen) => screen.id)); + const nextSelection = selectedScreenIds.filter((id) => + selectableIds.has(id), + ); // Remember the selection we're pushing in from the parent so the report // effects above can recognise (and not echo back) the resulting change. - propSyncedSelectionRef.current = selectedScreenIds; - updateSelectedIds(() => selectedScreenIds); - }, [screens, selectedScreenIds, updateSelectedIds]); + propSyncedSelectionRef.current = nextSelection; + updateSelectedIds(() => nextSelection); + }, [selectableScreens, selectedScreenIds, updateSelectedIds]); useEffect(() => { if ( @@ -1102,8 +1364,13 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ } handledSelectAllRequestRef.current = selectAllRequest; updateSelectedDraftIds(() => []); - updateSelectedIds(() => screens.map((screen) => screen.id)); - }, [screens, selectAllRequest, updateSelectedDraftIds, updateSelectedIds]); + updateSelectedIds(() => selectableScreens.map((screen) => screen.id)); + }, [ + selectAllRequest, + selectableScreens, + updateSelectedDraftIds, + updateSelectedIds, + ]); useEffect(() => { if ( @@ -1158,14 +1425,14 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ } return; } - if (!surfaceRef.current || screens.length === 0) return; + if (!surfaceRef.current || renderedScreens.length === 0) return; const rect = surfaceRef.current.getBoundingClientRect(); const scale = zoomRef.current / 100; - const frames = screens.map((screen, index) => { + const frames = renderedScreens.map((screen) => { const metadata = getResolvedMetadata(screen); const currentGeometry = frameGeometryRef.current[screen.id] ?? - getInitialFrameGeometry(index, metadata); + getInitialFrameGeometry(screenIndexById.get(screen.id) ?? 0, metadata); return getPreviewDeviceFrameGeometry({ currentGeometry, metadata, @@ -1174,7 +1441,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ }); const bounds = getFrameGroupBounds( frames.map((geometry, index) => ({ - id: screens[index]?.id ?? String(index), + id: renderedScreens[index]?.id ?? String(index), geometry, })), ); @@ -1194,7 +1461,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ // Leave a Figma-like board gutter beside the last frame for quick drops/draws, // and fit tall single frames so lower canvas interactions remain reachable. const widthFitScale = - screens.length > 1 && totalWidth > 0 + renderedScreens.length > 1 && totalWidth > 0 ? Math.max(0.1, (rect.width - 180) / totalWidth) : scale; const heightFitScale = @@ -1273,16 +1540,27 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ const getCurrentFrameEntries = useCallback( () => - screens.map((screen, index) => { + renderedScreens.map((screen) => { const metadata = getResolvedMetadata(screen); return { id: screen.id, geometry: frameGeometryRef.current[screen.id] ?? - getInitialFrameGeometry(index, metadata), + getInitialFrameGeometry( + screenIndexById.get(screen.id) ?? 0, + metadata, + ), }; }), - [getResolvedMetadata, screens], + [getResolvedMetadata, renderedScreens, screenIndexById], + ); + + const getSelectableFrameEntries = useCallback( + () => + getCurrentFrameEntries().filter( + (entry) => !lockedScreenIdSet.has(entry.id), + ), + [getCurrentFrameEntries, lockedScreenIdSet], ); const getCurrentDraftEntries = useCallback( @@ -1301,7 +1579,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ const getFrameEntryAtPoint = useCallback( (point: Point) => - findTopFrameEntryAtPoint(getCurrentFrameEntries(), point, { + findTopFrameEntryAtPoint(getSelectableFrameEntries(), point, { // Screen wrappers give this same id a large z-index boost. Geometry // hit testing must mirror it or drops/draws on overlapping frames can // persist into a visually obscured sibling. @@ -1313,7 +1591,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ ? activeId : screensRef.current[0]?.id), }), - [activeId, getCurrentFrameEntries], + [activeId, getSelectableFrameEntries], ); // Mirrors getFrameEntryAtPoint above, but hit-tests draft primitives @@ -1453,8 +1731,10 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ const iframeId = targetScreen ? getActiveScreenIframeId(targetScreen) : targetId; - const targetIframe = surfaceRef.current?.querySelector( - `[data-screen-iframe-id="${CSS.escape(iframeId)}"]`, + const targetIframe = findCanvasIframeForScreen( + surfaceRef.current, + iframeId, + boardFileId, ); targetIframe?.contentWindow?.postMessage( { type: "agent-native:hit-test-preview-clear" }, @@ -1525,21 +1805,44 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ const targetScreen = screensRef.current.find( (s) => s.id === candidate.id, ); - if (!targetScreen) return Promise.resolve({}); - const targetGeometry = candidate.geometry; - const targetIframeId = CSS.escape(getActiveScreenIframeId(targetScreen)); - const targetIframe = surfaceRef.current?.querySelector( - `[data-screen-iframe-id="${targetIframeId}"]`, - ); + const targetIsBoard = candidate.id === boardFileId; + if (!targetScreen && !targetIsBoard) return Promise.resolve({}); + if (targetIsBoard && !boardSurfaceRenderGeometry) { + return Promise.resolve({}); + } + const targetIframe = targetIsBoard + ? findCanvasIframeForScreen( + surfaceRef.current, + candidate.id, + boardFileId, + ) + : surfaceRef.current?.querySelector( + `[data-screen-iframe-id="${CSS.escape( + getActiveScreenIframeId(targetScreen!), + )}"]`, + ); const targetContentWindow = targetIframe?.contentWindow; if (!targetContentWindow) return Promise.resolve({}); - const { width: targetViewportWidth, height: targetViewportHeight } = - getTargetViewportMetadata(targetScreen, targetIframe); - const localPoint = boardPointToScreenLocalPoint( - boardPoint, - targetGeometry, - { width: targetViewportWidth, height: targetViewportHeight }, - ); + const localPoint = + targetIsBoard && boardSurfaceRenderGeometry + ? boardPointToBoardSurfaceLocalPoint( + boardPoint, + boardSurfaceRenderGeometry, + ) + : (() => { + const { + width: targetViewportWidth, + height: targetViewportHeight, + } = getTargetViewportMetadata(targetScreen!, targetIframe); + return boardPointToScreenLocalPoint( + boardPoint, + candidate.geometry, + { + width: targetViewportWidth, + height: targetViewportHeight, + }, + ); + })(); const correlationId = `hit-${Date.now()}-${Math.random() .toString(36) @@ -1652,14 +1955,20 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ const targetScreen = screensRef.current.find( (s) => s.id === candidate.id, ); - const targetIframeId = targetScreen - ? CSS.escape(getActiveScreenIframeId(targetScreen)) - : null; - const targetIframe = targetIframeId + const targetIsBoard = candidate.id === boardFileId; + const targetIframe = targetScreen ? surfaceRef.current?.querySelector( - `[data-screen-iframe-id="${targetIframeId}"]`, + `[data-screen-iframe-id="${CSS.escape( + getActiveScreenIframeId(targetScreen), + )}"]`, ) - : null; + : targetIsBoard + ? findCanvasIframeForScreen( + surfaceRef.current, + candidate.id, + boardFileId, + ) + : null; const guide = targetScreen ? getCrossScreenDropGuideForHitTest({ hit, @@ -1669,7 +1978,20 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ targetIframe, ), }) - : null; + : targetIsBoard && boardSurfaceRenderGeometry + ? getCrossScreenDropGuideForHitTest({ + hit, + targetGeometry: boardSurfaceRenderGeometry, + targetMetadata: { + width: + targetIframe?.clientWidth || + boardSurfaceRenderGeometry.width, + height: + targetIframe?.clientHeight || + boardSurfaceRenderGeometry.height, + }, + }) + : null; setCrossScreenDropGuide(guide); }); }; @@ -1706,6 +2028,20 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ : { boardX: boardPoint.x, boardY: boardPoint.y }, ); requestCrossScreenDropGuide(nextTarget, boardPoint); + } else if ( + sourceScreenId !== boardFileId && + boardFileId && + boardFrameGeometry && + geometryContainsPoint(boardFrameGeometry, boardPoint) + ) { + const nextTarget = { id: boardFileId, geometry: boardFrameGeometry }; + if (crossScreenTargetRef.current?.id !== boardFileId) { + clearCrossScreenPreviewGuide(); + } + crossScreenTargetRef.current = nextTarget; + setCrossScreenTarget(nextTarget); + setCrossScreenGhost({ boardX: boardPoint.x, boardY: boardPoint.y }); + requestCrossScreenDropGuide(nextTarget, boardPoint); } else { clearCrossScreenPreviewGuide(); crossScreenTargetRef.current = null; @@ -1741,16 +2077,45 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ if (!targetCandidate) return; if (targetCandidate.id === boardFileId) { - onCrossScreenElementDropRef.current?.({ - sourceSelector: payload.selector, - sourceNodeId: payload.sourceId, - sourceScreenId, - targetScreenId: targetCandidate.id, - targetCanvasPoint: lastBoardPoint, - targetLocalPoint: lastBoardPoint, - sourcePointerOffset: payload.sourcePointerOffset, - styleSnapshot: payload.styleSnapshot, - }); + void runHitTest(targetCandidate, lastBoardPoint).then( + ({ + anchorNodeId, + pendingNodeId, + anchorSelector, + placement, + dropMode, + anchorRect, + }) => { + const hasAnchor = Boolean( + anchorNodeId || pendingNodeId || anchorSelector, + ); + onCrossScreenElementDropRef.current?.({ + sourceSelector: payload.selector, + sourceNodeId: payload.sourceId, + sourceScreenId, + targetScreenId: targetCandidate.id, + targetAnchorNodeId: anchorNodeId, + targetAnchorPendingNodeId: pendingNodeId, + targetAnchorSelector: anchorSelector, + targetAnchorPlacement: placement, + targetDropMode: dropMode, + targetAnchorRect: anchorRect, + targetCanvasPoint: lastBoardPoint, + // Anchor rects come from the finite board iframe and are local + // to its render window. Use the same local space for nested + // placement; root-level board drops keep persisted board coords. + targetLocalPoint: + hasAnchor && boardSurfaceRenderGeometry + ? boardPointToBoardSurfaceLocalPoint( + lastBoardPoint, + boardSurfaceRenderGeometry, + ) + : lastBoardPoint, + sourcePointerOffset: payload.sourcePointerOffset, + styleSnapshot: payload.styleSnapshot, + }); + }, + ); return; } @@ -2048,11 +2413,17 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ let boardX: number; let boardY: number; - if (sourceScreenId === boardFileId && boardFrameGeometry) { + if (sourceScreenId === boardFileId && boardSurfaceRenderGeometry) { // The board iframe is pixel-exact: 1 iframe pixel == 1 canvas unit. - // iframeX/iframeY are already in canvas space (no scale needed). - boardX = boardFrameGeometry.x + iframeX; - boardY = boardFrameGeometry.y + iframeY; + // Its finite paint window can start anywhere within the much larger + // logical board, so add the render origin (not the logical board's + // fixed -65536 origin) to recover persisted canvas coordinates. + const boardPoint = boardSurfaceLocalPointToBoardPoint( + { x: iframeX, y: iframeY }, + boardSurfaceRenderGeometry, + ); + boardX = boardPoint.x; + boardY = boardPoint.y; } else { const sourceScreen = screensRef.current.find( (s) => s.id === sourceScreenId, @@ -2107,6 +2478,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ activeId, boardFileId, boardFrameGeometry, + boardSurfaceRenderGeometry, getFrameEntryAtPoint, getCanvasPoint, getResolvedMetadata, @@ -2305,7 +2677,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ * to collect every screen. */ const collectLayerMarqueeCandidates = useCallback( async (screenIds?: Set) => { - const frameEntries = getCurrentFrameEntries().filter( + const frameEntries = getSelectableFrameEntries().filter( (entry) => !screenIds || screenIds.has(entry.id), ); const frameCandidates = await Promise.all( @@ -2340,7 +2712,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ ); const boardCandidates = boardFileId && - boardFrameGeometry && + boardSurfaceRenderGeometry && (!screenIds || screenIds.has(boardFileId)) ? await (async () => { const infos = await requestSelectableElementInfos(boardFileId); @@ -2354,13 +2726,13 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ width: info.boundingRect.width, height: info.boundingRect.height, }, - boardFrameGeometry, + boardSurfaceRenderGeometry, { - width: boardFrameGeometry.width, - height: boardFrameGeometry.height, + width: boardSurfaceRenderGeometry.width, + height: boardSurfaceRenderGeometry.height, }, ), - frameGeometry: boardFrameGeometry, + frameGeometry: boardSurfaceRenderGeometry, })); })() : []; @@ -2368,8 +2740,8 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ }, [ boardFileId, - boardFrameGeometry, - getCurrentFrameEntries, + boardSurfaceRenderGeometry, + getSelectableFrameEntries, getResolvedMetadata, requestSelectableElementInfos, ], @@ -2576,6 +2948,52 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ const position = frameStyleLeftTop(geometry, labelHeight); element.style.left = `${position.left}px`; element.style.top = `${position.top}px`; + if (kind !== "frame") { + // A draft resize (unlike a plain draft move) can also leave width/ + // height/rotation, and for path/line/arrow/polygon/star kinds the + // inner SVG viewBox + path/polygon content, imperatively mutated + // mid-gesture (see applyDraftPrimitiveToDom, used by + // beginDraftResize's own live mousemove tick). Restoring it here + // unconditionally is a harmless no-op for a plain draft move (whose + // origin geometry never had a different width/height/rotation to + // begin with) and fixes an Escape mid-draft-resize leaving the + // shape's box/content visually stuck at its last dragged size. + if ("kind" in origin) { + applyDraftPrimitiveToDom(element, origin); + } + return; + } + // Resize/rotate (unlike plain move) can also leave width/height/ + // rotation imperatively mutated mid-gesture — restore those here too + // so an Escape mid-resize/rotate doesn't leave the frame visually + // stuck at its last dragged size/angle even though the geometry + // state itself was already rolled back above. + element.style.width = `${geometry.width}px`; + element.style.transform = geometry.rotation + ? `rotate(${geometry.rotation}deg)` + : ""; + element.style.transformOrigin = `${geometry.width / 2}px ${ + labelHeight + geometry.height / 2 + }px`; + const cardEl = element.querySelector("[data-screen-card]"); + if (cardEl) { + cardEl.style.width = `${geometry.width}px`; + cardEl.style.height = `${geometry.height}px`; + } + const iframeEl = element.querySelector( + `[data-screen-iframe-id="${CSS.escape(targetId)}"]`, + ); + const screen = screensRef.current.find((s) => s.id === targetId); + if (iframeEl && screen) { + const viewport = getScreenPreviewViewport( + getResolvedMetadata(screen), + geometry, + ); + iframeEl.style.width = `${viewport.viewportWidth}px`; + iframeEl.style.height = `${viewport.viewportHeight}px`; + iframeEl.style.transform = + viewport.scale === 1 ? "" : `scale(${viewport.scale})`; + } }); const selectionBox = surfaceRef.current?.querySelector( @@ -2602,6 +3020,17 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ const position = frameStyleLeftTop(bounds); selectionBox.style.left = `${position.left}px`; selectionBox.style.top = `${position.top}px`; + // Plain move never touches the selection box's width/height/rotation + // (only left/top), but resize/rotate/group-rotate do — restore those + // too so Escape can't leave the box's chrome a stale dragged size. + selectionBox.style.width = `${bounds.width}px`; + selectionBox.style.height = `${bounds.height}px`; + const boxRotation = + originGeometries.length === 1 ? (originGeometries[0].rotation ?? 0) : 0; + selectionBox.style.transform = boxRotation + ? `rotate(${boxRotation}deg)` + : ""; + selectionBox.style.transformOrigin = `${bounds.width / 2}px ${bounds.height / 2}px`; }; if (state) { @@ -2611,33 +3040,36 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ state.type === "resize" || state.type === "group-rotate" ) { - if (state.type === "move") { - // Move drags mutate left/top directly while React deliberately keeps - // its pre-drag props. A state rollback to those same values does not - // make React rewrite the externally-mutated styles, so Escape must - // restore the frame + selection-box DOM positions explicitly. - restoreImperativeMoveDom( - state.originFrames, - state.targetIds, - "frame", - ); - } + // Move/resize/group-rotate all mutate the frame(s)' DOM directly + // (left/top for move; also width/height/rotation for resize and + // group-rotate — see restoreImperativeMoveDom's comment) while React + // deliberately keeps its pre-drag props. A state rollback to those + // same values does not make React rewrite the externally-mutated + // styles, so Escape must restore the frame + selection-box DOM + // state explicitly for every one of these gesture types, not just + // move. + restoreImperativeMoveDom(state.originFrames, state.targetIds, "frame"); updateFrameGeometry((current) => frameGeometryWithOverrides(current, state.originFrames), ); } else if (state.type === "rotate") { + // Single-frame rotate also mutates the frame's transform directly — + // see the matching comment above. + restoreImperativeMoveDom( + { [state.frameId]: state.originFrame }, + [state.frameId], + "frame", + ); updateFrameGeometry((current) => ({ ...current, [state.frameId]: { ...state.originFrame }, })); } else if (state.type === "draft-move" || state.type === "draft-resize") { - if (state.type === "draft-move") { - restoreImperativeMoveDom( - state.originDrafts, - state.targetIds, - "draft", - ); - } + // Both draft-move (left/top only) and draft-resize (also width/ + // height/rotation + inner SVG content, see restoreImperativeMoveDom's + // draft-kind branch) mutate the draft's DOM node directly mid-gesture + // — restore it here the same way move/resize/rotate do for frames. + restoreImperativeMoveDom(state.originDrafts, state.targetIds, "draft"); updateDraftPrimitives((current) => current.map((draft) => { const origin = state.originDrafts[draft.id]; @@ -2689,6 +3121,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ return false; }, [ finishDrag, + getResolvedMetadata, updateDraftPrimitives, updateFrameGeometry, updateSelectedDraftIds, @@ -2725,6 +3158,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); + cancelPendingStaticBoardSelection(); dragState.current = { type: "pan", originClient: { x: e.clientX, y: e.clientY }, @@ -2760,7 +3194,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ installDragListeners(handleMouseMove, handlePanEnd); }, - [finishDrag, installDragListeners], + [cancelPendingStaticBoardSelection, finishDrag, installDragListeners], ); const beginMarquee = useCallback( @@ -2875,10 +3309,22 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ ) { state.hasMoved = true; } + + // Never commit a live marquee rect/selection before the drag + // threshold is crossed — matches every other gesture in this file + // (move/resize/rotate/draft-move/draft-resize all gate on hasMoved + // here). Without this, a shift-click that jitters a couple px near an + // already-selected frame's edge runs xorMarqueeSelection against a + // near-zero-size rect and can toggle that frame OUT of the selection + // — a corruption that mouseup's shouldClearSelectionOnEmptyCanvasClick + // never rolls back for the additive (shift) case, since it only + // restores selection when the gesture is non-additive. + if (!state.hasMoved) return; + setMarquee(rect); const chromeScale = chromeScaleFromZoom(zoomRef.current); - const hitIds = getCurrentFrameEntries() + const hitIds = getSelectableFrameEntries() .filter((entry) => rotatedRectIntersects( rect, @@ -2940,7 +3386,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ finishDrag, getCanvasPoint, getCurrentDraftEntries, - getCurrentFrameEntries, + getSelectableFrameEntries, installDragListeners, onLayerMarqueeSelectionChange, updateSelectedDraftIds, @@ -3160,6 +3606,12 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ undefined, { nextTool: "pen" }, ); + // Keep the Pen tool armed after Enter/Escape/closing a path, matching + // Figma. The parent selection callback also receives nextTool="pen", + // but board primitives intentionally bypass that generic callback and + // asynchronous selection reconciliation can otherwise paint Move for a + // frame. Drive the controlled tool explicitly at the commit boundary. + onActiveToolChange?.("pen"); clearActivePenPath(); }, [clearActivePenPath, commitDraftPrimitive, onActiveToolChange, toolProps], @@ -4025,13 +4477,16 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ current.filter((draftId) => !persistedDraftIds.has(draftId)), ); // Reparent each persisted node into the container primitive. + // When the drop resolved to a before/after auto-layout slot + // (see findAutoLayoutInsertionAnchor), anchor against that + // sibling instead of always appending inside the container. persisted.forEach((entry) => { onPrimitiveReparentRef.current?.({ sourceNodeId: entry.nodeId, sourceScreenId: entry.frameId, - targetNodeId: dropTarget.nodeId, + targetNodeId: dropTarget.anchorNodeId ?? dropTarget.nodeId, targetScreenId: dropTarget.screenId, - placement: "inside", + placement: dropTarget.placement ?? "inside", }); }); const lastPersisted = persisted[persisted.length - 1]; @@ -4165,6 +4620,23 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ setIsDragging(true); setDragCursor(getResizeCursor(handle)); + // PERF9: cache each resized draft's DOM node (and the selection-box + // overlay tracking it) once, up front — mirrors beginDraftDrag/ + // beginFrameDrag/beginResize's imperative "mutate now, commit once" + // discipline. This used to call updateDraftPrimitives (setDraftPrimitives) + // on every mousemove tick, forcing a full MultiScreenCanvas re-render + // per rAF frame during a drag. + const resizedDraftEls = new Map(); + targetIds.forEach((targetId) => { + const el = surfaceRef.current?.querySelector( + `[data-draft-id="${CSS.escape(targetId)}"]`, + ); + if (el) resizedDraftEls.set(targetId, el); + }); + const selectionBoxEl = surfaceRef.current?.querySelector( + "[data-frame-selection-box]", + ); + const handleMouseMove = (ev: MouseEvent) => { const state = dragState.current; if (!state || state.type !== "draft-resize") return; @@ -4182,7 +4654,11 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ } // A resize handle click (or normal 1-2px hand jitter) must not resize - // the draft. Frame resize already enforces this same threshold. + // the draft. Frame resize already enforces this same threshold. Draft + // resizes use direct DOM mutation now (see below), so applying even a + // 1px delta here would leave a phantom visual nudge React never + // learns it needs to overwrite on mouseup — same rationale as + // beginDraftDrag's matching guard. if (!state.hasMoved) return; const originEntries = state.targetIds.map((targetId) => ({ @@ -4229,7 +4705,10 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ // drag-start and must see whichever tool is active on THIS tick. const scaleK = effectiveToolRef.current === "scale"; - updateDraftPrimitives((current) => + // PERF9: ref-only geometry write (no setDraftPrimitives) + direct DOM + // mutation via applyDraftPrimitiveToDom, same "imperative now, commit + // once" discipline as beginDraftDrag/beginFrameDrag/beginResize above. + updateDraftPrimitivesRefOnly((current) => current.map((draft) => { const origin = state.originDrafts[draft.id]; const geometry = resizedById[draft.id]; @@ -4237,6 +4716,54 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ return applyDraftGeometry(origin, geometry, scaleK); }), ); + state.targetIds.forEach((targetId) => { + const draft = draftPrimitivesRef.current.find( + (candidate) => candidate.id === targetId, + ); + const el = resizedDraftEls.get(targetId); + if (draft && el) applyDraftPrimitiveToDom(el, draft); + }); + if (selectionBoxEl) { + const targetGeometries = state.targetIds + .map( + (targetId) => + draftPrimitivesRef.current.find( + (candidate) => candidate.id === targetId, + )?.geometry, + ) + .filter((geometry): geometry is FrameGeometry => Boolean(geometry)); + const bounds = + targetGeometries.length === 1 + ? targetGeometries[0] + : (() => { + const groupBounds = getFrameGroupBounds( + targetGeometries.map((geometry) => ({ id: "", geometry })), + ); + return groupBounds + ? { + x: groupBounds.left, + y: groupBounds.top, + width: groupBounds.width, + height: groupBounds.height, + } + : null; + })(); + if (bounds) { + const { left, top } = frameStyleLeftTop(bounds); + selectionBoxEl.style.left = `${left}px`; + selectionBoxEl.style.top = `${top}px`; + selectionBoxEl.style.width = `${bounds.width}px`; + selectionBoxEl.style.height = `${bounds.height}px`; + const rotation = + targetGeometries.length === 1 + ? (targetGeometries[0].rotation ?? 0) + : 0; + selectionBoxEl.style.transform = rotation + ? `rotate(${rotation}deg)` + : ""; + selectionBoxEl.style.transformOrigin = `${bounds.width / 2}px ${bounds.height / 2}px`; + } + } setAlignmentGuides(snap.guides); showTransformFeedback( `${Math.round(snap.frame.width)} x ${Math.round(snap.frame.height)}`, @@ -4245,7 +4772,19 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ ); }; - installDragListeners(handleMouseMove, finishDrag); + const handleMouseUp = () => { + const state = dragState.current; + if (state?.type === "draft-resize" && state.hasMoved) { + // PERF9: the live resize above only wrote draftPrimitivesRef (no + // setDraftPrimitives per tick) — reconcile React state with the + // ref's final values here, exactly once, mirroring beginDraftDrag's + // matching handleMouseUp comment. + updateDraftPrimitives(() => draftPrimitivesRef.current); + } + finishDrag(); + }; + + installDragListeners(handleMouseMove, handleMouseUp); }, [ finishDrag, @@ -4253,6 +4792,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ installDragListeners, showTransformFeedback, updateDraftPrimitives, + updateDraftPrimitivesRefOnly, updateSelectedDraftIds, updateSelectedIds, ], @@ -4285,7 +4825,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ const beginFrameDrag = useCallback( (id: string, e: React.MouseEvent) => { - if (e.button !== 0 || e.shiftKey) return; + if (e.button !== 0 || e.shiftKey || lockedScreenIdSet.has(id)) return; e.preventDefault(); e.stopPropagation(); // Frame mousedowns stop propagation, so they never reach handleMouseDown. @@ -4326,6 +4866,24 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ // Figma parity: object drags keep the default arrow cursor, never a // grabbing hand — see the matching comment in beginDraftDrag above. + // The surface itself never moves mid-gesture, so its bounding rect is + // invariant for the whole drag. Cache it once instead of letting the + // allCommitted/getCanvasPoint branch below call getBoundingClientRect + // on every tick — that read would force a synchronous layout reflow + // right after this same tick's direct style writes (a classic + // write-then-read thrash), scoped to primitive/layer drags specifically + // (the only path that reaches that branch). + const cachedSurfaceRect = surfaceRef.current?.getBoundingClientRect(); + const getCanvasPointFromCachedRect = (clientX: number, clientY: number) => + cachedSurfaceRect + ? screenToCanvasPoint( + { x: clientX, y: clientY }, + { ...panRef.current, zoom: zoomRef.current }, + { x: cachedSurfaceRect.left, y: cachedSurfaceRect.top }, + SURFACE_PADDING, + ) + : getCanvasPoint(clientX, clientY); + // PERF9: cache each dragged screen's DOM node (and the selection-box // overlay tracking it) once, up front, instead of querying the DOM on // every rAF tick. onStartFrameDrag only ever fires with screen.id @@ -4520,7 +5078,10 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ (targetId) => !currentFrameIds.includes(targetId), ); if (allCommitted) { - const canvasPoint = getCanvasPoint(ev.clientX, ev.clientY); + const canvasPoint = getCanvasPointFromCachedRect( + ev.clientX, + ev.clientY, + ); updatePrimitiveDropTarget( findPrimitiveDropTarget(canvasPoint, state.primaryId), ); @@ -4549,12 +5110,15 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ if (allCommitted && dropTarget) { const sourceScreenId = resolvePrimitiveScreenId(state.primaryId); if (sourceScreenId) { + // When the drop resolved to a before/after auto-layout slot + // (see findAutoLayoutInsertionAnchor), anchor against that + // sibling instead of always appending inside the container. onPrimitiveReparentRef.current?.({ sourceNodeId: state.primaryId, sourceScreenId, - targetNodeId: dropTarget.nodeId, + targetNodeId: dropTarget.anchorNodeId ?? dropTarget.nodeId, targetScreenId: dropTarget.screenId, - placement: "inside", + placement: dropTarget.placement ?? "inside", }); suppressNextPick.current = true; finishDrag(); @@ -4591,6 +5155,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ getCanvasPoint, getCurrentFrameEntries, installDragListeners, + lockedScreenIdSet, onPick, resolvePrimitiveScreenId, showTransformFeedback, @@ -4604,7 +5169,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ const beginResize = useCallback( (id: string, handle: ResizeHandle, e: React.MouseEvent) => { - if (e.button !== 0) return; + if (e.button !== 0 || lockedScreenIdSet.has(id)) return; e.preventDefault(); e.stopPropagation(); suppressNextPick.current = true; @@ -4650,6 +5215,131 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ : getResizeCursor(handle), ); + // PERF9: mirror beginFrameDrag's imperative-DOM discipline for resize + // (previously missing here — every tick called the state-committing + // updateFrameGeometry, forcing a full MultiScreenCanvas re-render on + // every native mousemove, unthrottled by rAF). Cache each resized + // screen's DOM nodes up front, write geometry directly to them per + // tick via updateFrameGeometryRefOnly (ref-only, no setFrameGeometry), + // and reconcile React state with one real commit in handleMouseUp. + const frameLabelHeight = + FRAME_LABEL_HEIGHT * chromeScaleFromZoom(zoomRef.current); + const resizedFrameEls = new Map(); + const resizedScreenCardEls = new Map(); + const resizedContentIframeEls = new Map(); + const resizedMetadataById = new Map< + string, + { width: number; height: number } + >(); + originEntries.forEach((entry) => { + const frameEl = surfaceRef.current?.querySelector( + `[data-frame-id="${CSS.escape(entry.id)}"]`, + ); + if (frameEl) { + resizedFrameEls.set(entry.id, frameEl); + const cardEl = + frameEl.querySelector("[data-screen-card]"); + if (cardEl) resizedScreenCardEls.set(entry.id, cardEl); + // Only the plain preview iframe (no custom screenContent, e.g. a + // fusion/localhost DesignCanvas) carries this attribute at this + // render layer — its own iframe is 100%-sized and follows the + // screen-card resize above via CSS with no extra write needed. + const iframeEl = frameEl.querySelector( + `[data-screen-iframe-id="${CSS.escape(entry.id)}"]`, + ); + if (iframeEl) resizedContentIframeEls.set(entry.id, iframeEl); + } + const screen = screensRef.current.find((s) => s.id === entry.id); + if (screen) { + resizedMetadataById.set(entry.id, getResolvedMetadata(screen)); + } + }); + const resizeSelectionBoxEl = + surfaceRef.current?.querySelector( + "[data-frame-selection-box]", + ); + + const applyResizedGeometryToDom = ( + ids: string[], + geometryById: FrameGeometryById, + ) => { + ids.forEach((targetId) => { + const geometry = geometryById[targetId]; + if (!geometry) return; + const frameEl = resizedFrameEls.get(targetId); + if (frameEl) { + const { left, top } = frameStyleLeftTop(geometry, frameLabelHeight); + frameEl.style.left = `${left}px`; + frameEl.style.top = `${top}px`; + frameEl.style.width = `${geometry.width}px`; + frameEl.style.transform = geometry.rotation + ? `rotate(${geometry.rotation}deg)` + : ""; + frameEl.style.transformOrigin = `${geometry.width / 2}px ${ + frameLabelHeight + geometry.height / 2 + }px`; + } + const cardEl = resizedScreenCardEls.get(targetId); + if (cardEl) { + cardEl.style.width = `${geometry.width}px`; + cardEl.style.height = `${geometry.height}px`; + } + const iframeEl = resizedContentIframeEls.get(targetId); + const metadata = resizedMetadataById.get(targetId); + if (iframeEl && metadata) { + const viewport = getScreenPreviewViewport(metadata, geometry); + iframeEl.style.width = `${viewport.viewportWidth}px`; + iframeEl.style.height = `${viewport.viewportHeight}px`; + iframeEl.style.transform = + viewport.scale === 1 ? "" : `scale(${viewport.scale})`; + } + }); + + // Keep the selection outline + resize/rotate handles glued to the + // live geometry — see the matching comment in beginFrameDrag's move + // handler for why SelectionBox's own geometry prop can't do this on + // its own during a ref-only tick. + if (resizeSelectionBoxEl) { + const targetGeometries = ids + .map((targetId) => geometryById[targetId]) + .filter((geometry): geometry is FrameGeometry => Boolean(geometry)); + const bounds = + targetGeometries.length === 1 + ? targetGeometries[0] + : (() => { + const groupBounds = getFrameGroupBounds( + targetGeometries.map((geometry) => ({ + id: "", + geometry, + })), + ); + return groupBounds + ? { + x: groupBounds.left, + y: groupBounds.top, + width: groupBounds.width, + height: groupBounds.height, + } + : null; + })(); + if (bounds) { + const { left, top } = frameStyleLeftTop(bounds); + resizeSelectionBoxEl.style.left = `${left}px`; + resizeSelectionBoxEl.style.top = `${top}px`; + resizeSelectionBoxEl.style.width = `${bounds.width}px`; + resizeSelectionBoxEl.style.height = `${bounds.height}px`; + const rotation = + targetGeometries.length === 1 + ? (targetGeometries[0].rotation ?? 0) + : 0; + resizeSelectionBoxEl.style.transform = rotation + ? `rotate(${rotation}deg)` + : ""; + resizeSelectionBoxEl.style.transformOrigin = `${bounds.width / 2}px ${bounds.height / 2}px`; + } + } + }; + const handleMouseMove = (ev: MouseEvent) => { const state = dragState.current; if (!state || state.type !== "resize") return; @@ -4704,26 +5394,52 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ : null; if (singleRotatedFrame) { - const resizedGeometry = resizeRotatedFrameFromDelta( - singleRotatedFrame.geometry, - state.handle, - dx, - dy, - { - preserveAspectRatio: lockAspectRatio, - resizeFromCenter: ev.altKey, - minWidth: 1, - minHeight: 1, - }, - ); - updateFrameGeometry((current) => ({ - ...current, - [singleRotatedFrame.id]: { - ...state.originFrames[singleRotatedFrame.id], - ...resizedGeometry, - }, - })); - setAlignmentGuides([]); + // Rotation-aware resize snapping (WORK ITEM 2): snap against the + // same stationary siblings the unrotated group-resize path below + // uses. See resizeRotatedFrameFromDeltaWithSnap's doc comment for + // the documented approximation (snaps the frame's own unrotated + // local bounds, not a full rotated-edge projection). + const { frame: resizedGeometry, guides: rotatedSnapGuides } = + resizeRotatedFrameFromDeltaWithSnap( + singleRotatedFrame.geometry, + state.handle, + dx, + dy, + getCurrentFrameEntries().filter( + (entry) => !state.targetIds.includes(entry.id), + ), + { + thresholdScreenPx: DEFAULT_SNAP_THRESHOLD_SCREEN_PX, + zoom: zoomRef.current, + bypass: ev.metaKey || ev.ctrlKey, + preserveAspectRatio: lockAspectRatio, + }, + { + preserveAspectRatio: lockAspectRatio, + resizeFromCenter: ev.altKey, + minWidth: 1, + minHeight: 1, + }, + ); + let nextGeometryById: FrameGeometryById | null = null; + updateFrameGeometryRefOnly((current) => { + const next = { + ...current, + [singleRotatedFrame.id]: { + ...state.originFrames[singleRotatedFrame.id], + ...resizedGeometry, + }, + }; + nextGeometryById = next; + return next; + }); + if (nextGeometryById) { + applyResizedGeometryToDom( + [singleRotatedFrame.id], + nextGeometryById, + ); + } + setAlignmentGuides(rotatedSnapGuides); showTransformFeedback( `${Math.round(resizedGeometry.width)} x ${Math.round(resizedGeometry.height)}`, ev.clientX, @@ -4767,7 +5483,8 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ state.originBounds, snap.frame, ); - updateFrameGeometry((current) => { + let nextGeometryById: FrameGeometryById | null = null; + updateFrameGeometryRefOnly((current) => { const next = { ...current }; resizedEntries.forEach((entry) => { next[entry.id] = { @@ -4775,8 +5492,12 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ ...entry.geometry, }; }); + nextGeometryById = next; return next; }); + if (nextGeometryById) { + applyResizedGeometryToDom(state.targetIds, nextGeometryById); + } setAlignmentGuides(snap.guides); showTransformFeedback( `${Math.round(snap.frame.width)} x ${Math.round(snap.frame.height)}`, @@ -4794,7 +5515,13 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ ); } if (state?.type === "resize" && state.hasMoved) { + // PERF9: the live resize above only wrote frameGeometryRef (ref-only, + // no setFrameGeometry per tick) — reconcile React state with the + // ref's final values here, exactly once, mirroring beginFrameDrag's + // matching handleMouseUp comment. const after = cloneFrameGeometryById(frameGeometryRef.current); + setFrameGeometry(after); + onGeometryChangeRef.current?.(after); onGeometryCommitRef.current?.( frameGeometryWithOverrides(after, state.originFrames), after, @@ -4809,10 +5536,13 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ activeId, finishDrag, getCurrentFrameEntries, + getResolvedMetadata, installDragListeners, + lockedScreenIdSet, onPick, showTransformFeedback, updateFrameGeometry, + updateFrameGeometryRefOnly, updateSelectedIds, ], ); @@ -4828,7 +5558,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ const beginRotate = useCallback( (id: string, e: React.MouseEvent) => { - if (e.button !== 0) return; + if (e.button !== 0 || lockedScreenIdSet.has(id)) return; e.preventDefault(); e.stopPropagation(); suppressNextPick.current = true; @@ -4863,6 +5593,18 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ rotateCursorDataUri(quantizeAngleTo8Buckets(originPointerAngle)), ); + // PERF9: same imperative-DOM discipline as beginFrameDrag/beginResize. + // Rotation never changes width/height, so only the frame shell's own + // transform (plus the glued selection box) needs a live write per + // tick — no screen-card/iframe involvement like resize needs. + const rotateFrameEl = surfaceRef.current?.querySelector( + `[data-frame-id="${CSS.escape(id)}"]`, + ); + const rotateSelectionBoxEl = + surfaceRef.current?.querySelector( + "[data-frame-selection-box]", + ); + const handleMouseMove = (ev: MouseEvent) => { const state = dragState.current; if (!state || state.type !== "rotate") return; @@ -4885,13 +5627,19 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ const raw = state.originRotation + pointerAngle - state.originPointerAngle; const rotation = ev.shiftKey ? Math.round(raw / 15) * 15 : raw; - updateFrameGeometry((current) => ({ + const roundedRotation = Math.round(rotation * 10) / 10; + updateFrameGeometryRefOnly((current) => ({ ...current, [state.frameId]: { ...state.originFrame, - rotation: Math.round(rotation * 10) / 10, + rotation: roundedRotation, }, })); + const rotateTransform = `rotate(${roundedRotation}deg)`; + if (rotateFrameEl) rotateFrameEl.style.transform = rotateTransform; + if (rotateSelectionBoxEl) { + rotateSelectionBoxEl.style.transform = rotateTransform; + } setDragCursor( rotateCursorDataUri(quantizeAngleTo8Buckets(pointerAngle)), ); @@ -4912,7 +5660,13 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ })); } if (state?.type === "rotate" && state.hasMoved) { + // PERF9: the live rotate above only wrote frameGeometryRef + // (ref-only, no setFrameGeometry per tick) — reconcile React state + // with the ref's final values here, exactly once, mirroring + // beginFrameDrag's matching handleMouseUp comment. const after = cloneFrameGeometryById(frameGeometryRef.current); + setFrameGeometry(after); + onGeometryChangeRef.current?.(after); onGeometryCommitRef.current?.( frameGeometryWithOverrides(after, { [state.frameId]: state.originFrame, @@ -4932,9 +5686,11 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ getCanvasPoint, getCurrentFrameEntries, installDragListeners, + lockedScreenIdSet, onPick, showTransformFeedback, updateFrameGeometry, + updateFrameGeometryRefOnly, updateSelectedIds, ], ); @@ -4979,6 +5735,29 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ rotateCursorDataUri(quantizeAngleTo8Buckets(originPointerAngle)), ); + // PERF9: same imperative-DOM discipline as beginRotate/beginResize. + // Unlike single rotate, orbiting around a shared center also changes + // each member's x/y, so every frame shell needs a live left/top write, + // not just transform. The individual PassiveSelectionBox outlines are + // intentionally left to the next real render (they briefly lag behind + // during the gesture, self-correcting at mouseup) — group rotate is a + // rarer gesture and threading a live update through each of those too + // is a larger change than this fix covers; the shared GroupSelectionBox + // (the interactive handle the user is actually dragging) is kept glued. + const groupRotateFrameEls = new Map(); + originEntries.forEach((entry) => { + const frameEl = surfaceRef.current?.querySelector( + `[data-frame-id="${CSS.escape(entry.id)}"]`, + ); + if (frameEl) groupRotateFrameEls.set(entry.id, frameEl); + }); + const groupRotateSelectionBoxEl = + surfaceRef.current?.querySelector( + "[data-frame-selection-box]", + ); + const groupFrameLabelHeight = + FRAME_LABEL_HEIGHT * chromeScaleFromZoom(zoomRef.current); + const handleMouseMove = (ev: MouseEvent) => { const state = dragState.current; if (!state || state.type !== "group-rotate") return; @@ -5012,7 +5791,8 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ state.groupCenter, delta, ); - updateFrameGeometry((current) => { + let nextGeometryById: FrameGeometryById | null = null; + updateFrameGeometryRefOnly((current) => { const next = { ...current }; rotated.forEach((entry) => { next[entry.id] = { @@ -5020,8 +5800,38 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ ...entry.geometry, }; }); + nextGeometryById = next; return next; }); + if (nextGeometryById) { + rotated.forEach((entry) => { + const geometry = nextGeometryById![entry.id]; + const frameEl = groupRotateFrameEls.get(entry.id); + if (!geometry || !frameEl) return; + const { left, top } = frameStyleLeftTop( + geometry, + groupFrameLabelHeight, + ); + frameEl.style.left = `${left}px`; + frameEl.style.top = `${top}px`; + frameEl.style.transform = geometry.rotation + ? `rotate(${geometry.rotation}deg)` + : ""; + }); + if (groupRotateSelectionBoxEl) { + const groupBoundsNow = getFrameGroupBounds(rotated); + if (groupBoundsNow) { + const { left, top } = frameStyleLeftTop({ + x: groupBoundsNow.left, + y: groupBoundsNow.top, + }); + groupRotateSelectionBoxEl.style.left = `${left}px`; + groupRotateSelectionBoxEl.style.top = `${top}px`; + groupRotateSelectionBoxEl.style.width = `${groupBoundsNow.width}px`; + groupRotateSelectionBoxEl.style.height = `${groupBoundsNow.height}px`; + } + } + } showTransformFeedback( `${Math.round(delta)}deg`, ev.clientX, @@ -5038,7 +5848,13 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ ); } if (state?.type === "group-rotate" && state.hasMoved) { + // PERF9: the live group-rotate above only wrote frameGeometryRef + // (ref-only, no setFrameGeometry per tick) — reconcile React state + // with the ref's final values here, exactly once, mirroring + // beginFrameDrag's matching handleMouseUp comment. const after = cloneFrameGeometryById(frameGeometryRef.current); + setFrameGeometry(after); + onGeometryChangeRef.current?.(after); onGeometryCommitRef.current?.( frameGeometryWithOverrides(after, state.originFrames), after, @@ -5057,12 +5873,14 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ installDragListeners, showTransformFeedback, updateFrameGeometry, + updateFrameGeometryRefOnly, ], ); const handleFrameClick = useCallback( (id: string, e: React.MouseEvent) => { e.stopPropagation(); + if (lockedScreenIdSet.has(id)) return; if (suppressNextPick.current) { suppressNextPick.current = false; return; @@ -5091,24 +5909,42 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ updateSelectedIds(() => [id]); onPick(id); }, - [activeId, onPick, updateSelectedDraftIds, updateSelectedIds], + [ + activeId, + lockedScreenIdSet, + onPick, + updateSelectedDraftIds, + updateSelectedIds, + ], ); const handleFrameDoubleClick = useCallback( (id: string, e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); + if (lockedScreenIdSet.has(id)) { + onEdit?.(id); + return; + } updateSelectedDraftIds(() => []); updateSelectedIds(() => [id]); onPick(id); onEdit?.(id); }, - [onEdit, onPick, updateSelectedDraftIds, updateSelectedIds], + [ + lockedScreenIdSet, + onEdit, + onPick, + updateSelectedDraftIds, + updateSelectedIds, + ], ); const beginDuplicateGesture = useCallback( (screen: ScreenFile, display: string, e: React.MouseEvent) => { - if (e.button !== 0 || !e.altKey) return; + if (e.button !== 0 || !e.altKey || lockedScreenIdSet.has(screen.id)) { + return; + } e.preventDefault(); e.stopPropagation(); duplicateCleanup.current?.(); @@ -5155,25 +5991,60 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ // duplicate on mouseup. setDragCursor(e.altKey ? "copy" : null); + // PERF: track the last committed canDuplicate/moved/cursor values in + // plain closure locals (not state) so the mousemove tick below can tell + // whether anything conditional actually changed without reading back + // through React state (which the imperative writes below intentionally + // stop keeping fresh every tick — see duplicatePreviewElRef). + let lastCanDuplicate = !!onDuplicate; + let lastMoved = false; + let lastCursor: string | null = e.altKey ? "copy" : null; + const handleMouseMove = (ev: MouseEvent) => { const dx = ev.clientX - origin.x; const dy = ev.clientY - origin.y; const moved = Math.hypot(dx, dy) >= DUPLICATE_DRAG_THRESHOLD; const rect = surfaceRef.current?.getBoundingClientRect(); - setDuplicatePreview({ - display, - x: rect ? ev.clientX - rect.left + 16 : ev.clientX, - y: rect ? ev.clientY - rect.top + 16 : ev.clientY, - width: previewWidth, - height: previewHeight, - // Live alt state, not just capability: if the user releases alt - // mid-drag the preview should visibly fall back to its "not armed" - // dashed/preview styling, matching that mouseup will then cancel - // the duplicate instead of creating one (see handleMouseUp below). - canDuplicate: !!onDuplicate && ev.altKey, - moved, - }); - setDragCursor(ev.altKey ? "copy" : null); + const x = rect ? ev.clientX - rect.left + 16 : ev.clientX; + const y = rect ? ev.clientY - rect.top + 16 : ev.clientY; + // Live alt state, not just capability: if the user releases alt + // mid-drag the preview should visibly fall back to its "not armed" + // dashed/preview styling, matching that mouseup will then cancel + // the duplicate instead of creating one (see handleMouseUp below). + const canDuplicate = !!onDuplicate && ev.altKey; + + // PERF9: the ghost's position is a direct DOM write every tick + // instead of a setDuplicatePreview (full re-render) — same + // "imperative now, commit state only when something conditional + // changes" discipline as the frame/draft drag paths above. Falls + // back to setDuplicatePreview itself if the node hasn't mounted yet + // (e.g. the very first tick right after mousedown, before React has + // committed the initial preview). + const el = duplicatePreviewElRef.current; + if (el) { + el.style.left = `${x}px`; + el.style.top = `${y}px`; + } + + if (!el || canDuplicate !== lastCanDuplicate || moved !== lastMoved) { + lastCanDuplicate = canDuplicate; + lastMoved = moved; + setDuplicatePreview({ + display, + x, + y, + width: previewWidth, + height: previewHeight, + canDuplicate, + moved, + }); + } + + const cursor = ev.altKey ? "copy" : null; + if (cursor !== lastCursor) { + lastCursor = cursor; + setDragCursor(cursor); + } }; const cleanupDuplicateGesture = () => { @@ -5247,6 +6118,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ finishDrag, getCurrentFrameEntries, installDragListeners, + lockedScreenIdSet, onDuplicate, onPick, ], @@ -5330,6 +6202,39 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ beginDraftCreation(creationTool, e); return; } + if ( + e.button === 0 && + tool === "move" && + !onFrame && + showBoardStaticPreview && + boardSurfaceRenderGeometry + ) { + const point = getCanvasPoint(e.clientX, e.clientY); + if (!geometryContainsPoint(boardSurfaceRenderGeometry, point)) { + const hit = [...boardStaticPrimitives] + .reverse() + .find((primitive) => + geometryContainsPoint( + getPrimitiveLowZoomHitRect(primitive, zoomRef.current), + point, + ), + ); + if (hit) { + e.preventDefault(); + e.stopPropagation(); + pendingStaticBoardSelectionRef.current = { + nodeId: hit.nodeId, + point, + }; + // Re-window the one live Alpine iframe around the clicked static + // primitive. DesignCanvas updates its content offset in place, so + // the iframe node/srcdoc/runtime survive; the effect above selects + // the same node through that live bridge after the offset settles. + setBoardSurfaceFocusPoint(point); + return; + } + } + } if (e.button === 0 && !onFrame) { beginMarquee(e); } @@ -5342,10 +6247,13 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ beginPenNodeCreation, beginVectorAnchorDrag, beginVectorHandleDrag, + boardStaticPrimitives, + boardSurfaceRenderGeometry, claimKeyboardFocus, getCanvasPoint, localActiveTool, setAltHoverMeasurement, + showBoardStaticPreview, toggleVectorNodeType, vectorEdit, ], @@ -5576,6 +6484,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ // it's an acceptable trade for keeping gesture start/end render-free. const markWheelGestureActive = useCallback(() => { if (wheelGestureActiveRef.current) return; + cancelPendingStaticBoardSelection(); wheelGestureActiveRef.current = true; setWheelCameraGestureActive(true); const surface = surfaceRef.current; @@ -5594,7 +6503,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ element.style.pointerEvents = "none"; }); wheelGestureMutedElementsRef.current = muted; - }, []); + }, [cancelPendingStaticBoardSelection]); const flushPendingWheelGesture = useCallback(() => { wheelGestureFrameRef.current = null; @@ -6164,11 +7073,12 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ const canvasFrames = useMemo(() => { const cache = canvasFrameEntryCacheRef.current; const nextIds = new Set(); - const next = screens.map((screen, index) => { + const next = renderedScreens.map((screen) => { nextIds.add(screen.id); const metadata = getResolvedMetadata(screen); const geometry = - frameGeometry[screen.id] ?? getInitialFrameGeometry(index, metadata); + frameGeometry[screen.id] ?? + getInitialFrameGeometry(screenIndexById.get(screen.id) ?? 0, metadata); const prior = cache.get(screen.id); if ( prior && @@ -6192,7 +7102,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ } pruneResolvedMetadataCache(resolvedMetadataCacheRef.current, nextIds); return next; - }, [frameGeometry, getResolvedMetadata, screens]); + }, [frameGeometry, getResolvedMetadata, renderedScreens, screenIndexById]); // Interaction-protected screens are never eligible for LRU eviction. Active // screen/frame selection are the primary signals; layer selection, native // file-drop targeting, gradient editing, and in-flight frame transforms are @@ -6441,6 +7351,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
+ {showBoardStaticPreview && + boardFrameGeometry && + boardStaticPreviewViewport && + boardStaticPreviewContent ? ( +