From be57ee4504b402b755d6a9906896ca7485b885fa Mon Sep 17 00:00:00 2001 From: Steve Sewell Date: Thu, 9 Jul 2026 17:53:25 -0700 Subject: [PATCH 1/8] feat: improve design workflows and app reliability --- .../.codex-plugin/plugin.json | 2 +- .../skills/visual-edit/SKILL.md | 14 + .changeset/bright-local-react-previews.md | 5 + .changeset/calm-content-flushes.md | 5 + .changeset/fresh-authoritative-reverts.md | 5 + .changeset/guard-recap-ssr-failures.md | 5 + .../lost-turns-recover-lost-handoffs.md | 7 + .changeset/rare-bridges-reconnect.md | 5 + .changeset/strong-local-source-writes.md | 5 + .claude/launch.json | 16 + ...roduction-agent.chain-continuation.spec.ts | 84 +- packages/core/src/agent/production-agent.ts | 94 +- packages/core/src/agent/run-manager.spec.ts | 51 +- packages/core/src/agent/run-manager.ts | 35 +- .../run-store.foreground-self-chain.spec.ts | 43 + packages/core/src/agent/run-store.spec.ts | 102 ++ packages/core/src/agent/run-store.ts | 80 ++ .../cli/design-connect-bridge-filters.spec.ts | 251 ++++ packages/core/src/cli/design-connect.spec.ts | 116 ++ packages/core/src/cli/design-connect.ts | 584 ++++++-- packages/core/src/cli/recap.io.spec.ts | 6 + packages/core/src/cli/recap.spec.ts | 48 +- packages/core/src/cli/recap.ts | 16 +- packages/core/src/cli/skills.ts | 14 + .../useCollabReconcile.concurrent.spec.ts | 60 + .../useCollabReconcile.ts | 25 +- .../core/src/collab/awareness-store.spec.ts | 4 + packages/core/src/collab/awareness-store.ts | 35 +- packages/core/src/collab/index.ts | 4 + packages/core/src/server/agent-chat-plugin.ts | 90 +- packages/core/src/server/ssr-handler.spec.ts | 32 + packages/core/src/server/ssr-handler.ts | 7 + skills/visual-edit/SKILL.md | 14 + ...top-popover-sits-closer-to-the-menu-bar.md | 6 + ...ress-stays-visible-until-the-clip-opens.md | 6 + ...er-lowers-your-microphone-volume-for-ot.md | 6 + .../clips/desktop/src-tauri/src/clips/mod.rs | 40 +- .../desktop/src-tauri/src/native_screen.rs | 2 +- .../desktop/src-tauri/src/system_audio.rs | 124 +- .../desktop/src-tauri/src/whisper_speech.rs | 156 ++- templates/clips/desktop/src/app.tsx | 14 +- .../src/hooks/useMeetingTranscription.ts | 9 +- templates/clips/desktop/src/lib/recorder.ts | 13 +- .../src/lib/transcription-engine.test.ts | 53 +- .../desktop/src/lib/transcription-engine.ts | 10 +- .../clips/desktop/src/lib/voice-dictation.ts | 3 + templates/clips/desktop/src/styles.css | 9 +- templates/content/AGENTS.md | 13 +- .../actions/_builder-docs-client.test.ts | 22 + .../content/actions/_document-flush.test.ts | 151 ++ templates/content/actions/_document-flush.ts | 134 +- .../actions/_notion-action-utils.test.ts | 20 + .../content/actions/_notion-action-utils.ts | 15 + .../actions/create-and-link-notion-page.ts | 2 + templates/content/actions/link-notion-page.ts | 2 + .../actions/notion-flush-actions.test.ts | 114 ++ templates/content/actions/pull-document.ts | 12 +- templates/content/actions/pull-notion-page.ts | 2 + templates/content/actions/push-notion-page.ts | 10 + .../actions/resolve-notion-sync-conflict.ts | 2 + .../editor/DocumentEditor.layout.test.ts | 14 + .../app/components/editor/DocumentEditor.tsx | 99 +- .../editor/VisualEditor.markdown.test.ts | 191 +++ .../app/components/editor/VisualEditor.tsx | 34 +- templates/content/app/hooks/use-notion.ts | 12 +- templates/content/app/i18n-data.ts | 18 + templates/content/app/i18n/zh-TW.ts | 1 + ...es-and-version-restores-preserve-docume.md | 6 + .../content/server/lib/notion-sync.spec.ts | 61 +- templates/content/server/lib/notion-sync.ts | 144 +- .../.agents/skills/visual-edit/SKILL.md | 11 + .../bridge/editor-chrome.generated.ts | 472 ++++++- .../.generated/bridge/hit-test.generated.ts | 3 +- .../bridge/motion-preview.generated.ts | 19 +- .../.generated/bridge/sample.generated.ts | 1 + templates/design/AGENTS.md | 21 +- .../add-localhost-screens.action.spec.ts | 138 +- .../design/actions/add-localhost-screens.ts | 104 +- ...ply-component-prop-edit.interleave.spec.ts | 28 +- .../actions/apply-component-prop-edit.ts | 36 +- .../grant-localhost-write-consent.spec.ts | 12 +- .../actions/grant-localhost-write-consent.ts | 7 +- .../design/actions/open-visual-edit.spec.ts | 20 + .../actions/preview-source-edit.spec.ts | 78 ++ .../design/actions/preview-source-edit.ts | 11 +- .../design/actions/write-local-file.spec.ts | 44 + templates/design/actions/write-local-file.ts | 26 +- .../DesignCanvas.bridge-restart.test.tsx | 191 +++ .../DesignCanvas.embedded-frame-live.test.tsx | 150 ++ .../DesignCanvas.refreshBoundary.test.ts | 13 +- .../app/components/design/DesignCanvas.tsx | 449 +++++- .../design/EditPanel.componentProps.spec.ts | 80 ++ .../design/EditPanel.document-colors.test.ts | 87 ++ .../design/EditPanel.fill-layers.test.ts | 80 ++ .../design/EditPanel.gradient.test.ts | 39 + .../design/EditPanel.inspectorHelpers.spec.ts | 51 + .../app/components/design/EditPanel.tsx | 19 +- .../app/components/design/LayersPanel.test.ts | 27 + .../app/components/design/LayersPanel.tsx | 43 +- .../design/LocalhostWriteConsentDialog.tsx | 2 - .../MultiScreenCanvas.gestures.test.tsx | 639 +++++++++ .../MultiScreenCanvas.primitives.test.ts | 197 +++ .../components/design/MultiScreenCanvas.tsx | 1164 ++++++++++++++-- .../components/design/ReviewPanel.test.tsx | 162 +++ .../app/components/design/ReviewPanel.tsx | 25 +- .../app/components/design/TokensPanel.test.ts | 50 + .../app/components/design/TokensPanel.tsx | 112 +- .../design/bridge/bridge.guard.spec.ts | 1221 ++++++++++++++++- .../design/bridge/editor-chrome.bridge.ts | 769 ++++++++++- .../design/bridge/hit-test.bridge.ts | 7 +- .../design/bridge/motion-preview.bridge.ts | 29 +- .../components/design/bridge/sample.bridge.ts | 7 + .../design/design-canvas/embedded-frame.ts | 9 +- .../edit-panel/code-inspect-helpers.spec.ts | 211 +++ .../edit-panel/code-inspect-helpers.tsx | 22 +- .../edit-panel/component-section.spec.ts | 54 + .../design/edit-panel/component-section.tsx | 168 ++- .../design/edit-panel/document-colors.ts | 16 +- .../effects-properties.mixed.test.ts | 143 ++ .../design/edit-panel/effects-properties.tsx | 576 ++++---- .../edit-panel/element-classification.test.ts | 60 +- .../edit-panel/element-classification.ts | 20 +- .../edit-panel/element-identity.test.ts | 105 ++ .../design/edit-panel/element-identity.ts | 19 +- .../design/edit-panel/field-primitives.tsx | 22 +- .../edit-panel/fill-gradient-helpers.spec.ts | 153 +++ .../edit-panel/fill-gradient-helpers.ts | 212 ++- .../design/edit-panel/fill-properties.tsx | 192 ++- .../edit-panel/inspector-controls.spec.ts | 62 + .../design/edit-panel/inspector-controls.tsx | 50 +- .../interaction-state-helpers.spec.ts | 104 ++ .../edit-panel/layout-properties.test.tsx | 113 ++ .../design/edit-panel/layout-properties.tsx | 99 +- .../edit-panel/panel-primitives.spec.ts | 99 ++ .../design/edit-panel/panel-primitives.tsx | 270 ++-- .../position-helpers.stroke.test.ts | 121 ++ .../design/edit-panel/position-helpers.ts | 54 +- .../position-layout-properties.test.ts | 105 ++ .../edit-panel/position-layout-properties.tsx | 126 +- .../edit-panel/selection-helpers.spec.ts | 202 +++ .../design/edit-panel/selection-helpers.ts | 77 ++ .../design/edit-panel/stroke-properties.tsx | 33 +- .../design/edit-panel/style-options.test.ts | 62 + .../design/edit-panel/style-options.ts | 7 + .../edit-panel/typography-helpers.spec.ts | 213 +++ .../design/edit-panel/typography-helpers.ts | 56 + .../edit-panel/typography-properties.tsx | 89 +- .../AutoLayoutMatrix.interaction.test.tsx | 72 + .../inspector/AutoLayoutMatrix.test.tsx | 63 + .../design/inspector/AutoLayoutMatrix.tsx | 60 + .../DesignColorPicker.gesture.test.ts | 80 ++ .../inspector/DesignColorPicker.modes.test.ts | 169 +++ .../design/inspector/DesignColorPicker.tsx | 256 +++- .../GradientEditor.interaction.test.tsx | 290 ++++ .../design/inspector/GradientEditor.tsx | 67 +- .../ShaderFillsPanel.commit.test.tsx | 222 +++ .../design/inspector/ShaderFillsPanel.tsx | 77 +- .../app/components/design/inspector/index.ts | 1 + .../design/motion-preview-bridge.test.ts | 58 + .../design/multi-screen/board-surface-html.ts | 157 +++ .../design/multi-screen/overview-layout.ts | 184 +++ .../primitive-drop-target.test.ts | 277 ++++ .../multi-screen/primitive-drop-target.ts | 429 +++++- .../components/design/multi-screen/types.ts | 15 +- .../design/app/components/design/types.ts | 17 + .../components/editor/PromptDialog.test.tsx | 211 +++ .../app/components/editor/PromptDialog.tsx | 66 +- .../visual-editor/CanvasCommentPins.test.tsx | 260 ++++ .../visual-editor/CanvasCommentPins.tsx | 136 +- .../visual-editor/DrawOverlay.test.tsx | 68 + .../components/visual-editor/DrawOverlay.tsx | 105 +- .../app/hooks/useDesignHotkeys.test.tsx | 19 + .../design/app/hooks/useDesignHotkeys.ts | 13 +- templates/design/app/i18n-data.ts | 288 ++++ templates/design/app/i18n/zh-TW.ts | 15 + .../pages/DesignEditor.collabRebase.test.ts | 71 + .../DesignEditor.reparentPosition.test.ts | 130 ++ .../app/pages/DesignEditor.selection.test.ts | 99 ++ templates/design/app/pages/DesignEditor.tsx | 831 +++++++++-- .../app/pages/DesignEditor.zoom.test.ts | 86 +- .../design-editor/alpine-inline-pilot.spec.ts | 242 ++++ .../design-editor/code-layer-state.spec.ts | 274 ++++ .../pages/design-editor/code-layer-state.ts | 90 +- .../app/pages/design-editor/editor-session.ts | 33 + .../pages/design-editor/overview-camera.ts | 50 +- .../app/pages/design-editor/pending-edits.ts | 245 ++++ .../react-semantic-handoff.test.ts | 595 ++++++++ .../design-editor/react-semantic-handoff.ts | 440 ++++++ .../pages/design-editor/selection-state.ts | 32 + ...-visible-and-nest-correctly-across-the-.md | 6 + ...angles-now-expose-full-auto-layout-cont.md | 6 + ...uto-layout-now-participate-correctly-in.md | 6 + ...w-keep-exact-source-provenance-and-supp.md | 6 + ...iting-now-loads-reliably-and-shows-live.md | 6 + templates/design/e2e/canvas-tools.spec.ts | 316 +++++ templates/design/server/plugins/db.ts | 41 + templates/design/shared/board-file.ts | 15 +- templates/design/shared/canvas-math.test.ts | 128 ++ templates/design/shared/canvas-math.ts | 167 ++- templates/design/shared/code-layer.ts | 117 +- .../shared/design-source-capabilities.ts | 3 +- templates/design/shared/source-mode.ts | 1 + .../plan/app/components/plan/CanvasArea.tsx | 26 +- ...-now-keeps-zoom-fixed-even-at-the-edges.md | 6 + .../plan/e2e/canvas-interactions.spec.ts | 73 +- .../context/DeckContext.persistence.test.ts | 467 ++++++- templates/slides/app/context/DeckContext.tsx | 381 ++++- ...going-permanently-stale-during-a-long-a.md | 6 + 208 files changed, 20791 insertions(+), 1841 deletions(-) create mode 100644 .changeset/bright-local-react-previews.md create mode 100644 .changeset/calm-content-flushes.md create mode 100644 .changeset/fresh-authoritative-reverts.md create mode 100644 .changeset/guard-recap-ssr-failures.md create mode 100644 .changeset/lost-turns-recover-lost-handoffs.md create mode 100644 .changeset/rare-bridges-reconnect.md create mode 100644 .changeset/strong-local-source-writes.md create mode 100644 templates/clips/changelog/2026-07-09-desktop-popover-sits-closer-to-the-menu-bar.md create mode 100644 templates/clips/changelog/2026-07-09-desktop-recording-optimization-progress-stays-visible-until-the-clip-opens.md create mode 100644 templates/clips/changelog/2026-07-09-meeting-notes-no-longer-lowers-your-microphone-volume-for-ot.md create mode 100644 templates/content/actions/_document-flush.test.ts create mode 100644 templates/content/actions/notion-flush-actions.test.ts create mode 100644 templates/content/changelog/2026-07-09-notion-conflict-choices-and-version-restores-preserve-docume.md create mode 100644 templates/design/actions/preview-source-edit.spec.ts create mode 100644 templates/design/app/components/design/DesignCanvas.bridge-restart.test.tsx create mode 100644 templates/design/app/components/design/DesignCanvas.embedded-frame-live.test.tsx create mode 100644 templates/design/app/components/design/ReviewPanel.test.tsx create mode 100644 templates/design/app/components/design/TokensPanel.test.ts create mode 100644 templates/design/app/components/design/edit-panel/code-inspect-helpers.spec.ts create mode 100644 templates/design/app/components/design/edit-panel/component-section.spec.ts create mode 100644 templates/design/app/components/design/edit-panel/effects-properties.mixed.test.ts create mode 100644 templates/design/app/components/design/edit-panel/element-identity.test.ts create mode 100644 templates/design/app/components/design/edit-panel/fill-gradient-helpers.spec.ts create mode 100644 templates/design/app/components/design/edit-panel/inspector-controls.spec.ts create mode 100644 templates/design/app/components/design/edit-panel/interaction-state-helpers.spec.ts create mode 100644 templates/design/app/components/design/edit-panel/layout-properties.test.tsx create mode 100644 templates/design/app/components/design/edit-panel/panel-primitives.spec.ts create mode 100644 templates/design/app/components/design/edit-panel/position-helpers.stroke.test.ts create mode 100644 templates/design/app/components/design/edit-panel/position-layout-properties.test.ts create mode 100644 templates/design/app/components/design/edit-panel/selection-helpers.spec.ts create mode 100644 templates/design/app/components/design/edit-panel/style-options.test.ts create mode 100644 templates/design/app/components/design/edit-panel/typography-helpers.spec.ts create mode 100644 templates/design/app/components/design/inspector/AutoLayoutMatrix.interaction.test.tsx create mode 100644 templates/design/app/components/design/inspector/GradientEditor.interaction.test.tsx create mode 100644 templates/design/app/components/design/inspector/ShaderFillsPanel.commit.test.tsx create mode 100644 templates/design/app/components/design/multi-screen/primitive-drop-target.test.ts create mode 100644 templates/design/app/components/editor/PromptDialog.test.tsx create mode 100644 templates/design/app/components/visual-editor/CanvasCommentPins.test.tsx create mode 100644 templates/design/app/pages/DesignEditor.reparentPosition.test.ts create mode 100644 templates/design/app/pages/design-editor/alpine-inline-pilot.spec.ts create mode 100644 templates/design/app/pages/design-editor/code-layer-state.spec.ts create mode 100644 templates/design/app/pages/design-editor/react-semantic-handoff.test.ts create mode 100644 templates/design/app/pages/design-editor/react-semantic-handoff.ts create mode 100644 templates/design/changelog/2026-07-09-board-shapes-now-stay-visible-and-nest-correctly-across-the-.md create mode 100644 templates/design/changelog/2026-07-09-empty-frames-and-rectangles-now-expose-full-auto-layout-cont.md create mode 100644 templates/design/changelog/2026-07-09-layers-dropped-into-auto-layout-now-participate-correctly-in.md create mode 100644 templates/design/changelog/2026-07-09-local-react-layers-now-keep-exact-source-provenance-and-supp.md create mode 100644 templates/design/changelog/2026-07-09-local-react-visual-editing-now-loads-reliably-and-shows-live.md create mode 100644 templates/plan/changelog/2026-07-09-canvas-panning-now-keeps-zoom-fixed-even-at-the-edges.md create mode 100644 templates/slides/changelog/2026-07-09-fixed-the-slide-rail-going-permanently-stale-during-a-long-a.md 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/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/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/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/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/packages/core/src/agent/production-agent.chain-continuation.spec.ts b/packages/core/src/agent/production-agent.chain-continuation.spec.ts index 16c61e29df..31e34e947f 100644 --- a/packages/core/src/agent/production-agent.chain-continuation.spec.ts +++ b/packages/core/src/agent/production-agent.chain-continuation.spec.ts @@ -256,45 +256,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), ); - // …and the finished chunk is errored too, with the failure written as its - // diag stage (the only forensics channel — bg logs are unreadable). + // 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"), + ); + // …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 +415,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 +434,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 +449,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 +477,21 @@ 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", + ); }); }); diff --git a/packages/core/src/agent/production-agent.ts b/packages/core/src/agent/production-agent.ts index d4580aa17c..272ec1770c 100644 --- a/packages/core/src/agent/production-agent.ts +++ b/packages/core/src/agent/production-agent.ts @@ -5046,12 +5046,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 @@ -5316,22 +5330,74 @@ export async function chainServerDrivenContinuation(opts: { } } 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") + // 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: 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 nextRunId=${nextRunId} ${ + lastDispatchErr instanceof Error + ? lastDispatchErr.message + : String(lastDispatchErr) + }`, + ) + .catch(() => {}); + console.error( + "[agent-chat] background continuation dispatch exhausted its retry budget; 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/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/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/server/agent-chat-plugin.ts b/packages/core/src/server/agent-chat-plugin.ts index d17f4e7655..48886588b3 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,66 @@ 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 { + 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/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/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/changelog/2026-07-09-desktop-popover-sits-closer-to-the-menu-bar.md b/templates/clips/changelog/2026-07-09-desktop-popover-sits-closer-to-the-menu-bar.md new file mode 100644 index 0000000000..79c8e4a84d --- /dev/null +++ b/templates/clips/changelog/2026-07-09-desktop-popover-sits-closer-to-the-menu-bar.md @@ -0,0 +1,6 @@ +--- +type: improved +date: 2026-07-09 +--- + +The Clips desktop popover now sits closer to its menu-bar icon. diff --git a/templates/clips/changelog/2026-07-09-desktop-recording-optimization-progress-stays-visible-until-the-clip-opens.md b/templates/clips/changelog/2026-07-09-desktop-recording-optimization-progress-stays-visible-until-the-clip-opens.md new file mode 100644 index 0000000000..26cefeb76f --- /dev/null +++ b/templates/clips/changelog/2026-07-09-desktop-recording-optimization-progress-stays-visible-until-the-clip-opens.md @@ -0,0 +1,6 @@ +--- +type: fixed +date: 2026-07-09 +--- + +Desktop recording optimization progress stays visible from Stop until the finished clip opens. diff --git a/templates/clips/changelog/2026-07-09-meeting-notes-no-longer-lowers-your-microphone-volume-for-ot.md b/templates/clips/changelog/2026-07-09-meeting-notes-no-longer-lowers-your-microphone-volume-for-ot.md new file mode 100644 index 0000000000..af273f0204 --- /dev/null +++ b/templates/clips/changelog/2026-07-09-meeting-notes-no-longer-lowers-your-microphone-volume-for-ot.md @@ -0,0 +1,6 @@ +--- +type: fixed +date: 2026-07-09 +--- + +Meeting Notes no longer lowers your microphone volume for other people in Zoom, Meet, or Teams. diff --git a/templates/clips/desktop/src-tauri/src/clips/mod.rs b/templates/clips/desktop/src-tauri/src/clips/mod.rs index 5105ecc8a9..043bc4cb52 100644 --- a/templates/clips/desktop/src-tauri/src/clips/mod.rs +++ b/templates/clips/desktop/src-tauri/src/clips/mod.rs @@ -46,6 +46,15 @@ const REGION_GUIDES_LABEL: &str = "region-guides"; const REGION_GUIDE_EDITOR_LABEL: &str = "region-guide-editor"; const REGION_RECORD_BORDER_LABEL: &str = "region-record-border"; const ONBOARDING_LABEL: &str = "onboarding"; +const OVERLAY_LABELS: &[&str] = &[ + COUNTDOWN_LABEL, + TOOLBAR_LABEL, + BUBBLE_LABEL, + FINALIZING_LABEL, + FLOW_BAR_LABEL, + REGION_GUIDES_LABEL, + REGION_RECORD_BORDER_LABEL, +]; const ONBOARDING_WIDTH_LOGICAL: f64 = 560.0; const ONBOARDING_HEIGHT_LOGICAL: f64 = 640.0; @@ -952,19 +961,21 @@ pub async fn set_bubble_capture_excluded(app: AppHandle, excluded: bool) -> 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/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..545d3ce805 --- /dev/null +++ b/templates/content/actions/_document-flush.test.ts @@ -0,0 +1,151 @@ +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({ + 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(); + }); +}); diff --git a/templates/content/actions/_document-flush.ts b/templates/content/actions/_document-flush.ts index cce844c6b1..424d149713 100644 --- a/templates/content/actions/_document-flush.ts +++ b/templates/content/actions/_document-flush.ts @@ -1,62 +1,162 @@ +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): { + visible?: boolean; + user?: { email?: unknown }; +} | null { + try { + return JSON.parse(state) as { + visible?: boolean; + user?: { email?: unknown }; + }; + } catch { + return null; + } +} + +function awarenessSessionEmail(entry: { + clientId: number; + state: string; +}): string | null { + if (entry.clientId === AGENT_CLIENT_ID) return null; + const state = parseAwarenessState(entry.state); + if (!state || state.visible === false || !state.user) return null; + const email = state.user.email; + return typeof email === "string" && email.trim() ? email.trim() : null; +} + +function isActiveHumanAwareness(entry: { + clientId: number; + state: string; +}): boolean { + if (entry.clientId === AGENT_CLIENT_ID) return false; + const state = parseAwarenessState(entry.state); + return !!state?.user && state.visible !== false; +} + 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. Only require a handshake while + // at least one non-expired human awareness row says an editor is actually + // open; otherwise SQL is the best durable snapshot and waiting would make + // every previously-opened document stall for four seconds. + const awarenessRows = await loadAwarenessRowsStrict(args.documentId); + const activeHumanRows = awarenessRows.filter(isActiveHumanAwareness); + if (activeHumanRows.length === 0) return; + const activeSessionEmails = activeHumanRows + .map(awarenessSessionEmail) + .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) { + 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) { + 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) { + 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..cfd73d2227 100644 --- a/templates/content/app/components/editor/DocumentEditor.layout.test.ts +++ b/templates/content/app/components/editor/DocumentEditor.layout.test.ts @@ -179,6 +179,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..a9de6f8538 100644 --- a/templates/content/app/components/editor/DocumentEditor.tsx +++ b/templates/content/app/components/editor/DocumentEditor.tsx @@ -813,6 +813,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 +1034,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 +1060,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: "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: "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: "error", + error: + error instanceof Error + ? error.message + : t("editor.liveDocumentSaveBeforeSyncFailed"), + }), }).catch(() => {}); } } @@ -1082,7 +1129,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/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..6f9de70988 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; @@ -1068,6 +1314,7 @@ export const editorChromeBridgeScript: string = `"use strict"; } var selectedEl = null; var hoveredEl = null; + var lastHoverInfoPostedEl = null; var passiveSelectionEls = []; var passiveSelectionOverlays = []; var activeMarqueeSelection = null; @@ -2304,6 +2551,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 = ""; @@ -2935,6 +3224,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) @@ -3697,7 +4024,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"; } @@ -4334,8 +4662,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 +4678,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); @@ -4394,7 +4726,8 @@ export const editorChromeBridgeScript: string = `"use strict"; dropMode: target.dropMode || "flow-insert", sourceRect: rectInfoForElement(el), anchorRect: rectInfoForElement(target.anchor), - payload: getElementInfo(el) + payload: getElementInfo(el), + anchorPayload: getElementInfo(target.anchor) }, "*" ); @@ -4821,6 +5154,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,7 +5206,7 @@ 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; @@ -4904,6 +5254,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(); @@ -5707,6 +6059,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 +6369,7 @@ export const editorChromeBridgeScript: string = `"use strict"; scheduleSpacingHoverClear(e); } hideMeasurements(); + lastHoverInfoPostedEl = null; return; } if (hoveredEl && hoveredEl.closest("[data-agent-native-text-editing]")) @@ -6036,11 +6404,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 ); @@ -6122,6 +6493,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; @@ -6380,6 +6758,45 @@ export const editorChromeBridgeScript: string = `"use strict"; 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 +6931,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/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..65fb10be33 100644 --- a/templates/design/AGENTS.md +++ b/templates/design/AGENTS.md @@ -202,12 +202,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 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..58b6797fb2 100644 --- a/templates/design/actions/add-localhost-screens.ts +++ b/templates/design/actions/add-localhost-screens.ts @@ -283,6 +283,32 @@ function metadataForFile( return isRecord(legacy) ? legacy : undefined; } +/** + * True when `err` is a UNIQUE / PRIMARY KEY constraint violation from either + * supported driver (Postgres 23505, SQLite SQLITE_CONSTRAINT_UNIQUE / + * _PRIMARYKEY). Mirrors `@agent-native/core`'s internal `isUniqueViolation` + * (kept local here rather than requiring a core export change for one insert + * race — see `ensureDesignFilesUniqueIndex` in server/plugins/db.ts for the + * index this is meant to catch a conflict against). + */ +function isUniqueConstraintViolation(err: unknown): boolean { + const e = err as { code?: unknown; message?: unknown } | null; + if (e?.code === "23505") return true; + const code = String(e?.code ?? ""); + if ( + code === "SQLITE_CONSTRAINT_PRIMARYKEY" || + code === "SQLITE_CONSTRAINT_UNIQUE" + ) { + return true; + } + const msg = String(e?.message ?? "").toLowerCase(); + return ( + msg.includes("unique constraint") || + msg.includes("primary key constraint") || + msg.includes("duplicate key") + ); +} + function metadataMatchesRoute( metadata: Record | undefined, args: { connectionId: string; routeId: string; path: string; url: string }, @@ -488,6 +514,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 +543,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 +660,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 +699,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 +765,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..1329562b3a 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()); diff --git a/templates/design/actions/apply-component-prop-edit.ts b/templates/design/actions/apply-component-prop-edit.ts index 1fbc3397e7..5f6fa5012a 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. */ @@ -41,7 +42,6 @@ import { writeInlineSourceFile, type SourceWorkspaceFile, } from "../server/source-workspace.js"; -import { resolveSourceCapabilities } from "../shared/capability-resolver.js"; import { applyVisualEdit, buildCodeLayerProjection, @@ -56,7 +56,6 @@ 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"; @@ -174,8 +173,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 +233,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 +255,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 = [ diff --git a/templates/design/actions/grant-localhost-write-consent.spec.ts b/templates/design/actions/grant-localhost-write-consent.spec.ts index 918a847f83..0786511eea 100644 --- a/templates/design/actions/grant-localhost-write-consent.spec.ts +++ b/templates/design/actions/grant-localhost-write-consent.spec.ts @@ -8,7 +8,8 @@ * - It throws when the connection row is missing. * - It throws when the connection has no rootPath. * - It upserts a grant (insert on first call, update on second). - * - The returned bridgeToken equals the one stored on the connection. + * - The unrestricted bridgeToken is persisted server-side but never returned + * to the browser caller. */ import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -107,7 +108,7 @@ beforeEach(() => { // --------------------------------------------------------------------------- describe("grant-localhost-write-consent", () => { - it("uses bridgeToken from the connection row (not a minted token)", async () => { + it("persists the connection bridgeToken without returning it to the browser", async () => { mockConnection = { id: "conn_1", ownerEmail: "user@example.com", @@ -120,8 +121,8 @@ describe("grant-localhost-write-consent", () => { connectionId: "conn_1", }); - expect(result.bridgeToken).toBe("bridge_real_token_xyz"); - // Ensure this is the same value persisted in the grant row. + expect(result).not.toHaveProperty("bridgeToken"); + // The server-side grant still retains the token write-local-file needs. expect(insertedValues?.bridgeToken).toBe("bridge_real_token_xyz"); }); @@ -207,7 +208,7 @@ describe("grant-localhost-write-consent", () => { expect(result.grantId).toBe("existing_grant_id"); }); - it("returns rootPath and grantedUntil alongside bridgeToken", async () => { + it("returns only non-secret grant metadata", async () => { mockConnection = { id: "conn_1", ownerEmail: "user@example.com", @@ -223,6 +224,7 @@ describe("grant-localhost-write-consent", () => { const after = Date.now(); expect(result.rootPath).toBe("/home/user/my-app"); + expect(result).not.toHaveProperty("bridgeToken"); const grantedUntilMs = new Date(result.grantedUntil).getTime(); const eightHoursMs = 8 * 60 * 60 * 1000; expect(grantedUntilMs).toBeGreaterThanOrEqual(before + eightHoursMs); diff --git a/templates/design/actions/grant-localhost-write-consent.ts b/templates/design/actions/grant-localhost-write-consent.ts index 461f879ad7..b9b1622f53 100644 --- a/templates/design/actions/grant-localhost-write-consent.ts +++ b/templates/design/actions/grant-localhost-write-consent.ts @@ -19,7 +19,7 @@ export default defineAction({ "for a specific design + localhost connection. The grant scopes writes to the " + "connection's rootPath and expires after 8 hours. Requires editor access on the design. " + "The LocalhostWriteConsentDialog calls this after the user clicks 'Allow writes'.", - // This action mints/returns the real bridgeToken that unlocks the local + // This action persists the real bridgeToken that unlocks the local // bridge's unrestricted /read-file, /write-file, and /apply-edit. It must // only ever be triggered by a human clicking "Allow writes" in // LocalhostWriteConsentDialog — never by the agent itself, or an agent @@ -27,7 +27,9 @@ export default defineAction({ // human consent model entirely. `agentTool: false` hides it from every // agent tool surface (in-app assistant, MCP, A2A) while keeping it // callable from the frontend via `callAction` / `useActionMutation` and - // the raw `/_agent-native/actions/grant-localhost-write-consent` route. + // the raw `/_agent-native/actions/grant-localhost-write-consent` route. The + // token intentionally stays server-side: browser callers only need grant + // metadata because write-local-file adds bridge authentication itself. agentTool: false, schema: z.object({ designId: z.string().describe("Design ID."), @@ -124,7 +126,6 @@ export default defineAction({ return { grantId: existing?.id ?? grantId, - bridgeToken, rootPath, grantedUntil, }; diff --git a/templates/design/actions/open-visual-edit.spec.ts b/templates/design/actions/open-visual-edit.spec.ts index d2aed4c80c..e5f8293dcb 100644 --- a/templates/design/actions/open-visual-edit.spec.ts +++ b/templates/design/actions/open-visual-edit.spec.ts @@ -141,4 +141,24 @@ describe("open-visual-edit", () => { }), ); }); + + it("accepts the complete capability list emitted by design connect route discovery", () => { + const parsed = action.schema.safeParse({ + designId: "design_1", + devServerUrl: "http://localhost:5173", + capabilities: [ + { operation: "select", status: "available" }, + { operation: "resolveNodeToFile", status: "available" }, + { operation: "readFile", status: "available" }, + { operation: "applyEdit", status: "available" }, + { operation: "writeFile", status: "available" }, + { operation: "captureSnapshot", status: "available" }, + { operation: "captureState", status: "available" }, + { operation: "listFiles", status: "available" }, + ], + paths: ["/"], + }); + + expect(parsed.success).toBe(true); + }); }); diff --git a/templates/design/actions/preview-source-edit.spec.ts b/templates/design/actions/preview-source-edit.spec.ts new file mode 100644 index 0000000000..d9d88bdce8 --- /dev/null +++ b/templates/design/actions/preview-source-edit.spec.ts @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// --------------------------------------------------------------------------- +// preview-source-edit.spec.ts +// +// Regression test for an inverted `nextVersionHash`: the action used to +// return `undefined` exactly when the edit DID change the content (the case +// where a caller actually needs the post-edit hash to pass into +// apply-source-edit's `expectedVersionHash` and close the preview -> apply +// race), and returned the unchanged `currentVersionHash` when nothing +// changed (where a "next" hash adds no value — it's identical to "current"). +// Fixed to compute the real post-edit hash via the same `sourceContentHash` +// convention apply-source-edit.ts uses (server/source-workspace.ts's +// writeInlineSourceFile hashes the persisted content the same way), and to +// omit it only when there is genuinely no new content to hash. +// --------------------------------------------------------------------------- + +const mocks = vi.hoisted(() => ({ + resolveSourceWorkspace: vi.fn(), + findSourceWorkspaceFile: vi.fn(), + readLiveSourceFile: vi.fn(), +})); + +vi.mock("@agent-native/core", () => ({ + defineAction: (config: unknown) => config, +})); +vi.mock("../server/source-workspace.js", () => ({ + resolveSourceWorkspace: mocks.resolveSourceWorkspace, + findSourceWorkspaceFile: mocks.findSourceWorkspaceFile, + readLiveSourceFile: mocks.readLiveSourceFile, +})); + +import { sourceContentHash } from "../shared/source-workspace.js"; +import action from "./preview-source-edit.js"; + +describe("preview-source-edit nextVersionHash", () => { + const liveContent = "Hello"; + + beforeEach(() => { + mocks.resolveSourceWorkspace.mockReset().mockResolvedValue({ + canEdit: true, + sourceType: "inline", + files: [], + }); + mocks.findSourceWorkspaceFile.mockReset().mockReturnValue({ + id: "file_1", + filename: "index.html", + }); + mocks.readLiveSourceFile.mockReset().mockResolvedValue({ + content: liveContent, + versionHash: sourceContentHash(liveContent), + }); + }); + + it("returns the post-edit hash (not the current hash, not undefined) when the edit actually changes content", async () => { + const result = (await action.run({ + designId: "design_1", + fileId: "file_1", + edit: { kind: "exact-replace", search: "Hello", replace: "Goodbye" }, + })) as { nextVersionHash?: string; currentVersionHash: string }; + + const expectedNextContent = liveContent.replace("Hello", "Goodbye"); + expect(result.nextVersionHash).toBe(sourceContentHash(expectedNextContent)); + expect(result.nextVersionHash).not.toBeUndefined(); + expect(result.nextVersionHash).not.toBe(result.currentVersionHash); + }); + + it("omits nextVersionHash when the edit is a no-op (identical full-replace content)", async () => { + const result = (await action.run({ + designId: "design_1", + fileId: "file_1", + edit: { kind: "full-replace", content: liveContent }, + })) as { nextVersionHash?: string; editsApplied: number }; + + expect(result.editsApplied).toBe(0); + expect(result.nextVersionHash).toBeUndefined(); + }); +}); diff --git a/templates/design/actions/preview-source-edit.ts b/templates/design/actions/preview-source-edit.ts index 5197eb0d62..1a2994646c 100644 --- a/templates/design/actions/preview-source-edit.ts +++ b/templates/design/actions/preview-source-edit.ts @@ -9,6 +9,7 @@ import { import { applySourceEdit, previewSourceDiff, + sourceContentHash, } from "../shared/source-workspace.js"; const sourceEditSchema = z.discriminatedUnion("kind", [ @@ -77,7 +78,15 @@ export default defineAction({ conflict: workspace.sourceType === "inline" ? null : "unsupported-source-backend", currentVersionHash: live.versionHash, - nextVersionHash: next.changed ? undefined : live.versionHash, + // The hash the file WOULD have after this edit is applied — needed + // exactly when something changed, so a caller can pass it as + // apply-source-edit's expectedVersionHash and close the + // preview→apply race (detect if the file changed again in between). + // When nothing changed, the content is still `live.content`, so there + // is no new hash to report. + nextVersionHash: next.changed + ? sourceContentHash(next.content) + : undefined, editsApplied: next.editsApplied, diff: previewSourceDiff(live.content, next.content), }; 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/DesignCanvas.bridge-restart.test.tsx b/templates/design/app/components/design/DesignCanvas.bridge-restart.test.tsx new file mode 100644 index 0000000000..0791566a21 --- /dev/null +++ b/templates/design/app/components/design/DesignCanvas.bridge-restart.test.tsx @@ -0,0 +1,191 @@ +// @vitest-environment happy-dom +// +// Behavioral coverage for the live-edit bridge auto-reconnect decision logic +// (see the shouldReregisterBridge 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(); + }); + } + + 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 = fetchMock.mock.calls.filter( + ([input]) => String(input).startsWith(`${BRIDGE_URL}/live-edit-bridge`), + ).length; + 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 = fetchMock.mock.calls.filter( + ([input]) => String(input).startsWith(`${BRIDGE_URL}/live-edit-bridge`), + ).length; + // 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("surfaces the error/Retry UI when /health reports the SAME bridgeInstanceId (real bug, not a restart)", 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(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(4200); + await flushMicrotasks(); + }); + + expect(container.textContent ?? "").toContain( + "Live editor connection failed", + ); + }); + + 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 healthCallCount = 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`)) { + healthCallCount += 1; + return jsonResponse({ + ok: true, + bridgeInstanceId: `instance-restart-${healthCallCount}`, + }); + } + 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.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.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..33c79d33ca 100644 --- a/templates/design/app/components/design/DesignCanvas.tsx +++ b/templates/design/app/components/design/DesignCanvas.tsx @@ -55,6 +55,7 @@ import { } from "./design-canvas/creation"; import { isElementInfoPayload } from "./design-canvas/element-payload"; import { + embeddedContentOffsetCss, embeddedContentOffsetStyle, getEmbeddedFrameBackgroundStyle, getEmbeddedFrameDocumentContent, @@ -91,6 +92,7 @@ import type { ElementInfo, ElementSelectionIntent, DeviceFrameType, + RuntimeStructureMoveRequest, } from "./types"; /** @@ -320,6 +322,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 +387,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; @@ -434,6 +442,7 @@ interface DesignCanvasProps { dropMode?: "flow-insert" | "absolute-container"; sourceRect?: { x: number; y: number; width: number; height: number }; anchorRect?: { x: number; y: number; width: number; height: number }; + anchorElementInfo?: ElementInfo; }, ) => boolean | "pending" | void; onVisualDuplicateChange?: ( @@ -685,6 +694,67 @@ function snapshotEndpointUrl(bridgeUrl: string, previewUrl: string): string { return endpoint.toString(); } +function healthEndpointUrl(bridgeUrl: string): string { + return new URL("/health", bridgeUrl).toString(); +} + +/** + * Decides how to react to a suspected `unknown-bridge-key` failure on the + * localhost live-edit bridge. + * + * 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` alongside the assumed failure `code`. + * + * - 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 (or no id / non-restart code at all) ⇒ the process that accepted + * our registration is still running, so a genuine unknown-bridge-key 409 + * means something else is wrong (stale bridgeKey, real bug). Surface the + * existing error/Retry UI instead of silently looping ("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 shouldReregisterBridge( + cachedBridgeInstanceId: string | null | undefined, + responseBridgeInstanceId: string | null | undefined, + code: string | null | undefined, +): "reregister" | "error" { + if (code !== "unknown-bridge-key") return "error"; + if (!cachedBridgeInstanceId || !responseBridgeInstanceId) return "error"; + return cachedBridgeInstanceId === responseBridgeInstanceId + ? "error" + : "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; + function originFromUrl(value: string | undefined): string | null { if (!value) return null; try { @@ -701,6 +771,9 @@ function buildEditorChromeBridgeScript(args: { editorChromeScaleY: number; screenId: string; boardSurface: boolean; + contentOffsetX: number; + contentOffsetY: number; + runtimeLayerSnapshotEnabled: boolean; }) { return ( createEditorBridgeThemeScript(readEditorBridgeThemeVars()) + @@ -716,6 +789,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", + ) ); } @@ -734,6 +819,7 @@ export function DesignCanvas({ bridgeUrl, externalSnapshotHtml, onExternalContentSnapshot, + onRuntimeLayerSnapshot, fusionUrl, previewToken, zoom, @@ -747,6 +833,7 @@ export function DesignCanvas({ styleBaselineResetRequest, textRevertRequest, structureAckRequest, + runtimeStructureMoveRequest, embeddedFrameBackground, transparentBackground = false, editorChromeScaleX = 1, @@ -861,8 +948,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; } @@ -891,6 +981,31 @@ 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 shouldReregisterBridge above. + const bridgeInstanceIdRef = useRef(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). + const liveEditRestartInFlightRef = useRef(false); + const liveEditRestartAttemptRef = useRef(0); const onExternalContentSnapshotRef = useRef(onExternalContentSnapshot); const isEmbeddedFrame = Boolean(embeddedFrame); // Resolve the URL to render in the iframe: @@ -912,6 +1027,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 +1040,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,8 +1064,12 @@ 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], ); const embeddedGestureBridgeForCurrentState = useMemo( () => @@ -1024,16 +1162,37 @@ 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; setRegisteredLiveEditBridgeKey(null); + setBridgeRegistrationError(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,18 +1210,46 @@ 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; + // 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); + setRegisteredLiveEditBridgeKey(liveEditBridgeKey); } catch (error) { if (!cancelled) { 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, @@ -1070,6 +1257,114 @@ export function DesignCanvas({ usesLiveEditInjectedBridge, ]); + // Manual retry (offline-state "Retry" button): reset the backoff so the + // user-initiated attempt fires immediately, mirroring + // handleManualSnapshotRetry below. + const handleManualBridgeRegistrationRetry = useCallback(() => { + bridgeRegistrationRetryAttemptRef.current = 0; + liveEditRestartAttemptRef.current = 0; + 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; + if (liveEditRestartAttemptRef.current >= MAX_LIVE_EDIT_RESTART_ATTEMPTS) { + setRegisteredLiveEditBridgeKey(null); + setBridgeRegistrationError({ + bridgeKey: liveEditBridgeKey, + message: t("designCanvas.localBridge.confirmationRetryExhausted"), + }); + return; + } + liveEditRestartInFlightRef.current = true; + try { + const response = await fetch(healthEndpointUrl(bridgeUrl)); + const payload = (await response.json().catch(() => null)) as { + bridgeInstanceId?: string; + } | null; + const responseBridgeInstanceId = + payload && typeof payload.bridgeInstanceId === "string" + ? payload.bridgeInstanceId + : null; + const decision = shouldReregisterBridge( + bridgeInstanceIdRef.current, + responseBridgeInstanceId, + "unknown-bridge-key", + ); + liveEditRestartAttemptRef.current += 1; + if (decision === "reregister") { + // 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); + setBridgeRegistrationRetryNonce((nonce) => nonce + 1); + } else { + // Same bridge process still doesn't know this key (or /health + // couldn't confirm a restart) — this isn't a transient restart, so + // surface the existing error/Retry UI instead of looping forever. + setRegisteredLiveEditBridgeKey(null); + setBridgeRegistrationError({ + bridgeKey: liveEditBridgeKey, + message: t("designCanvas.localBridge.connectionNotConfirmed"), + }); + } + } catch (error) { + liveEditRestartAttemptRef.current += 1; + setRegisteredLiveEditBridgeKey(null); + setBridgeRegistrationError({ + bridgeKey: liveEditBridgeKey, + message: error instanceof Error ? error.message : String(error), + }); + } finally { + liveEditRestartInFlightRef.current = false; + } + }, [bridgeUrl, liveEditBridgeKey, previewToken, t]); + + // 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. + 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 +1621,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 +1684,6 @@ export function DesignCanvas({ externalPreviewUrl, interactMode, isEmbeddedFrame, - embeddedFrame?.contentOffsetX, - embeddedFrame?.contentOffsetY, embeddedFrameBackground, embeddedGestureBridgeForCurrentState, iframeRenderContent, @@ -1432,8 +1734,29 @@ export function DesignCanvas({ 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") { bridgeReadyRef.current = true; + // 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; flushPendingOneShotMessages(); return; } @@ -1579,6 +1902,9 @@ export function DesignCanvas({ dropMode, sourceRect, anchorRect, + anchorElementInfo: isElementInfoPayload(e.data.anchorPayload) + ? e.data.anchorPayload + : undefined, }, ); if (requestId && applied !== "pending") { @@ -1842,6 +2168,7 @@ export function DesignCanvas({ return () => window.removeEventListener("message", handleMessage); }, [ onElementSelect, + onRuntimeLayerSnapshot, onElementMarqueeSelect, onElementHover, onClearSelection, @@ -2127,6 +2454,55 @@ export function DesignCanvas({ postOneShotBridgeMessage, ]); + // 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 +2748,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 @@ -2870,7 +3267,41 @@ export function DesignCanvas({ ) : null} {waitingForEditableExternalSnapshot || waitingForLiveEditBridge ? (
- {waitingForLiveEditBridge ? ( + {waitingForLiveEditBridge && + bridgeRegistrationError?.bridgeKey === liveEditBridgeKey ? ( + // Mirrors the snapshot-fetch offline card below: a stuck + // registration used to look identical to ordinary "still + // registering" with no explanation and no way to recover short + // of a full page reload. Only shown for the CURRENT + // liveEditBridgeKey — a stale error from a previous script + // revision must not linger after content/mode changes move on + // to registering a new one. +
+
+ + { + "Live editor connection failed" /* i18n-ignore local dev bridge registration failure title */ + } +
+
+ { + "Is the local dev server still running?" /* i18n-ignore local dev bridge registration failure subtitle */ + } +
+
+ {bridgeRegistrationError.message} +
+ +
+ ) : waitingForLiveEditBridge ? (
{ "Preparing live editor..." /* i18n-ignore transient localhost live-edit bridge loading state */ diff --git a/templates/design/app/components/design/EditPanel.componentProps.spec.ts b/templates/design/app/components/design/EditPanel.componentProps.spec.ts index d5612fa46c..35d4fbc0eb 100644 --- a/templates/design/app/components/design/EditPanel.componentProps.spec.ts +++ b/templates/design/app/components/design/EditPanel.componentProps.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; +import { buildComponentPropRows } from "./edit-panel/component-section"; import { alpineDataValueLiteral, canRebuildAlpineDataLosslessly, @@ -310,6 +311,85 @@ describe("canRebuildAlpineDataLosslessly", () => { // isBooleanPropValue — toggle detection // --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// buildComponentPropRows — row model + persist-surface selection (Bug: a +// never-observed persisted-variant group used to route its first edit to +// whichever surface an UNRELATED sibling x-data key happened to use). +// --------------------------------------------------------------------------- + +describe("buildComponentPropRows", () => { + it("lists Alpine x-data keys first, tagged for the alpineData surface", () => { + const rows = buildComponentPropRows({ + instance: { alpineData: "{ variant: 'solid', open: false }" }, + observedProps: [], + persistedVariants: { variant: ["solid", "outline"] }, + }); + const variantRow = rows.find((r) => r.name === "variant"); + expect(variantRow).toMatchObject({ + value: "solid", + surface: "alpineData", + options: ["solid", "outline"], + }); + const openRow = rows.find((r) => r.name === "open"); + expect(openRow).toMatchObject({ value: "false", surface: "alpineData" }); + }); + + it("lists observed data-attribute props not already in x-data, tagged attribute", () => { + const rows = buildComponentPropRows({ + instance: { alpineData: "{ open: false }" }, + observedProps: [{ name: "label", value: "Save" }], + persistedVariants: {}, + }); + expect(rows.find((r) => r.name === "label")).toMatchObject({ + value: "Save", + surface: "attribute", + }); + // The x-data key isn't duplicated as an attribute row. + expect(rows.filter((r) => r.name === "open")).toHaveLength(1); + }); + + it("defaults a never-observed persisted-variant group to the attribute surface even when x-data exists for other keys", () => { + // This instance's x-data only carries `open` — `size` is a persisted + // variant group that has never appeared in x-data or observedProps on + // this instance, meaning it must be attribute-driven. Regression: this + // used to key off `alpineData` being truthy for the unrelated `open` + // key and route `size`'s first edit into an x-data rewrite instead. + const rows = buildComponentPropRows({ + instance: { alpineData: "{ open: false }" }, + observedProps: [], + persistedVariants: { size: ["sm", "md", "lg"] }, + }); + const sizeRow = rows.find((r) => r.name === "size"); + expect(sizeRow).toMatchObject({ + value: "sm", + surface: "attribute", + options: ["sm", "md", "lg"], + }); + }); + + it("defaults a never-observed persisted-variant group to attribute when there is no x-data at all", () => { + const rows = buildComponentPropRows({ + instance: null, + observedProps: [], + persistedVariants: { size: ["sm", "lg"] }, + }); + expect(rows.find((r) => r.name === "size")).toMatchObject({ + value: "sm", + surface: "attribute", + }); + }); + + it("does not duplicate a group already covered by x-data or an observed attribute", () => { + const rows = buildComponentPropRows({ + instance: { alpineData: "{ variant: 'solid' }" }, + observedProps: [{ name: "label", value: "Save" }], + persistedVariants: { variant: ["solid", "outline"], label: [] }, + }); + expect(rows.filter((r) => r.name === "variant")).toHaveLength(1); + expect(rows.filter((r) => r.name === "label")).toHaveLength(1); + }); +}); + describe("isBooleanPropValue", () => { it("recognizes true/false case-insensitively", () => { expect(isBooleanPropValue("true")).toBe(true); diff --git a/templates/design/app/components/design/EditPanel.document-colors.test.ts b/templates/design/app/components/design/EditPanel.document-colors.test.ts index 0052bff571..a5d6412bea 100644 --- a/templates/design/app/components/design/EditPanel.document-colors.test.ts +++ b/templates/design/app/components/design/EditPanel.document-colors.test.ts @@ -1,6 +1,19 @@ import { describe, expect, it } from "vitest"; +import { selectionColorValues } from "./edit-panel/document-colors"; import { extractDocumentColorPalette } from "./EditPanel"; +import type { ElementInfo } from "./types"; + +function fakeElement(computedStyles: Record): ElementInfo { + return { + tagName: "DIV", + classes: [], + computedStyles, + boundingRect: { x: 0, y: 0, width: 0, height: 0 }, + isFlexChild: false, + isFlexContainer: false, + }; +} describe("extractDocumentColorPalette", () => { it("collects hex colors from inline styles across multiple files", () => { @@ -117,3 +130,77 @@ describe("extractDocumentColorPalette", () => { ).not.toThrow(); }); }); + +describe("selectionColorValues", () => { + it("skips the literal transparent spellings (existing behavior)", () => { + const values = selectionColorValues( + fakeElement({ + color: "rgb(0, 0, 0)", + backgroundColor: "transparent", + borderColor: "rgba(0, 0, 0, 0)", + outlineColor: "", + }), + ); + + expect(values).toEqual([{ property: "color", value: "rgb(0, 0, 0)" }]); + }); + + it("skips any other zero-alpha color, not just the two literal spellings", () => { + // Regression: this used to only filter the exact strings "transparent" + // and "rgba(0, 0, 0, 0)" — a zero-alpha color with any other RGB + // channels or formatting (e.g. a non-black rgba, or hsla) slipped + // through as a bogus, effectively-invisible "selection color" swatch. + const values = selectionColorValues( + fakeElement({ + color: "rgb(0, 0, 0)", + backgroundColor: "rgba(255, 0, 0, 0)", + borderColor: "hsla(210, 50%, 50%, 0)", + outlineColor: "rgba(0,0,0,0)", + }), + ); + + expect(values).toEqual([{ property: "color", value: "rgb(0, 0, 0)" }]); + }); + + it("keeps visible colors with non-zero alpha", () => { + const values = selectionColorValues( + fakeElement({ + color: "#111111", + backgroundColor: "rgba(255, 0, 0, 0.5)", + borderColor: "", + outlineColor: "", + }), + ); + + expect(values).toEqual([ + { property: "color", value: "#111111" }, + { property: "backgroundColor", value: "rgba(255, 0, 0, 0.5)" }, + ]); + }); + + it("dedupes equal colors across properties", () => { + const values = selectionColorValues( + fakeElement({ + color: "#111111", + backgroundColor: "#111111", + borderColor: "#111111", + outlineColor: "", + }), + ); + + expect(values).toEqual([{ property: "color", value: "#111111" }]); + }); + + it("keeps unparseable non-color values through (e.g. a Mixed sentinel)", () => { + const values = selectionColorValues( + fakeElement({ + color: "Mixed", + backgroundColor: "", + borderColor: "", + outlineColor: "", + }), + ); + + expect(values).toEqual([{ property: "color", value: "Mixed" }]); + }); +}); diff --git a/templates/design/app/components/design/EditPanel.fill-layers.test.ts b/templates/design/app/components/design/EditPanel.fill-layers.test.ts index 1b6a4403d5..a51a488d47 100644 --- a/templates/design/app/components/design/EditPanel.fill-layers.test.ts +++ b/templates/design/app/components/design/EditPanel.fill-layers.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; +import { + addFillLayerPatch, + removeBaseFillPatch, +} from "./edit-panel/fill-gradient-helpers"; import { averageGradientOpacity, defaultGradientStops, @@ -188,3 +192,79 @@ describe("splitCssLayers / joinCssLayers round-trip (sanity for the arrays above expect(joinCssLayers([])).toBe("none"); }); }); + +describe("addFillLayerPatch", () => { + it("reveals the hidden base solid when there is truly no fill at all", () => { + const patch = addFillLayerPatch({ + backgroundColor: "transparent", + backgroundLayers: [], + backgroundSizeLayers: [], + backgroundRepeatLayers: [], + backgroundPositionLayers: [], + }); + + expect(patch).toEqual({ backgroundColor: "#ffffff" }); + }); + + it("adds a new layer instead of un-hiding the base solid when layers already exist", () => { + // Regression: after switching solid -> gradient (solidToGradientPatch + // clears backgroundColor to "transparent"), clicking "+" again used to + // just un-hide the base solid instead of stacking a new layer — the + // opposite of "+", and it reintroduced the exact phantom-fill problem + // solidToGradientPatch exists to avoid. + const patch = addFillLayerPatch({ + backgroundColor: "transparent", + backgroundLayers: ["linear-gradient(90deg, red, blue)"], + backgroundSizeLayers: ["auto"], + backgroundRepeatLayers: ["no-repeat"], + backgroundPositionLayers: ["0% 0%"], + }); + + expect(patch.backgroundColor).toBeUndefined(); + const layers = splitCssLayers(patch.backgroundImage ?? ""); + expect(layers).toHaveLength(2); + expect(layers[1]).toBe("linear-gradient(90deg, red, blue)"); + expect(splitCssLayers(patch.backgroundSize ?? "")).toEqual([ + "auto", + "auto", + ]); + expect(splitCssLayers(patch.backgroundRepeat ?? "")).toEqual([ + "no-repeat", + "no-repeat", + ]); + expect(splitCssLayers(patch.backgroundPosition ?? "")).toEqual([ + "0% 0%", + "0% 0%", + ]); + }); + + it("adds a layer on top of a visible base solid, keeping the solid", () => { + const patch = addFillLayerPatch({ + backgroundColor: "#ff0000", + backgroundLayers: [], + backgroundSizeLayers: [], + backgroundRepeatLayers: [], + backgroundPositionLayers: [], + }); + + expect(patch.backgroundColor).toBeUndefined(); + expect(splitCssLayers(patch.backgroundImage ?? "")).toHaveLength(1); + }); +}); + +describe("removeBaseFillPatch", () => { + it("clears only the base color, never backgroundImage", () => { + // Regression: this used to also set backgroundImage: "none" whenever + // onStylesChange was available, silently deleting every other stacked + // gradient/image layer just from clicking the base fill row's remove + // button. + const patch = removeBaseFillPatch("backgroundColor"); + + expect(patch).toEqual({ backgroundColor: "transparent" }); + expect(patch.backgroundImage).toBeUndefined(); + }); + + it("clears the text color property for text fills", () => { + expect(removeBaseFillPatch("color")).toEqual({ color: "transparent" }); + }); +}); diff --git a/templates/design/app/components/design/EditPanel.gradient.test.ts b/templates/design/app/components/design/EditPanel.gradient.test.ts index 6ddcb97bc8..1a57316f43 100644 --- a/templates/design/app/components/design/EditPanel.gradient.test.ts +++ b/templates/design/app/components/design/EditPanel.gradient.test.ts @@ -43,4 +43,43 @@ describe("EditPanel gradient layer serialization", () => { expect(parsed?.stops[0]?.color).toBe("oklch(70% 0.1 200)"); expect(parsed?.stops[1]?.color).toBe("color-mix(in srgb, red 40%, blue)"); }); + + it("parses bare CSS named colors as gradient stops instead of dropping them", () => { + // Regression: readLeadingColor only recognized hex/transparent/function + // colors, so a plain named-color stop like "red" (extremely common in + // hand-authored or generated CSS) was misread as a gradient *prefix* — + // silently dropping that stop and corrupting an otherwise-valid 2-stop + // gradient into a broken 1-stop one with a garbage "red" prefix. + const parsed = parseGradientLayer("linear-gradient(red, blue)"); + + expect(parsed).not.toBeNull(); + expect(parsed?.prefix).toBeUndefined(); + expect(parsed?.stops).toHaveLength(2); + expect(parsed?.stops[0]?.color).toBe("#ff0000"); + expect(parsed?.stops[1]?.color).toBe("#0000ff"); + }); + + it("still treats direction/shape keywords as a prefix, not a color", () => { + const linear = parseGradientLayer( + "linear-gradient(to right, red 0%, blue 100%)", + ); + expect(linear?.prefix).toBe("to right"); + expect(linear?.stops).toHaveLength(2); + + const radial = parseGradientLayer( + "radial-gradient(circle at 50% 50%, red 0%, blue 100%)", + ); + expect(radial?.prefix).toBe("circle at 50% 50%"); + expect(radial?.stops).toHaveLength(2); + }); + + it("preserves a named-color angle prefix round-trip through buildGradientLayer", () => { + const parsed = parseGradientLayer( + "linear-gradient(135deg, red 0%, blue 100%)", + ); + expect(parsed).not.toBeNull(); + expect( + buildGradientLayer(parsed!.type, parsed!.stops, parsed!.prefix), + ).toBe("linear-gradient(135deg, #ff0000 0%, #0000ff 100%)"); + }); }); diff --git a/templates/design/app/components/design/EditPanel.inspectorHelpers.spec.ts b/templates/design/app/components/design/EditPanel.inspectorHelpers.spec.ts index e012e2292c..b7dba5fa5d 100644 --- a/templates/design/app/components/design/EditPanel.inspectorHelpers.spec.ts +++ b/templates/design/app/components/design/EditPanel.inspectorHelpers.spec.ts @@ -484,6 +484,57 @@ describe("mixedElementFromSelection", () => { const merged = mixedElementFromSelection([a, b]); expect(merged?.primitiveKind).toBe("text"); }); + + // isParentFlex/isParentGrid/parentFlexDirection (element-classification.ts) + // read parentDisplay/parentAutoLayout/parentLayout to decide whether + // LayoutContextProperties renders FlexChildControls/GridChildControls at + // all. Left to leak through the `...base` spread unchecked, these would + // report whichever element was selected LAST, misrendering (and + // misapplying align-self/flex-grow edits) for a selection spanning two + // different parents. + it("clears parentDisplay/parentAutoLayout/parentLayout when the selection's parents disagree", () => { + const a = makeElement({ + parentDisplay: "flex", + parentAutoLayout: { + display: "flex", + sourceId: "parent-a", + boundingRect: { x: 0, y: 0, width: 200, height: 100 }, + }, + parentLayout: { display: "flex", flexDirection: "row" }, + }); + const b = makeElement({ + parentDisplay: "block", + parentAutoLayout: undefined, + parentLayout: undefined, + }); + const merged = mixedElementFromSelection([a, b]); + expect(merged?.parentDisplay).toBeUndefined(); + expect(merged?.parentAutoLayout).toBeUndefined(); + expect(merged?.parentLayout).toBeUndefined(); + }); + + it("keeps parentDisplay/parentAutoLayout/parentLayout when every selected element shares the same parent", () => { + const parentAutoLayout = { + display: "flex", + sourceId: "parent-shared", + boundingRect: { x: 0, y: 0, width: 200, height: 100 }, + }; + const parentLayout = { display: "flex", flexDirection: "column" }; + const a = makeElement({ + parentDisplay: "flex", + parentAutoLayout, + parentLayout, + }); + const b = makeElement({ + parentDisplay: "flex", + parentAutoLayout: { ...parentAutoLayout }, + parentLayout: { ...parentLayout }, + }); + const merged = mixedElementFromSelection([a, b]); + expect(merged?.parentDisplay).toBe("flex"); + expect(merged?.parentAutoLayout).toEqual(parentAutoLayout); + expect(merged?.parentLayout).toEqual(parentLayout); + }); }); // --------------------------------------------------------------------------- diff --git a/templates/design/app/components/design/EditPanel.tsx b/templates/design/app/components/design/EditPanel.tsx index cae2f6bb0c..ecab9bc344 100644 --- a/templates/design/app/components/design/EditPanel.tsx +++ b/templates/design/app/components/design/EditPanel.tsx @@ -170,7 +170,6 @@ import { componentNameForElementInfo, cssElementSize, displayLabel, - elementHasLayoutChildren, elementIsComponentSelection, horizontalToJustify, inferElementSizing, @@ -321,7 +320,6 @@ import { type ExportSettingsValue, type FrameSizePreset, type FrameSizePresetCategoryKey, - imageFillToBackgroundStyles, InteractionStatePanel, type ActiveInteractionState, type DesignFillRow, @@ -1153,12 +1151,17 @@ function PageProperties({ backgroundRepeat={styles.backgroundRepeat} backgroundPosition={styles.backgroundPosition} onBackgroundImageChange={(v) => onStyleChange("backgroundImage", v)} - onImageFillChange={(value) => - commitStylePatch( - imageFillToBackgroundStyles(value), - onStyleChange, - onStylesChange, - ) + // Layer-index-aware: ColorInput merges the edited image into the + // correct backgroundImage/backgroundSize/backgroundRepeat/ + // backgroundPosition index and hands back the full four-property + // patch here, already preserving every other stacked + // gradient/image layer (same pattern as FillProperties' base fill + // row — see fill-properties.tsx). The single-layer + // `onImageFillChange` this previously used always overwrote the + // *whole* background stack via `imageFillToBackgroundStyles`, + // silently wiping any other stacked background layer. + onImageFillLayerChange={(patch) => + commitStylePatch(patch, onStyleChange, onStylesChange) } blendMode={styles.backgroundBlendMode || "normal"} onBlendModeChange={(v) => onStyleChange("backgroundBlendMode", v)} diff --git a/templates/design/app/components/design/LayersPanel.test.ts b/templates/design/app/components/design/LayersPanel.test.ts index e85cfaeac4..48c1eaadcd 100644 --- a/templates/design/app/components/design/LayersPanel.test.ts +++ b/templates/design/app/components/design/LayersPanel.test.ts @@ -17,10 +17,37 @@ import { nextAutoExpandedIds, nextExpandedIdsForSubtree, shouldResyncLayerSelectionAnchor, + shapeLayerUsesLayoutGlyph, type FlatLayerRow, type LayersPanelNode, } from "./LayersPanel"; +describe("LayersPanel promoted rectangle glyphs", () => { + it("uses the auto-layout glyph after a rectangle becomes a flex container", () => { + expect( + shapeLayerUsesLayoutGlyph({ + type: "rectangle", + layout: { isFlexContainer: true, flexDirection: "row" }, + }), + ).toBe(true); + expect( + shapeLayerUsesLayoutGlyph({ + type: "shape", + layout: { isGridContainer: true }, + }), + ).toBe(true); + }); + + it("keeps an ordinary rectangle on the rectangle glyph", () => { + expect( + shapeLayerUsesLayoutGlyph({ + type: "rectangle", + layout: { isFlexContainer: false, isGridContainer: false }, + }), + ).toBe(false); + }); +}); + function row( id: string, ancestorIds: string[] = [], diff --git a/templates/design/app/components/design/LayersPanel.tsx b/templates/design/app/components/design/LayersPanel.tsx index 27ad3c630e..93b663a71d 100644 --- a/templates/design/app/components/design/LayersPanel.tsx +++ b/templates/design/app/components/design/LayersPanel.tsx @@ -1606,6 +1606,7 @@ const LayerRow = memo(function LayerRow({ onFlipHorizontal, onFlipVertical, }: LayerRowProps) { + const t = useT(); const { node, depth, hasChildren, canAcceptChildren } = row; const isComponentLayer = layerNodeIsComponent(node); const selectable = node.selectable !== false; @@ -1747,6 +1748,27 @@ const LayerRow = memo(function LayerRow({ "application/x-design-layer-ids", JSON.stringify(draggedIds), ); + // Figma parity: dragging 2+ selected layers shows a small "N layers" + // count badge following the cursor instead of the browser's default + // drag image — a screenshot of just the ONE row that received this + // native dragstart event, even though every selected row moves together. + // setDragImage requires the image element to be attached to the DOM at + // the moment it's called, but not after — build an offscreen node here, + // wire it up, and detach it on the next frame. Single-layer drags are + // unaffected (kept exactly as before: the browser's own row snapshot). + if (draggedIds.length > 1) { + const ghost = document.createElement("div"); + ghost.textContent = t("layersPanel.dragGhostCount", { + count: draggedIds.length, + }); + ghost.className = + "fixed left-[-9999px] top-[-9999px] pointer-events-none select-none whitespace-nowrap rounded-full border border-[var(--design-editor-control-border)] bg-[var(--design-editor-panel-bg)] px-2.5 py-1 text-[11px] font-medium leading-none text-foreground shadow-[0_4px_16px_rgba(0,0,0,0.16),0_0_0_0.5px_rgba(0,0,0,0.08)]"; + document.body.appendChild(ghost); + event.dataTransfer.setDragImage(ghost, -12, -12); + requestAnimationFrame(() => { + ghost.remove(); + }); + } // Store drag state at module level so handleDragOver can read it. // dataTransfer.getData() returns "" during dragover per the HTML spec. activeDragState = { sourceId: node.id, draggedIds }; @@ -2435,7 +2457,11 @@ function LayerGlyph({ case "board-element": case "shape": case "rectangle": - return ; + return shapeLayerUsesLayoutGlyph(node) ? ( + + ) : ( + + ); case "vector": return ; case "line": @@ -2462,6 +2488,21 @@ function LayerGlyph({ } } +/** + * A canvas rectangle starts as a shape, but nest-on-drop can promote it to a + * flex/grid container. Once promoted, show the same auto-layout glyph as a + * frame so the Layers tree reflects the layer's real layout behavior instead + * of continuing to advertise it as a leaf rectangle. + */ +export function shapeLayerUsesLayoutGlyph( + node: Pick, +): boolean { + return ( + ["board-element", "shape", "rectangle"].includes(node.type ?? "") && + Boolean(node.layout?.isFlexContainer || node.layout?.isGridContainer) + ); +} + function layerNodeTagName( node: Pick, ): string | null { diff --git a/templates/design/app/components/design/LocalhostWriteConsentDialog.tsx b/templates/design/app/components/design/LocalhostWriteConsentDialog.tsx index 65e13203f1..3525b51f55 100644 --- a/templates/design/app/components/design/LocalhostWriteConsentDialog.tsx +++ b/templates/design/app/components/design/LocalhostWriteConsentDialog.tsx @@ -21,7 +21,6 @@ export interface LocalhostWriteConsentPayload { /** Pending callback to invoke after user grants consent. */ onGranted: (grant: { grantId: string; - bridgeToken: string; rootPath: string; grantedUntil: string; }) => void; @@ -58,7 +57,6 @@ export function LocalhostWriteConsentDialog({ try { const result = await callAction<{ grantId: string; - bridgeToken: string; rootPath: string; grantedUntil: string; }>("grant-localhost-write-consent", { diff --git a/templates/design/app/components/design/MultiScreenCanvas.gestures.test.tsx b/templates/design/app/components/design/MultiScreenCanvas.gestures.test.tsx index 4437e49fb1..09e425f309 100644 --- a/templates/design/app/components/design/MultiScreenCanvas.gestures.test.tsx +++ b/templates/design/app/components/design/MultiScreenCanvas.gestures.test.tsx @@ -5,6 +5,7 @@ import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { findCanvasIframeForScreen } from "./multi-screen/iframe-targeting"; +import { SURFACE_PADDING } from "./multi-screen/overview-layout"; import type { MultiScreenCanvasTool } from "./multi-screen/types"; import { MultiScreenCanvas } from "./MultiScreenCanvas"; @@ -52,6 +53,25 @@ function dispatchMouse( ); } +function dispatchMouseAlt( + target: EventTarget, + type: "mousedown" | "mousemove" | "mouseup", + clientX: number, + clientY: number, +) { + target.dispatchEvent( + new MouseEvent(type, { + bubbles: true, + cancelable: true, + button: 0, + buttons: type === "mouseup" ? 0 : 1, + clientX, + clientY, + altKey: true, + }), + ); +} + async function nextAnimationFrame() { await new Promise((resolve) => requestAnimationFrame(() => resolve())); } @@ -177,6 +197,131 @@ describe("MultiScreenCanvas gesture cancellation and drag thresholds", () => { expect(selectionBox!.style.height).toBe(before.height); }); + it("resizes a draft's DOM imperatively and restores it when Escape cancels the drag", async () => { + const surface = await renderHarness("rect"); + const draft = await createSelectedDraft(surface); + const selectionBox = container.querySelector( + "[data-frame-selection-box]", + ); + const resizeHandle = selectionBox?.querySelector( + '[data-resize-handle="se"]', + ); + expect(selectionBox).not.toBeNull(); + expect(resizeHandle).not.toBeNull(); + const before = { + draftWidth: draft.style.width, + draftHeight: draft.style.height, + boxWidth: selectionBox!.style.width, + boxHeight: selectionBox!.style.height, + }; + + // PERF9: beginDraftResize now writes the live geometry straight to the + // draft's own DOM node + selection box via updateDraftPrimitivesRefOnly + // (mirroring beginResize's frame path), instead of committing full React + // state (setDraftPrimitives) on every native mousemove. Confirm those DOM + // writes actually happen mid-gesture (not just at the eventual commit). + await act(async () => { + dispatchMouse(resizeHandle!, "mousedown", 400, 400); + dispatchMouse(window, "mousemove", 450, 450); + await nextAnimationFrame(); + }); + expect(draft.style.width).not.toBe(before.draftWidth); + expect(draft.style.height).not.toBe(before.draftHeight); + expect(selectionBox!.style.width).not.toBe(before.boxWidth); + expect(selectionBox!.style.height).not.toBe(before.boxHeight); + + // Escape must roll back every DOM node the live resize mutated + // imperatively, not just the (already-reverted) React draft state — + // otherwise the shape stays visually stuck at its last dragged size. + await act(async () => { + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Escape", + bubbles: true, + cancelable: true, + }), + ); + }); + + expect(draft.style.width).toBe(before.draftWidth); + expect(draft.style.height).toBe(before.draftHeight); + expect(selectionBox!.style.width).toBe(before.boxWidth); + expect(selectionBox!.style.height).toBe(before.boxHeight); + }); + + it("does not deselect an already-selected frame for shift-marquee jitter below the drag threshold", async () => { + await renderSelectedFrame(); + const surface = container.querySelector('[tabindex="-1"]'); + expect(surface).not.toBeNull(); + expect( + container.querySelector("[data-frame-selection-box]"), + ).not.toBeNull(); + + // MultiScreenCanvas auto-fits a lone screen into the mocked 800x600 + // surface on mount (see the "lineup fit" effect keyed on screens.length), + // so pan/zoom aren't simply {0,0}/100 here. Read the actual world-layer + // transform it committed instead of assuming a 1:1 mapping, so this test + // targets the frame's real screen-space edge regardless of that fit math. + const worldLayer = surface!.firstElementChild as HTMLElement; + const transformMatch = worldLayer.style.transform.match( + /translate\(([-\d.]+)px,\s*([-\d.]+)px\)\s*scale\(([\d.]+)\)/, + ); + expect(transformMatch).not.toBeNull(); + const [, panXStr, panYStr, scaleStr] = transformMatch!; + const panX = Number.parseFloat(panXStr); + const panY = Number.parseFloat(panYStr); + const scale = Number.parseFloat(scaleStr); + // Mirrors getCanvasPoint/screenToCanvasPoint's inverse: clientX = surface + // rect.left (mocked to 0) + panX + (SURFACE_PADDING + canvasX) * scale. + const clientPointForCanvas = (canvasX: number, canvasY: number) => ({ + clientX: panX + (SURFACE_PADDING + canvasX) * scale, + clientY: panY + (SURFACE_PADDING + canvasY) * scale, + }); + + // The frame spans canvas x:[0,320]. Start a shift+mousedown just to the + // right of it (on empty canvas, so beginMarquee fires, not the frame's + // own drag), then jitter 2 canvas px left — comfortably below + // DRAG_THRESHOLD (3 CLIENT px, and even smaller once scaled down here) — + // which crosses back over the frame's right edge. Before the fix, every + // mousemove (even sub-threshold ones) ran xorMarqueeSelection against the + // live rect, so this exact jitter toggled the already-selected frame OUT + // of the selection. + const origin = clientPointForCanvas(321, 300); + const jittered = clientPointForCanvas(319, 300); + const dispatchShiftMouse = ( + target: EventTarget, + type: "mousedown" | "mousemove" | "mouseup", + clientX: number, + clientY: number, + ) => + target.dispatchEvent( + new MouseEvent(type, { + bubbles: true, + cancelable: true, + button: 0, + buttons: type === "mouseup" ? 0 : 1, + clientX, + clientY, + shiftKey: true, + }), + ); + + await act(async () => { + dispatchShiftMouse(surface!, "mousedown", origin.clientX, origin.clientY); + dispatchShiftMouse( + window, + "mousemove", + jittered.clientX, + jittered.clientY, + ); + dispatchShiftMouse(window, "mouseup", jittered.clientX, jittered.clientY); + }); + + expect( + container.querySelector("[data-frame-selection-box]"), + ).not.toBeNull(); + }); + it("restores direct-DOM draft movement when Escape cancels the drag", async () => { const surface = await renderHarness("rect"); const draft = await createSelectedDraft(surface); @@ -230,6 +375,153 @@ describe("MultiScreenCanvas gesture cancellation and drag thresholds", () => { expect(frame.style.top).toBe(before.top); }); + it("rotates a frame's DOM imperatively and restores it when Escape cancels the drag", async () => { + const { frame } = await renderSelectedFrame(); + const selectionBox = container.querySelector( + "[data-frame-selection-box]", + ); + const rotateHandle = selectionBox?.querySelector( + "[data-rotate-handle]", + ); + expect(selectionBox).not.toBeNull(); + expect(rotateHandle).not.toBeNull(); + const before = { + frameTransform: frame.style.transform, + boxTransform: selectionBox!.style.transform, + }; + + // PERF9: rotate now writes the live transform straight to the frame + // shell + selection box via updateFrameGeometryRefOnly (mirroring + // beginFrameDrag), instead of committing full React state on every + // native mousemove. A rotate gesture starts at the handle's own + // position (outside the frame, near its corner) and needs to move past + // the drag threshold from there. + await act(async () => { + dispatchMouse(rotateHandle!, "mousedown", 500, 100); + dispatchMouse(window, "mousemove", 560, 100); + await nextAnimationFrame(); + }); + expect(frame.style.transform).not.toBe(before.frameTransform); + expect(selectionBox!.style.transform).not.toBe(before.boxTransform); + + // Escape must roll back the imperatively-mutated transform on both + // nodes, not just the (already-reverted) React geometry state. + await act(async () => { + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Escape", + bubbles: true, + cancelable: true, + }), + ); + }); + + expect(frame.style.transform).toBe(before.frameTransform); + expect(selectionBox!.style.transform).toBe(before.boxTransform); + }); + + it("resizes a frame's DOM imperatively and restores it when Escape cancels the drag", async () => { + const { frame } = await renderSelectedFrame(); + const selectionBox = container.querySelector( + "[data-frame-selection-box]", + ); + const resizeHandle = selectionBox?.querySelector( + '[data-resize-handle="se"]', + ); + expect(selectionBox).not.toBeNull(); + expect(resizeHandle).not.toBeNull(); + const screenCard = frame.querySelector("[data-screen-card]"); + expect(screenCard).not.toBeNull(); + const before = { + frameLeft: frame.style.left, + frameWidth: frame.style.width, + cardWidth: screenCard!.style.width, + cardHeight: screenCard!.style.height, + boxWidth: selectionBox!.style.width, + boxHeight: selectionBox!.style.height, + }; + + // PERF9: resize now writes the live geometry straight to the frame + // shell + screen-card + selection box via updateFrameGeometryRefOnly + // (mirroring beginFrameDrag), instead of committing full React state on + // every native mousemove. Confirm those DOM writes actually happen mid- + // gesture (not just at the eventual React commit). + await act(async () => { + dispatchMouse(resizeHandle!, "mousedown", 400, 400); + dispatchMouse(window, "mousemove", 450, 450); + await nextAnimationFrame(); + }); + expect(frame.style.width).not.toBe(before.frameWidth); + expect(screenCard!.style.width).not.toBe(before.cardWidth); + expect(screenCard!.style.height).not.toBe(before.cardHeight); + expect(selectionBox!.style.width).not.toBe(before.boxWidth); + expect(selectionBox!.style.height).not.toBe(before.boxHeight); + + // Escape must roll back every DOM node the live resize mutated + // imperatively, not just the (already-reverted) React geometry state — + // otherwise the frame stays visually stuck at its last dragged size. + await act(async () => { + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Escape", + bubbles: true, + cancelable: true, + }), + ); + }); + + expect(frame.style.left).toBe(before.frameLeft); + expect(frame.style.width).toBe(before.frameWidth); + expect(screenCard!.style.width).toBe(before.cardWidth); + expect(screenCard!.style.height).toBe(before.cardHeight); + expect(selectionBox!.style.width).toBe(before.boxWidth); + expect(selectionBox!.style.height).toBe(before.boxHeight); + }); + + it("moves the alt-drag duplicate ghost imperatively on every tick and unmounts it on release", async () => { + const { label } = await renderSelectedFrame(); + + // PERF9: beginDuplicateGesture now writes the ghost's left/top straight + // to its own DOM node (data-duplicate-preview-ghost) every native + // mousemove tick, instead of calling setDuplicatePreview (a full + // re-render) unconditionally each time — see duplicatePreviewElRef. + // canDuplicate/moved never flip in this harness (no onDuplicate prop is + // passed), which is exactly the steady-state case the fix targets: the + // ghost must still track the pointer on every tick even though nothing + // conditional ever changes. + await act(async () => { + dispatchMouseAlt(label, "mousedown", 320, 100); + }); + const ghost = container.querySelector( + "[data-duplicate-preview-ghost]", + ); + expect(ghost).not.toBeNull(); + const afterMount = { left: ghost!.style.left, top: ghost!.style.top }; + + await act(async () => { + dispatchMouseAlt(window, "mousemove", 400, 160); + await nextAnimationFrame(); + }); + const afterFirstMove = { left: ghost!.style.left, top: ghost!.style.top }; + expect(afterFirstMove.left).not.toBe(afterMount.left); + expect(afterFirstMove.top).not.toBe(afterMount.top); + + await act(async () => { + dispatchMouseAlt(window, "mousemove", 430, 190); + await nextAnimationFrame(); + }); + const afterSecondMove = { left: ghost!.style.left, top: ghost!.style.top }; + expect(afterSecondMove.left).not.toBe(afterFirstMove.left); + expect(afterSecondMove.top).not.toBe(afterFirstMove.top); + + await act(async () => { + dispatchMouseAlt(window, "mouseup", 430, 190); + }); + expect( + container.querySelector("[data-duplicate-preview-ghost]"), + ).toBeNull(); + }); + it("restores the camera origin when Escape cancels a mouse pan", async () => { const surface = await renderHarness("hand"); const world = surface.querySelector( @@ -275,6 +567,353 @@ describe("canvas iframe identity", () => { expect(board).toBe(root.querySelector("[data-board-surface-layer] iframe")); expect(screen?.getAttribute("data-screen-iframe-id")).toBe("screen-a"); }); + + it("renders negative and positive board coordinates inside a bounded paint window", async () => { + const container = document.createElement("div"); + document.body.append(container); + const rectSpy = vi + .spyOn(HTMLElement.prototype, "getBoundingClientRect") + .mockReturnValue({ + x: 0, + y: 0, + top: 0, + right: 800, + bottom: 600, + left: 0, + width: 800, + height: 600, + toJSON: () => ({}), + }); + const root = createRoot(container); + + try { + await act(async () => { + root.render( + ", + }, + ]} + zoom={100} + geometryById={{ + "screen-a": { x: 100, y: 80, width: 320, height: 640 }, + }} + boardFileId="board" + boardFileContent={` +
+
+ `} + boardFrameGeometry={{ + x: -65536, + y: -65536, + width: 131072, + height: 131072, + }} + boardEditMode + onPick={() => {}} + />, + ); + }); + + const boardLayer = container.querySelector( + "[data-board-surface-layer]", + ); + const iframe = boardLayer?.querySelector( + "iframe[data-design-preview-iframe]", + ); + expect(boardLayer).not.toBeNull(); + expect(iframe).not.toBeNull(); + expect(boardLayer!.style.left).toBe("-3856px"); + expect(boardLayer!.style.top).toBe("-3856px"); + expect(boardLayer!.style.width).toBe("8192px"); + expect(boardLayer!.style.height).toBe("8192px"); + expect(iframe!.srcdoc).toContain( + "body > [data-agent-native-node-id]{translate:4096px 4096px;}", + ); + } finally { + await act(async () => root.unmount()); + rectSpy.mockRestore(); + container.remove(); + } + }); + + it("re-windows the board after a distant pan without replacing its iframe", async () => { + const container = document.createElement("div"); + document.body.append(container); + const rectSpy = vi + .spyOn(HTMLElement.prototype, "getBoundingClientRect") + .mockReturnValue({ + x: 0, + y: 0, + top: 0, + right: 800, + bottom: 600, + left: 0, + width: 800, + height: 600, + toJSON: () => ({}), + }); + const root = createRoot(container); + + try { + await act(async () => { + root.render( + ", + }, + ]} + zoom={100} + activeTool="hand" + geometryById={{ + "screen-a": { x: 100, y: 80, width: 320, height: 640 }, + }} + boardFileId="board" + boardFileContent={` +
+ `} + boardFrameGeometry={{ + x: -65536, + y: -65536, + width: 131072, + height: 131072, + }} + boardEditMode + onPick={() => {}} + />, + ); + }); + + const surface = container.querySelector('[tabindex="-1"]'); + const boardLayer = container.querySelector( + "[data-board-surface-layer]", + ); + const iframe = boardLayer?.querySelector( + "iframe[data-design-preview-iframe]", + ); + expect(surface).not.toBeNull(); + expect(boardLayer).not.toBeNull(); + expect(iframe).not.toBeNull(); + const initialLayerLeft = boardLayer!.style.left; + const initialSrcdoc = iframe!.srcdoc; + + await act(async () => { + dispatchMouse(surface!, "mousedown", 400, 300); + dispatchMouse(window, "mousemove", -26_000, 300); + await nextAnimationFrame(); + dispatchMouse(window, "mouseup", -26_000, 300); + await nextAnimationFrame(); + }); + + const rewindowedLayer = container.querySelector( + "[data-board-surface-layer]", + ); + const rewindowedIframe = + rewindowedLayer?.querySelector( + "iframe[data-design-preview-iframe]", + ); + expect(rewindowedLayer!.style.left).not.toBe(initialLayerLeft); + expect(rewindowedIframe).toBe(iframe); + expect(rewindowedIframe!.srcdoc).toBe(initialSrcdoc); + } finally { + await act(async () => root.unmount()); + rectSpy.mockRestore(); + container.remove(); + } + }); + + it("keeps edge primitives visible and re-focuses the same live iframe when selected at 2% zoom", async () => { + const container = document.createElement("div"); + document.body.append(container); + const rectSpy = vi + .spyOn(HTMLElement.prototype, "getBoundingClientRect") + .mockReturnValue({ + x: 0, + y: 0, + top: 0, + right: 800, + bottom: 600, + left: 0, + width: 800, + height: 600, + toJSON: () => ({}), + }); + const root = createRoot(container); + + try { + await act(async () => { + root.render( + + +
+
Edge label
+ `} + boardFrameGeometry={{ + x: -65536, + y: -65536, + width: 131072, + height: 131072, + }} + boardEditMode + onPick={() => {}} + />, + ); + }); + + const surface = container.querySelector('[tabindex="-1"]'); + const staticPreview = container.querySelector( + "[data-board-static-preview]", + ); + const staticIframe = staticPreview?.querySelector( + "iframe[data-board-static-preview-iframe]", + ); + const activeLayer = container.querySelector( + "[data-board-surface-layer]", + ); + const activeIframe = activeLayer?.querySelector( + "iframe[data-design-preview-iframe]", + ); + expect(surface).not.toBeNull(); + expect(staticPreview).not.toBeNull(); + expect(staticIframe).not.toBeNull(); + expect(activeIframe).not.toBeNull(); + expect(staticIframe!.getAttribute("sandbox")).toBe(""); + expect(staticIframe!.getAttribute("sandbox")).not.toContain( + "allow-scripts", + ); + expect(staticIframe!.srcdoc).toContain("left-edge"); + expect(staticIframe!.srcdoc).toContain("right-edge"); + expect(staticIframe!.srcdoc).not.toContain(" { + dispatchMouse(surface!, "mousedown", 706, 8); + await nextAnimationFrame(); + await nextAnimationFrame(); + }); + + const focusedLayer = container.querySelector( + "[data-board-surface-layer]", + ); + const focusedIframe = focusedLayer?.querySelector( + "iframe[data-design-preview-iframe]", + ); + expect(focusedLayer!.style.left).not.toBe(initialActiveLeft); + expect(focusedIframe).toBe(initialActiveIframe); + expect(focusedIframe!.srcdoc).toBe(initialActiveSrcdoc); + expect(postMessage).toHaveBeenCalledWith( + { + type: "select-element", + selector: '[data-agent-native-node-id="right-edge"]', + selectorCandidates: ['[data-agent-native-node-id="right-edge"]'], + }, + "*", + ); + } finally { + await act(async () => root.unmount()); + rectSpy.mockRestore(); + container.remove(); + } + }); + + it("cancels a pending static-board handoff when the tool changes before the live post", async () => { + const container = document.createElement("div"); + document.body.append(container); + const rectSpy = vi + .spyOn(HTMLElement.prototype, "getBoundingClientRect") + .mockReturnValue({ + x: 0, + y: 0, + top: 0, + right: 800, + bottom: 600, + left: 0, + width: 800, + height: 600, + toJSON: () => ({}), + }); + const root = createRoot(container); + const boardProps = { + screens: [], + zoom: 2, + boardFileId: "board", + boardFileContent: ` +
+
Edge label
+ `, + boardFrameGeometry: { + x: -65536, + y: -65536, + width: 131072, + height: 131072, + }, + }; + + try { + await act(async () => { + root.render( + {}} + />, + ); + }); + const surface = container.querySelector('[tabindex="-1"]')!; + const activeIframe = container.querySelector( + "[data-board-surface-layer] iframe[data-design-preview-iframe]", + )!; + const postMessage = vi.spyOn(activeIframe.contentWindow!, "postMessage"); + + await act(async () => { + dispatchMouse(surface, "mousedown", 706, 8); + // Invalidate the pending handoff before its live-bridge frame runs. + root.render( + {}} + />, + ); + }); + await act(async () => { + await nextAnimationFrame(); + await nextAnimationFrame(); + }); + + expect(postMessage).not.toHaveBeenCalledWith( + expect.objectContaining({ type: "select-element" }), + "*", + ); + } finally { + await act(async () => root.unmount()); + rectSpy.mockRestore(); + container.remove(); + } + }); }); describe("cold-open iframe culling", () => { diff --git a/templates/design/app/components/design/MultiScreenCanvas.primitives.test.ts b/templates/design/app/components/design/MultiScreenCanvas.primitives.test.ts index 0b6ea7e6c8..a19a752d48 100644 --- a/templates/design/app/components/design/MultiScreenCanvas.primitives.test.ts +++ b/templates/design/app/components/design/MultiScreenCanvas.primitives.test.ts @@ -10,7 +10,9 @@ import { computeAltHoverMeasurement } from "./multi-screen/alt-hover-measurement import { getBoardContentKey, getBoardContentLayerSignature, + getBoardSurfaceContentBounds, getBoardSurfaceRenderContent, + getBoardSurfaceStaticPreviewContent, hasBoardSurfaceContent, } from "./multi-screen/board-surface-html"; import { @@ -44,13 +46,20 @@ import { isBreakpointSelectionTarget, } from "./multi-screen/iframe-targeting"; import { + BOARD_SURFACE_RENDER_MAX_SIZE, + boardPointToBoardSurfaceLocalPoint, + boardSurfaceLocalPointToBoardPoint, + getBoardSurfaceRenderGeometry, getBoardSurfaceLayerStyle, + getBoardSurfaceStaticPreviewViewport, + shouldRenderBoardSurfaceStaticPreview, SURFACE_PADDING, } from "./multi-screen/overview-layout"; import { __clearPrimitiveParseCachesForTests, __getPrimitiveParseCacheSizesForTests, getPrimitiveDropTargetForPoint, + isPrimitiveContainer, parsePrimitivesFromScreen, primitiveLocalToBoardRect, primitiveParseCache, @@ -132,6 +141,167 @@ describe("board surface pointer capture", () => { expect(style.pointerEvents).toBe("auto"); }); + it("bounds negative and positive board nodes in persisted canvas coordinates", () => { + const bounds = getBoardSurfaceContentBounds(` +
+
+ `); + + expect(bounds).toEqual({ x: -165, y: -90, width: 594, height: 360 }); + }); + + it("includes nested overflow and ignores an instrumented document body", () => { + expect( + getBoardSurfaceContentBounds( + '', + ), + ).toBeNull(); + + const bounds = + getBoardSurfaceContentBounds(` +
+
+
+ `); + expect(bounds).toEqual({ x: 300, y: 100, width: 220, height: 180 }); + }); + + it("uses a browser-safe chunked paint window without changing logical board coordinates", () => { + const logical = makeGeom(-65536, -65536, 131072, 131072); + const negativeBounds = makeGeom(-165, -90, 84, 76); + const screens = [makeGeom(100, 80, 320, 640)]; + const geometry = getBoardSurfaceRenderGeometry({ + logicalGeometry: logical, + contentBounds: negativeBounds, + screenGeometries: screens, + }); + + expect(geometry).toEqual({ + x: -4096, + y: -4096, + width: 8192, + height: 8192, + }); + expect(geometry.width).toBeLessThanOrEqual(BOARD_SURFACE_RENDER_MAX_SIZE); + expect(geometry.height).toBeLessThanOrEqual(BOARD_SURFACE_RENDER_MAX_SIZE); + // The iframe-local coordinate round-trip lands at the exact persisted + // negative board coordinate; no fixed +/-65536 projection is involved. + const iframeLocalX = negativeBounds.x - geometry.x; + const iframeLocalY = negativeBounds.y - geometry.y; + expect(geometry.x + iframeLocalX).toBe(negativeBounds.x); + expect(geometry.y + iframeLocalY).toBe(negativeBounds.y); + }); + + it("keeps the render origin stable for nearby edits inside the same chunk", () => { + const logical = makeGeom(-65536, -65536, 131072, 131072); + const before = getBoardSurfaceRenderGeometry({ + logicalGeometry: logical, + contentBounds: makeGeom(-165, -90, 84, 76), + screenGeometries: [makeGeom(100, 80, 320, 640)], + }); + const after = getBoardSurfaceRenderGeometry({ + logicalGeometry: logical, + contentBounds: makeGeom(329, 210, 100, 60), + screenGeometries: [makeGeom(100, 80, 320, 640)], + }); + + expect(after).toEqual(before); + }); + + it("keeps a low-zoom viewport browser-bounded while following its center", () => { + const logical = makeGeom(-65536, -65536, 131072, 131072); + // At the canvas minimum zoom (2%), a 1440x900 viewport spans 72,000 x + // 45,000 board pixels. One iframe intentionally remains capped below + // that span; it follows the live viewport center instead of regressing to + // the origin or allocating the old 131,072px document. + const lowZoomViewport = makeGeom(18_000, -12_000, 72_000, 45_000); + const geometry = getBoardSurfaceRenderGeometry({ + logicalGeometry: logical, + contentBounds: makeGeom(-165, -90, 84, 76), + screenGeometries: [lowZoomViewport], + focus: { + x: lowZoomViewport.x + lowZoomViewport.width / 2, + y: lowZoomViewport.y + lowZoomViewport.height / 2, + }, + }); + + expect(geometry.width).toBe(BOARD_SURFACE_RENDER_MAX_SIZE); + expect(geometry.height).toBe(BOARD_SURFACE_RENDER_MAX_SIZE); + expect(geometry.x).toBe(40_960); + expect(geometry.y).toBe(-4096); + expect(geometry.x + geometry.width / 2).toBeCloseTo(53_248, 0); + expect(geometry.y + geometry.height / 2).toBeCloseTo(8192, 0); + }); + + it("covers a 2% viewport with one uniformly compressed inert board preview", () => { + const logical = makeGeom(-65536, -65536, 131072, 131072); + const active = makeGeom(-12288, -12288, 24576, 24576); + const viewport = makeGeom(-36000, -22500, 72000, 45000); + const staticViewport = getBoardSurfaceStaticPreviewViewport(logical); + + expect( + shouldRenderBoardSurfaceStaticPreview({ + zoom: 2, + viewportGeometry: viewport, + renderGeometry: active, + }), + ).toBe(true); + expect(staticViewport).toEqual({ width: 4096, height: 4096 }); + + const content = getBoardSurfaceStaticPreviewContent({ + html: ` + + + + + + + + + + + + + +
+
+ `, + 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..ae8f12aa85 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, @@ -165,7 +165,9 @@ import { BOARD_SURFACE_BACKGROUND, getBoardContentKey, getBoardContentLayerSignature, + getBoardSurfaceContentBounds, getBoardSurfaceRenderContent, + getBoardSurfaceStaticPreviewContent, hasBoardSurfaceContent, } from "./multi-screen/board-surface-html"; import { @@ -197,9 +199,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 +287,7 @@ import { findTopFrameEntryAtPoint, frameGeometryWithOverrides, frameStyleLeftTop, + geometryContainsPoint, getBreakpointFrameGeometry, getFrameCenter, getInitialFrameGeometry, @@ -302,12 +310,77 @@ 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, @@ -434,6 +507,154 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ const [selectedIds, setSelectedIds] = useState( selectedScreenIds ?? [], ); + 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 +729,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, ); @@ -1453,8 +1680,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 +1754,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 +1904,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 +1927,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 +1977,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 +2026,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 +2362,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 +2427,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ activeId, boardFileId, boardFrameGeometry, + boardSurfaceRenderGeometry, getFrameEntryAtPoint, getCanvasPoint, getResolvedMetadata, @@ -2340,7 +2661,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 +2675,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,7 +2689,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ }, [ boardFileId, - boardFrameGeometry, + boardSurfaceRenderGeometry, getCurrentFrameEntries, getResolvedMetadata, requestSelectableElementInfos, @@ -2576,6 +2897,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 +2969,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 +2989,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 +3070,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ return false; }, [ finishDrag, + getResolvedMetadata, updateDraftPrimitives, updateFrameGeometry, updateSelectedDraftIds, @@ -2725,6 +3107,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 +3143,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ installDragListeners(handleMouseMove, handlePanEnd); }, - [finishDrag, installDragListeners], + [cancelPendingStaticBoardSelection, finishDrag, installDragListeners], ); const beginMarquee = useCallback( @@ -2875,6 +3258,18 @@ 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); @@ -4025,13 +4420,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 +4563,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 +4597,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 +4648,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 +4659,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 +4715,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 +4735,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ installDragListeners, showTransformFeedback, updateDraftPrimitives, + updateDraftPrimitivesRefOnly, updateSelectedDraftIds, updateSelectedIds, ], @@ -4326,6 +4809,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 +5021,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 +5053,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(); @@ -4650,6 +5157,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 +5336,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 +5425,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 +5434,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 +5457,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 +5478,12 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ activeId, finishDrag, getCurrentFrameEntries, + getResolvedMetadata, installDragListeners, onPick, showTransformFeedback, updateFrameGeometry, + updateFrameGeometryRefOnly, updateSelectedIds, ], ); @@ -4863,6 +5534,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 +5568,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 +5601,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, @@ -4935,6 +5630,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ onPick, showTransformFeedback, updateFrameGeometry, + updateFrameGeometryRefOnly, updateSelectedIds, ], ); @@ -4979,6 +5675,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 +5731,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 +5740,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 +5788,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,6 +5813,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ installDragListeners, showTransformFeedback, updateFrameGeometry, + updateFrameGeometryRefOnly, ], ); @@ -5155,25 +5912,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 = () => { @@ -5330,6 +6122,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 +6167,13 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ beginPenNodeCreation, beginVectorAnchorDrag, beginVectorHandleDrag, + boardStaticPrimitives, + boardSurfaceRenderGeometry, claimKeyboardFocus, getCanvasPoint, localActiveTool, setAltHoverMeasurement, + showBoardStaticPreview, toggleVectorNodeType, vectorEdit, ], @@ -5576,6 +6404,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 +6423,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ element.style.pointerEvents = "none"; }); wheelGestureMutedElementsRef.current = muted; - }, []); + }, [cancelPendingStaticBoardSelection]); const flushPendingWheelGesture = useCallback(() => { wheelGestureFrameRef.current = null; @@ -6456,11 +7285,51 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ transformOrigin: "top left", }} > + {showBoardStaticPreview && + boardFrameGeometry && + boardStaticPreviewViewport && + boardStaticPreviewContent ? ( + ` : ``; + if (!visibility) { + return ( +
+
+
+
+ ); + } + return (
{!isPublic ? ( diff --git a/templates/clips/app/components/player/transcript-panel.tsx b/templates/clips/app/components/player/transcript-panel.tsx index f166d88d50..1c2b7b2ce8 100644 --- a/templates/clips/app/components/player/transcript-panel.tsx +++ b/templates/clips/app/components/player/transcript-panel.tsx @@ -19,6 +19,7 @@ import { IconBolt, IconChevronDown, IconChevronUp, + IconRefresh, } from "@tabler/icons-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -56,6 +57,9 @@ export interface TranscriptPanelProps { recordingTitle?: string; /** Called when the user asks us to retry transcription after fixing an error. */ onRetry?: () => void; + /** Called when the user asks for a fresh transcript from the recording media. */ + onRegenerate?: () => void; + isRegenerating?: boolean; } export function TranscriptPanel(props: TranscriptPanelProps) { @@ -71,6 +75,8 @@ export function TranscriptPanel(props: TranscriptPanelProps) { cleanup, recordingTitle, onRetry, + onRegenerate, + isRegenerating = false, } = props; const [query, setQuery] = useState(""); const [copied, setCopied] = useState(false); @@ -170,8 +176,18 @@ export function TranscriptPanel(props: TranscriptPanelProps) {

{onRetry ? ( - ) : 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() { = {}) { 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/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/.generated/bridge/editor-chrome.generated.ts b/templates/design/.generated/bridge/editor-chrome.generated.ts index 6f9de70988..34ca05f329 100644 --- a/templates/design/.generated/bridge/editor-chrome.generated.ts +++ b/templates/design/.generated/bridge/editor-chrome.generated.ts @@ -873,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, @@ -885,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, @@ -944,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, @@ -1315,6 +1328,10 @@ 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; @@ -1372,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 = []; @@ -1379,7 +1398,7 @@ export const editorChromeBridgeScript: string = `"use strict"; var lastEditorPointWasBlocked = false; function clearRuntimeSelection() { selectedEl = null; - hoveredEl = null; + clearHoverGate(); setPassiveSelectionElements([]); clearSpacingHoverTimer(); selectedSpacingHovered = false; @@ -1629,7 +1648,7 @@ export const editorChromeBridgeScript: string = `"use strict"; } catch (_err) { } } - hoveredEl = null; + clearHoverGate(); if (selectedEl && !isLayerInteractionBlocked(selectedEl)) { positionOverlay(selectionOverlay, selectedEl); postElementSelect(selectedEl); @@ -1658,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]); @@ -2829,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 [ @@ -2846,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; @@ -3175,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; @@ -3185,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(); @@ -3194,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 }, "*" ); @@ -3272,7 +3351,7 @@ export const editorChromeBridgeScript: string = `"use strict"; selectedEl = null; hideSelectionOverlay(); } - hoveredEl = null; + clearHoverGate(); highlightOverlay.style.display = "none"; hideMeasurements(); refreshOverlays(); @@ -4449,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 = []; @@ -4651,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; @@ -4689,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; @@ -4724,6 +4892,7 @@ 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), @@ -4894,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; @@ -4998,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); } @@ -5006,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(); @@ -5025,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(); @@ -5056,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) @@ -5066,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( @@ -5091,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; @@ -5105,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; } @@ -5213,12 +5431,17 @@ export const editorChromeBridgeScript: string = `"use strict"; 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"); @@ -5297,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); @@ -5395,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 ); @@ -5411,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; @@ -5473,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) { @@ -5537,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; } @@ -5698,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(); @@ -5816,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 || ""; @@ -5916,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( @@ -6442,7 +6705,7 @@ export const editorChromeBridgeScript: string = `"use strict"; updateSpacingOverlay(selectedEl); return; } - hoveredEl = null; + clearHoverGate(); if (!spacingDrag) { scheduleSpacingHoverClear(e); } @@ -6700,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") { @@ -6722,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; @@ -6752,7 +7017,7 @@ export const editorChromeBridgeScript: string = `"use strict"; hideSelectionOverlay(); } if (hoveredEl && isLayerInteractionBlocked(hoveredEl)) { - hoveredEl = null; + clearHoverGate(); highlightOverlay.style.display = "none"; } applyHiddenSelectors(); 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/AGENTS.md b/templates/design/AGENTS.md index 65fb10be33..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 @@ -297,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.ts b/templates/design/actions/add-localhost-screens.ts index 58b6797fb2..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, @@ -283,32 +284,6 @@ function metadataForFile( return isRecord(legacy) ? legacy : undefined; } -/** - * True when `err` is a UNIQUE / PRIMARY KEY constraint violation from either - * supported driver (Postgres 23505, SQLite SQLITE_CONSTRAINT_UNIQUE / - * _PRIMARYKEY). Mirrors `@agent-native/core`'s internal `isUniqueViolation` - * (kept local here rather than requiring a core export change for one insert - * race — see `ensureDesignFilesUniqueIndex` in server/plugins/db.ts for the - * index this is meant to catch a conflict against). - */ -function isUniqueConstraintViolation(err: unknown): boolean { - const e = err as { code?: unknown; message?: unknown } | null; - if (e?.code === "23505") return true; - const code = String(e?.code ?? ""); - if ( - code === "SQLITE_CONSTRAINT_PRIMARYKEY" || - code === "SQLITE_CONSTRAINT_UNIQUE" - ) { - return true; - } - const msg = String(e?.message ?? "").toLowerCase(); - return ( - msg.includes("unique constraint") || - msg.includes("primary key constraint") || - msg.includes("duplicate key") - ); -} - function metadataMatchesRoute( metadata: Record | undefined, args: { connectionId: string; routeId: string; path: string; url: string }, 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 1329562b3a..8bd321428b 100644 --- a/templates/design/actions/apply-component-prop-edit.interleave.spec.ts +++ b/templates/design/actions/apply-component-prop-edit.interleave.spec.ts @@ -258,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 5f6fa5012a..be0d3155aa 100644 --- a/templates/design/actions/apply-component-prop-edit.ts +++ b/templates/design/actions/apply-component-prop-edit.ts @@ -39,6 +39,8 @@ 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"; @@ -57,7 +59,6 @@ import { componentNodeIdMatches, } from "../shared/component-model.js"; import { designSourceTypeFromData } from "../shared/source-mode.js"; -import { sourceContentHash } from "../shared/source-workspace.js"; // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -291,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, @@ -309,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/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 index 0791566a21..52cabc5356 100644 --- a/templates/design/app/components/design/DesignCanvas.bridge-restart.test.tsx +++ b/templates/design/app/components/design/DesignCanvas.bridge-restart.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment happy-dom // // Behavioral coverage for the live-edit bridge auto-reconnect decision logic -// (see the shouldReregisterBridge doc comment in DesignCanvas.tsx). The +// (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 @@ -90,6 +90,32 @@ describe("DesignCanvas live-edit bridge restart detection", () => { }); } + 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() { + const iframe = container.querySelector("iframe"); + if (!iframe?.contentWindow) { + throw new Error("expected the live-edit iframe to be mounted"); + } + window.dispatchEvent( + new MessageEvent("message", { + data: { type: "agent-native:editor-chrome-ready" }, + origin: BRIDGE_URL, + source: iframe.contentWindow, + }), + ); + } + 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); @@ -104,9 +130,7 @@ describe("DesignCanvas live-edit bridge restart detection", () => { await renderLiveEditCanvas(); - const registrationCallsBeforeTimeout = fetchMock.mock.calls.filter( - ([input]) => String(input).startsWith(`${BRIDGE_URL}/live-edit-bridge`), - ).length; + const registrationCallsBeforeTimeout = registrationCallCount(); expect(registrationCallsBeforeTimeout).toBe(1); // No agent-native:editor-chrome-ready message ever arrives (simulating @@ -117,9 +141,7 @@ describe("DesignCanvas live-edit bridge restart detection", () => { await flushMicrotasks(); }); - const registrationCallsAfterTimeout = fetchMock.mock.calls.filter( - ([input]) => String(input).startsWith(`${BRIDGE_URL}/live-edit-bridge`), - ).length; + const registrationCallsAfterTimeout = registrationCallCount(); // A second registration POST fired automatically — the silent // re-register/reload path — without ever surfacing an error. expect(registrationCallsAfterTimeout).toBeGreaterThanOrEqual(2); @@ -128,7 +150,13 @@ describe("DesignCanvas live-edit bridge restart detection", () => { ); }); - it("surfaces the error/Retry UI when /health reports the SAME bridgeInstanceId (real bug, not a restart)", async () => { + 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`)) { @@ -141,15 +169,120 @@ describe("DesignCanvas live-edit bridge restart detection", () => { }); 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("does not loop forever when the bridge never confirms (attempt cap)", async () => { @@ -157,17 +290,17 @@ describe("DesignCanvas live-edit bridge restart detection", () => { // 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 healthCallCount = 0; + 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`)) { - healthCallCount += 1; + healthCallCounter += 1; return jsonResponse({ ok: true, - bridgeInstanceId: `instance-restart-${healthCallCount}`, + bridgeInstanceId: `instance-restart-${healthCallCounter}`, }); } throw new Error(`unexpected fetch: ${url}`); 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.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.tsx b/templates/design/app/components/design/DesignCanvas.tsx index 33c79d33ca..7d35bb0f5b 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"; @@ -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, @@ -440,6 +442,7 @@ 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; @@ -699,8 +702,9 @@ function healthEndpointUrl(bridgeUrl: string): string { } /** - * Decides how to react to a suspected `unknown-bridge-key` failure on the - * localhost live-edit bridge. + * 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) @@ -711,17 +715,24 @@ function healthEndpointUrl(bridgeUrl: string): string { * 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` alongside the assumed failure `code`. + * `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 (or no id / non-restart code at all) ⇒ the process that accepted - * our registration is still running, so a genuine unknown-bridge-key 409 - * means something else is wrong (stale bridgeKey, real bug). Surface the - * existing error/Retry UI instead of silently looping ("error"). + * - 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, @@ -732,15 +743,13 @@ function healthEndpointUrl(bridgeUrl: string): string { * sessions. Its decision logic is covered by behavioral tests in * DesignCanvas.bridge-restart.test.tsx instead of a direct unit import. */ -function shouldReregisterBridge( +function classifyLiveEditHealthProbe( cachedBridgeInstanceId: string | null | undefined, responseBridgeInstanceId: string | null | undefined, - code: string | null | undefined, -): "reregister" | "error" { - if (code !== "unknown-bridge-key") return "error"; +): "reregister" | "escalate" | "error" { if (!cachedBridgeInstanceId || !responseBridgeInstanceId) return "error"; return cachedBridgeInstanceId === responseBridgeInstanceId - ? "error" + ? "escalate" : "reregister"; } @@ -754,6 +763,53 @@ const LIVE_EDIT_READY_TIMEOUT_MS = 4000; // 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; @@ -812,6 +868,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, @@ -934,6 +1013,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(() => { @@ -962,6 +1043,11 @@ 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 [fetchedExternalSnapshot, setFetchedExternalSnapshot] = useState<{ url: string; html: string; @@ -999,13 +1085,52 @@ export function DesignCanvas({ // /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 shouldReregisterBridge above. + // process really doesn't know this key" — see classifyLiveEditHealthProbe + // above. const bridgeInstanceIdRef = useRef(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). + // 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: @@ -1071,16 +1196,17 @@ export function DesignCanvas({ // 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( @@ -1104,6 +1230,10 @@ export function DesignCanvas({ () => contentHash(liveEditBridgeScript), [liveEditBridgeScript], ); + const registrationHandoffKey = liveEditRegistrationHandoffKey( + bridgeUrl, + liveEditBridgeKey, + ); const hasLiveEditExternalFrame = sourceType === "localhost" && Boolean(bridgeUrl && rawExternalPreviewUrl); const hasAuthenticatedLiveEditExternalFrame = @@ -1113,9 +1243,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 @@ -1133,7 +1268,7 @@ export function DesignCanvas({ previewToken, previewUrl: rawExternalPreviewUrl, bridgeKey: liveEditBridgeKey, - registeredBridgeKey: registeredLiveEditBridgeKey, + registeredBridgeKey: effectiveRegisteredLiveEditBridgeKey, }); const iframeRenderContent = !hasAuthenticatedLiveEditExternalFrame && activeExternalSnapshotHtml @@ -1174,8 +1309,15 @@ export function DesignCanvas({ 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); return; } let cancelled = false; @@ -1222,6 +1364,9 @@ export function DesignCanvas({ } 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 @@ -1234,6 +1379,9 @@ export function DesignCanvas({ setRegisteredLiveEditBridgeKey(liveEditBridgeKey); } catch (error) { if (!cancelled) { + if (registrationHandoffKey) { + liveEditRegistrationHandoff.delete(registrationHandoffKey); + } console.warn("live-edit bridge registration failed", error); setRegisteredLiveEditBridgeKey(null); setBridgeRegistrationError({ @@ -1254,15 +1402,47 @@ export function DesignCanvas({ 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); + }, [liveEditBridgeKey]); + // Manual retry (offline-state "Retry" button): reset the backoff so the // user-initiated attempt fires immediately, mirroring - // handleManualSnapshotRetry below. + // 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); }, []); @@ -1279,31 +1459,39 @@ export function DesignCanvas({ const handleSuspectedBridgeRestart = useCallback(async () => { if (!bridgeUrl || !previewToken) return; if (liveEditRestartInFlightRef.current) return; - if (liveEditRestartAttemptRef.current >= MAX_LIVE_EDIT_RESTART_ATTEMPTS) { - setRegisteredLiveEditBridgeKey(null); - setBridgeRegistrationError({ - bridgeKey: liveEditBridgeKey, - message: t("designCanvas.localBridge.confirmationRetryExhausted"), - }); - 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 = shouldReregisterBridge( + const decision = classifyLiveEditHealthProbe( bridgeInstanceIdRef.current, responseBridgeInstanceId, - "unknown-bridge-key", ); - liveEditRestartAttemptRef.current += 1; if (decision === "reregister") { + if ( + liveEditRestartAttemptRef.current >= MAX_LIVE_EDIT_RESTART_ATTEMPTS + ) { + if (registrationHandoffKey) { + liveEditRegistrationHandoff.delete(registrationHandoffKey); + } + 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 @@ -1313,19 +1501,75 @@ export function DesignCanvas({ 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); - } else { - // Same bridge process still doesn't know this key (or /health - // couldn't confirm a restart) — this isn't a transient restart, so - // surface the existing error/Retry UI instead of looping forever. - setRegisteredLiveEditBridgeKey(null); - setBridgeRegistrationError({ - bridgeKey: liveEditBridgeKey, - message: t("designCanvas.localBridge.connectionNotConfirmed"), - }); + 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); + } + setRegisteredLiveEditBridgeKey(null); + setBridgeRegistrationError({ + bridgeKey: liveEditBridgeKey, + message: t("designCanvas.localBridge.connectionNotConfirmed"), + }); } catch (error) { - liveEditRestartAttemptRef.current += 1; + 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); + } setRegisteredLiveEditBridgeKey(null); setBridgeRegistrationError({ bridgeKey: liveEditBridgeKey, @@ -1334,13 +1578,44 @@ export function DesignCanvas({ } finally { liveEditRestartInFlightRef.current = false; } - }, [bridgeUrl, liveEditBridgeKey, previewToken, t]); + }, [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 || @@ -1700,6 +1975,13 @@ 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(() => { @@ -1751,12 +2033,25 @@ export function DesignCanvas({ } if (e.data.type === "agent-native:editor-chrome-ready") { 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; } @@ -1900,6 +2195,8 @@ export function DesignCanvas({ sourceId, anchorSourceId, dropMode, + forceFlowPositionOverride: + e.data.forceFlowPositionOverride === true, sourceRect, anchorRect, anchorElementInfo: isElementInfoPayload(e.data.anchorPayload) @@ -2031,11 +2328,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; @@ -2454,6 +2779,17 @@ 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, @@ -3144,10 +3480,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 }); }, @@ -3207,6 +3546,21 @@ export function DesignCanvas({ : null), }} > + {liveEditTransitionFallbackHtml ? ( +