@agent-native/core 0.161.8 → 0.161.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +180 -37
  3. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +18 -3
  4. package/corpus/templates/design/app/components/layout/Layout.tsx +4 -1
  5. package/corpus/templates/design/app/hooks/use-navigation-state.ts +13 -2
  6. package/corpus/templates/design/app/i18n-data.ts +11 -0
  7. package/corpus/templates/design/app/lib/agent-chat.ts +30 -0
  8. package/corpus/templates/design/app/lib/builder-host-chat.ts +34 -0
  9. package/corpus/templates/design/app/lib/builder-host-origin.ts +31 -0
  10. package/corpus/templates/design/app/lib/embed-chrome.ts +70 -0
  11. package/corpus/templates/design/app/lib/shell-design.ts +113 -0
  12. package/corpus/templates/design/app/pages/design-editor/code-layer-state.ts +89 -0
  13. package/corpus/templates/design/app/pages/design-editor/nudge-intent.ts +85 -12
  14. package/corpus/templates/design/app/pages/design-editor/pending-edits.ts +43 -19
  15. package/corpus/templates/design/app/pages/design-editor/screen-command-utils.ts +9 -2
  16. package/corpus/templates/design/app/pages/design-editor/tool-state.ts +13 -0
  17. package/corpus/templates/design/app/root.tsx +23 -1
  18. package/corpus/templates/design/server/lib/fusion-screens.ts +17 -1
  19. package/corpus/templates/design/server/plugins/builder-host-embed-headers.ts +37 -0
  20. package/corpus/templates/design/server/routes/[...page].get.ts +1 -0
  21. package/corpus/templates/design/shared/builder-preview-url.ts +113 -0
  22. package/corpus/templates/design/shared/full-app.ts +19 -0
  23. package/corpus/templates/design/shared/shell-screens.ts +139 -0
  24. package/corpus/templates/design/shared/source-mode.ts +10 -0
  25. package/dist/client/RuntimeConfigNotice.js +3 -0
  26. package/dist/client/api-surface.d.ts +19 -0
  27. package/dist/client/api-surface.js +32 -0
  28. package/dist/client/application-state.js +4 -0
  29. package/dist/client/builder-frame.d.ts +6 -0
  30. package/dist/client/builder-frame.js +1 -1
  31. package/dist/client/client-status-requests.js +5 -0
  32. package/dist/client/host/index.d.ts +1 -0
  33. package/dist/client/host/index.js +1 -0
  34. package/dist/client/use-action.d.ts +1 -1
  35. package/dist/client/use-action.js +17 -0
  36. package/dist/client/use-session.js +5 -0
  37. package/dist/collab/awareness.d.ts +2 -2
  38. package/dist/collab/struct-routes.d.ts +1 -1
  39. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  40. package/dist/observability/routes.d.ts +1 -1
  41. package/dist/progress/routes.d.ts +1 -1
  42. package/dist/provider-api/actions/custom-provider-registration.d.ts +13 -13
  43. package/dist/provider-api/actions/provider-api.d.ts +6 -6
  44. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  45. package/dist/resources/handlers.d.ts +1 -1
  46. package/dist/server/realtime-token.d.ts +1 -1
  47. package/dist/server/transcribe-voice.d.ts +1 -1
  48. package/package.json +1 -1
  49. /package/corpus/templates/design/app/routes/{visual-edit.$id.tsx → visual-edit_.$id.tsx} +0 -0
package/corpus/README.md CHANGED
@@ -31,4 +31,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
31
31
 
32
32
  ## Generated Counts
33
33
 
34
- - template files: 8292
34
+ - template files: 8299
@@ -7,6 +7,7 @@ import { useT } from "@agent-native/core/client/i18n";
7
7
  import { type ReviewThread } from "@agent-native/core/client/review";
8
8
  import type { ReviewComment } from "@agent-native/core/review";
9
9
  import { injectDocumentMarkup } from "@agent-native/core/shared";
10
+ import { isLoopbackPreviewAllowed } from "@shared/builder-preview-url";
10
11
  import {
11
12
  DEFAULT_CANVAS_MAX_ZOOM,
12
13
  DEFAULT_CANVAS_MIN_ZOOM,
@@ -39,6 +40,7 @@ import {
39
40
  import { toast } from "sonner";
40
41
 
41
42
  import { Button } from "@/components/ui/button";
43
+ import { Spinner } from "@/components/ui/spinner";
42
44
  // NOTE: This wires up the NEW shared visual-editor DrawOverlay + comment-pin
43
45
  // components from `@/components/visual-editor`. The legacy iframe-only
44
46
  // DrawOverlay at `./DrawOverlay.tsx` is intentionally NOT used here — both
@@ -150,8 +152,15 @@ function isAllowedFusionOrigin(
150
152
  } catch {
151
153
  return false;
152
154
  }
153
- // Only allow secure (https) Builder origins.
154
- if (protocol !== "https:") return false;
155
+ // Only allow secure (https) Builder origins. Loopback is a development-only
156
+ // exception: without it every bridge message from a local proxy is dropped.
157
+ if (protocol !== "https:") {
158
+ const loopbackDev =
159
+ isLoopbackPreviewAllowed() &&
160
+ (host === "localhost" || host === "127.0.0.1" || host === "[::1]");
161
+ if (!loopbackDev) return false;
162
+ return true;
163
+ }
155
164
  // Exact match against the configured fusion URL's origin.
156
165
  if (fusionUrl) {
157
166
  try {
@@ -1401,27 +1410,27 @@ export function DesignCanvas({
1401
1410
  );
1402
1411
  const onExternalContentSnapshotRef = useRef(onExternalContentSnapshot);
1403
1412
  const isEmbeddedFrame = Boolean(embeddedFrame);
1404
- // Resolve the URL to render in the iframe:
1405
- // 1. When sourceType === "fusion" and fusionUrl is set, prefer the explicit
1406
- // Builder-hosted URL over whatever is in `content` (which may still be the
1407
- // original inline HTML).
1408
- // 2. Otherwise fall back to the content-based URL detection (handles the case
1409
- // where the branch URL has been written into the design file content, or
1410
- // where the localhost URL is the file content).
1413
+ // The screen's own URL wins: it carries the route path and may address the
1414
+ // container through a same-origin proxy. `fusionUrl` only covers fusion
1415
+ // screens whose content is still the original inline HTML.
1411
1416
  const rawExternalPreviewUrl = useMemo(() => {
1417
+ const contentUrl = getExternalPreviewUrl(renderedContent);
1418
+ if (contentUrl) return contentUrl;
1412
1419
  if (sourceType === "fusion" && fusionUrl) {
1413
1420
  try {
1414
1421
  const url = new URL(fusionUrl);
1415
1422
  url.hash = "";
1416
1423
  return url.toString();
1417
1424
  } catch {
1418
- // fall through to content detection below
1425
+ // coercion-ok: an unparseable URL has no frame to render; the screen
1426
+ // falls back to its own content, as it did before any fusion linkage.
1427
+ return null;
1419
1428
  }
1420
1429
  }
1421
- return getExternalPreviewUrl(renderedContent);
1430
+ return null;
1422
1431
  }, [fusionUrl, renderedContent, sourceType]);
1423
1432
  const runtimeLayerSnapshotEnabled =
1424
- sourceType === "localhost" &&
1433
+ (sourceType === "localhost" || sourceType === "fusion") &&
1425
1434
  Boolean(rawExternalPreviewUrl) &&
1426
1435
  Boolean(onRuntimeLayerSnapshot);
1427
1436
  const activeExternalSnapshotHtml =
@@ -1606,6 +1615,117 @@ export function DesignCanvas({
1606
1615
  runtimeReplacementContentRef.current = runtimeReplacementContent;
1607
1616
  runtimeReplacementKeyRef.current = runtimeReplacementKey;
1608
1617
 
1618
+ // A framed container has no dev-server bridge to register the editor chrome
1619
+ // with, so it goes over postMessage to the bootstrap its proxy injects.
1620
+ const containerPreview = useMemo(() => {
1621
+ // Not the localhost live-edit path: there the bridge server injects the
1622
+ // bridge into its own proxied document, so nothing is posted from here.
1623
+ if (usesLiveEditInjectedBridge) return false;
1624
+ if (!externalPreviewUrl || typeof window === "undefined") return false;
1625
+ try {
1626
+ return (
1627
+ new URL(externalPreviewUrl, window.location.href).origin !==
1628
+ window.location.origin
1629
+ );
1630
+ // coercion-ok: an unparseable URL frames nothing to bridge into.
1631
+ } catch {
1632
+ return false;
1633
+ }
1634
+ }, [externalPreviewUrl, usesLiveEditInjectedBridge]);
1635
+ const installedBridgeKeyRef = useRef<string | null>(null);
1636
+ const sendBridgeToContainer = useCallback(() => {
1637
+ if (!containerPreview || !includeLiveEditEditorChrome) {
1638
+ console.log("[design:bridge] install skipped", {
1639
+ containerPreview,
1640
+ includeLiveEditEditorChrome,
1641
+ });
1642
+ return;
1643
+ }
1644
+ const target = iframeRef.current?.contentWindow;
1645
+ if (!target || !externalPreviewUrl) {
1646
+ console.log("[design:bridge] install skipped", {
1647
+ hasTarget: Boolean(target),
1648
+ externalPreviewUrl,
1649
+ });
1650
+ return;
1651
+ }
1652
+ if (installedBridgeKeyRef.current === liveEditBridgeKey) return;
1653
+ let origin: string;
1654
+ try {
1655
+ origin = new URL(externalPreviewUrl, window.location.href).origin;
1656
+ // coercion-ok: without a parseable origin there is nowhere safe to post.
1657
+ } catch {
1658
+ return;
1659
+ }
1660
+ try {
1661
+ // Never "*": the bridge carries editor internals, and the container is
1662
+ // the only window that should receive it.
1663
+ target.postMessage(
1664
+ {
1665
+ type: "agentNative.installBridge",
1666
+ key: liveEditBridgeKey,
1667
+ script: liveEditBridgeScript,
1668
+ },
1669
+ origin,
1670
+ );
1671
+ } catch (error) {
1672
+ // Leave the key unmarked: `bridgeReady` drives the real install, and
1673
+ // marking a failed attempt disables the bridge for the document's life.
1674
+ console.error("[design:bridge] install post threw", origin, error);
1675
+ return;
1676
+ }
1677
+ console.log(
1678
+ "[design:bridge] install posted from",
1679
+ window.location.origin,
1680
+ "to",
1681
+ origin,
1682
+ );
1683
+ installedBridgeKeyRef.current = liveEditBridgeKey;
1684
+ }, [
1685
+ containerPreview,
1686
+ externalPreviewUrl,
1687
+ includeLiveEditEditorChrome,
1688
+ liveEditBridgeKey,
1689
+ liveEditBridgeScript,
1690
+ ]);
1691
+ // The bootstrap announces itself on every document load, which is also how a
1692
+ // reload or in-frame navigation asks for the bridge again.
1693
+ useEffect(() => {
1694
+ if (!containerPreview) return;
1695
+ function onBootstrapMessage(event: MessageEvent) {
1696
+ if (event.source !== iframeRef.current?.contentWindow) return;
1697
+ // A failed install must not read as an installed one, or the retry below
1698
+ // never fires and the canvas silently loses selection and inline editing.
1699
+ if (event.data?.type === "agentNative.bridgeFailed") {
1700
+ installedBridgeKeyRef.current = null;
1701
+ console.error(
1702
+ "[design] editor bridge failed to install in the preview:",
1703
+ event.data?.message,
1704
+ );
1705
+ return;
1706
+ }
1707
+ if (event.data?.type === "agentNative.bridgeRejected") {
1708
+ installedBridgeKeyRef.current = null;
1709
+ console.error(
1710
+ `[design] the preview container refused the editor bridge from ${window.location.origin}. ` +
1711
+ "Containers install it only for https://design.agent-native.com, so selection " +
1712
+ "and inline editing are dead anywhere else.",
1713
+ );
1714
+ return;
1715
+ }
1716
+ if (event.data?.type === "agentNative.bridgeInstalled") {
1717
+ console.log("[design:bridge] installed", event.data?.key);
1718
+ return;
1719
+ }
1720
+ if (event.data?.type !== "agentNative.bridgeReady") return;
1721
+ console.log("[design:bridge] container reported ready");
1722
+ installedBridgeKeyRef.current = null;
1723
+ sendBridgeToContainer();
1724
+ }
1725
+ window.addEventListener("message", onBootstrapMessage);
1726
+ return () => window.removeEventListener("message", onBootstrapMessage);
1727
+ }, [containerPreview, sendBridgeToContainer]);
1728
+
1609
1729
  useEffect(() => {
1610
1730
  onExternalContentSnapshotRef.current = onExternalContentSnapshot;
1611
1731
  }, [onExternalContentSnapshot]);
@@ -2336,6 +2456,23 @@ export function DesignCanvas({
2336
2456
  // long tasks. Memoizing on the (already-stable) `srcdoc` reference
2337
2457
  // makes unrelated re-renders skip this entirely.
2338
2458
  const srcdocHash = useMemo(() => contentHash(srcdoc ?? ""), [srcdoc]);
2459
+ /**
2460
+ * A container we framed ourselves is cross-origin, so its messages match
2461
+ * neither `parentOrigin` nor the localhost bridge origin. Without its origin
2462
+ * here every selection, hover and layer message from the editor bridge is
2463
+ * dropped as untrusted while the bootstrap handshake still succeeds.
2464
+ */
2465
+ const canvasBridgeAllowedOrigins = useMemo(
2466
+ () =>
2467
+ [
2468
+ sourceType === "localhost" && bridgeUrl
2469
+ ? originFromUrl(bridgeUrl)
2470
+ : null,
2471
+ externalPreviewUrl ? originFromUrl(externalPreviewUrl) : null,
2472
+ ].filter((origin): origin is string => Boolean(origin)),
2473
+ [bridgeUrl, externalPreviewUrl, sourceType],
2474
+ );
2475
+
2339
2476
  const iframeDocumentIdentity = externalPreviewUrl
2340
2477
  ? `src:${externalPreviewUrl}`
2341
2478
  : waitingForLiveEditBridge
@@ -2345,6 +2482,12 @@ export function DesignCanvas({
2345
2482
  previousIframeDocumentIdentityRef.current = iframeDocumentIdentity;
2346
2483
  bridgeReadyRef.current = false;
2347
2484
  }
2485
+ // Only a URL-backed frame boots: srcdoc paints synchronously, so gating it on
2486
+ // an onLoad that already fired would strand a spinner over finished content.
2487
+ const [previewFrameLoaded, setPreviewFrameLoaded] = useState(false);
2488
+ useEffect(() => {
2489
+ setPreviewFrameLoaded(false);
2490
+ }, [iframeDocumentIdentity]);
2348
2491
  // No snapshot is ever painted over the live frame, not even for the few
2349
2492
  // frames of a document swap. Covering the real iframe with a frozen copy is
2350
2493
  // the same false-success shape as rendering the snapshot outright: when the
@@ -2354,6 +2497,12 @@ export function DesignCanvas({
2354
2497
  usesLiveEditEditorBridge &&
2355
2498
  Boolean(externalPreviewUrl) &&
2356
2499
  readyIframeDocumentIdentity !== iframeDocumentIdentity;
2500
+ // A proxied container paints its own app immediately, so without this the
2501
+ // canvas looks ready while hover, selection and layers are still dead.
2502
+ const sameOriginBridgePending =
2503
+ containerPreview &&
2504
+ includeLiveEditEditorChrome &&
2505
+ readyIframeDocumentIdentity !== iframeDocumentIdentity;
2357
2506
 
2358
2507
  // Listen for messages from the iframe
2359
2508
  useEffect(() => {
@@ -2370,12 +2519,7 @@ export function DesignCanvas({
2370
2519
  origin: e.origin,
2371
2520
  iframeWindow: runtimeVerificationWindow,
2372
2521
  parentOrigin: window.location.origin,
2373
- allowedOrigins:
2374
- sourceType === "localhost" && bridgeUrl
2375
- ? [originFromUrl(bridgeUrl)].filter((origin): origin is string =>
2376
- Boolean(origin),
2377
- )
2378
- : [],
2522
+ allowedOrigins: canvasBridgeAllowedOrigins,
2379
2523
  });
2380
2524
  if (trustedRuntimeVerificationFrame) {
2381
2525
  if (e.data?.type !== "agent-native:runtime-layer-snapshot") return;
@@ -2402,16 +2546,11 @@ export function DesignCanvas({
2402
2546
  e.data?.type === "agent-native:editor-chrome-ready"
2403
2547
  ? lateLiveEditReadyRecoveryRef.current
2404
2548
  : null;
2405
- // For fusion sources the Builder-hosted app is cross-origin, so the strict
2406
- // `origin === parentOrigin` check can never match. We still require window
2407
- // identity (the message must come from our own iframe window, not any
2408
- // arbitrary cross-origin frame), AND we validate the message origin
2409
- // against a Builder-host allowlist (the configured fusionUrl origin or the
2410
- // *.builder.io family) before relaxing the origin check. If the origin is
2411
- // not on the allowlist we keep the strict check so a hostile frame that
2412
- // somehow shares our window reference still can't be trusted.
2549
+ // A cross-origin fusion frame can never satisfy `origin === parentOrigin`,
2550
+ // so trust there rests on window identity plus a Builder-host allowlist.
2551
+ // A proxied one is same-origin and takes the strict path unchanged.
2413
2552
  const trustedCurrentFrame =
2414
- sourceType === "fusion"
2553
+ sourceType === "fusion" && e.origin !== window.location.origin
2415
2554
  ? iframeWindow !== null &&
2416
2555
  e.source === iframeWindow &&
2417
2556
  isAllowedFusionOrigin(e.origin, fusionUrl)
@@ -2420,12 +2559,7 @@ export function DesignCanvas({
2420
2559
  origin: e.origin,
2421
2560
  iframeWindow,
2422
2561
  parentOrigin: window.location.origin,
2423
- allowedOrigins:
2424
- sourceType === "localhost" && bridgeUrl
2425
- ? [originFromUrl(bridgeUrl)].filter(
2426
- (origin): origin is string => Boolean(origin),
2427
- )
2428
- : [],
2562
+ allowedOrigins: canvasBridgeAllowedOrigins,
2429
2563
  });
2430
2564
  const trustedLateLiveEditReady =
2431
2565
  sourceType === "localhost" &&
@@ -2436,9 +2570,7 @@ export function DesignCanvas({
2436
2570
  origin: e.origin,
2437
2571
  iframeWindow: lateReadyRecovery.source,
2438
2572
  parentOrigin: window.location.origin,
2439
- allowedOrigins: [originFromUrl(bridgeUrl)].filter(
2440
- (origin): origin is string => Boolean(origin),
2441
- ),
2573
+ allowedOrigins: canvasBridgeAllowedOrigins,
2442
2574
  });
2443
2575
  const trusted = trustedCurrentFrame || trustedLateLiveEditReady;
2444
2576
  if (!trusted) {
@@ -4445,6 +4577,8 @@ export function DesignCanvas({
4445
4577
  })}
4446
4578
  data-design-preview-iframe
4447
4579
  onLoad={(event) => {
4580
+ setPreviewFrameLoaded(true);
4581
+ sendBridgeToContainer();
4448
4582
  // The bridge logs into the IFRAME console and cannot read
4449
4583
  // import.meta.env, so dev has to switch it on from out here.
4450
4584
  if (!import.meta.env?.DEV) return;
@@ -4478,6 +4612,14 @@ export function DesignCanvas({
4478
4612
  title={t("designEditor.designPreview")}
4479
4613
  />
4480
4614
  )}
4615
+ {externalPreviewUrl && !previewFrameLoaded ? (
4616
+ <div className="pointer-events-none absolute inset-0 z-40 flex items-center justify-center gap-2 bg-background px-2 text-muted-foreground">
4617
+ <Spinner className="size-4 shrink-0" />
4618
+ <span className="truncate !text-[11px] font-medium">
4619
+ {t("multiScreenCanvas.preparingLiveEditor")}
4620
+ </span>
4621
+ </div>
4622
+ ) : null}
4481
4623
  {runtimeVerificationUrl ? (
4482
4624
  <iframe
4483
4625
  key={`${runtimeVerificationUrl}::${runtimeVerificationRequest?.requestId ?? 0}`}
@@ -4573,6 +4715,7 @@ export function DesignCanvas({
4573
4715
  ) : null}
4574
4716
  {waitingForEditableExternalSnapshot ||
4575
4717
  waitingForLiveEditBridge ||
4718
+ sameOriginBridgePending ||
4576
4719
  liveEditDocumentPending ? (
4577
4720
  <div className="pointer-events-auto absolute inset-0 z-10 flex items-center justify-center bg-background/85 px-4 text-center text-sm text-muted-foreground">
4578
4721
  {waitingForLiveEditBridge &&
@@ -4609,7 +4752,7 @@ export function DesignCanvas({
4609
4752
  {"Retry" /* i18n-ignore local dev bridge retry button */}
4610
4753
  </Button>
4611
4754
  </div>
4612
- ) : waitingForLiveEditBridge ? (
4755
+ ) : waitingForLiveEditBridge || sameOriginBridgePending ? (
4613
4756
  <div className="max-w-[28rem] rounded-md border bg-card px-4 py-3 shadow-sm">
4614
4757
  {
4615
4758
  "Preparing live editor..." /* i18n-ignore transient localhost live-edit bridge loading state */
@@ -45,6 +45,7 @@ import {
45
45
  type PenNode,
46
46
  type PenPath,
47
47
  } from "@shared/pen-path";
48
+ import { isRunningAppSourceType } from "@shared/source-mode";
48
49
  import {
49
50
  IconCopy,
50
51
  IconDots,
@@ -7998,6 +7999,11 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
7998
7999
  ? canvasFrames.find((entry) => entry.screen.id === singleSelectedFrame.id)
7999
8000
  ?.screen
8000
8001
  : undefined;
8002
+ const singleSelectedFrameIsRunningApp = singleSelectedFrameScreen
8003
+ ? isRunningAppSourceType(
8004
+ getResolvedMetadata(singleSelectedFrameScreen).source,
8005
+ )
8006
+ : false;
8001
8007
  // Overview element selection (a Layers-panel row or an in-canvas click
8002
8008
  // resolving to a specific node) also suppresses the frame box: the parent
8003
8009
  // still carries the screen in `selectedIds` (other UI — z-order, "topmost
@@ -8382,8 +8388,13 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
8382
8388
  onStartRotate={(event) =>
8383
8389
  beginRotate(singleSelectedFrame.id, event)
8384
8390
  }
8385
- onStartDrag={(event) =>
8386
- beginFrameDrag(singleSelectedFrame.id, event)
8391
+ onStartDrag={
8392
+ // A running app's frame is dragged by its label. Blanketing its
8393
+ // content with a drag surface would make the app unclickable the
8394
+ // moment it is selected, which is most of the time.
8395
+ singleSelectedFrameIsRunningApp
8396
+ ? undefined
8397
+ : (event) => beginFrameDrag(singleSelectedFrame.id, event)
8387
8398
  }
8388
8399
  />
8389
8400
  ) : null}
@@ -9720,9 +9731,13 @@ const Screen = memo(function Screen({
9720
9731
  !isSelected &&
9721
9732
  !groupSelected &&
9722
9733
  !suppressFrameChromeForChild;
9734
+ // A running app's `content` is its URL, so it never "has child layers" — but
9735
+ // its live DOM does, and that DOM is the only thing there is to select.
9723
9736
  const screenContentInteractive =
9724
9737
  Boolean(screenContent) &&
9725
- (isSelected || hasScreenChildLayers(screen.content)) &&
9738
+ (isSelected ||
9739
+ isRunningAppSourceType(metadata.source) ||
9740
+ hasScreenChildLayers(screen.content)) &&
9726
9741
  !locked &&
9727
9742
  !penActive &&
9728
9743
  !creationToolActive &&
@@ -20,6 +20,7 @@ import { useLocation } from "react-router";
20
20
 
21
21
  import { useNavigationState } from "@/hooks/use-navigation-state";
22
22
  import { DESIGN_CHAT_STORAGE_KEY } from "@/lib/agent-chat";
23
+ import { isBuilderHostEmbed } from "@/lib/builder-host-origin";
23
24
  import {
24
25
  designEditorRoute,
25
26
  isDesignEditorRoute,
@@ -59,7 +60,9 @@ export function Layout({ children }: LayoutProps) {
59
60
  const t = useT();
60
61
  const { session } = useSession();
61
62
  const hasSession = Boolean(session?.email);
62
- const embedded = isEmbedAuthActive();
63
+ // The shell canvas is embedded without a session, so this cannot be the token
64
+ // check alone or it renders Design's own nav inside Builder.
65
+ const embedded = isBuilderHostEmbed() || isEmbedAuthActive();
63
66
  useNavigationState(hasSession);
64
67
  const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
65
68
  const openMobileSidebar = useCallback(() => setMobileSidebarOpen(true), []);
@@ -12,6 +12,7 @@ export interface NavigationState {
12
12
  designSystemId?: string;
13
13
  templateId?: string;
14
14
  editorView?: "single" | "overview";
15
+ mode?: "edit" | "annotate" | "interact";
15
16
  inspectorTab?: "design" | "comments" | "tweaks" | "code" | "extensions";
16
17
  inspector?: "design" | "comments" | "tweaks" | "code" | "extensions";
17
18
  leftPanel?:
@@ -152,6 +153,14 @@ function normalizeLeftPanel(
152
153
  : undefined;
153
154
  }
154
155
 
156
+ function normalizeEditorMode(
157
+ value: unknown,
158
+ ): "edit" | "annotate" | "interact" | undefined {
159
+ return value === "edit" || value === "annotate" || value === "interact"
160
+ ? value
161
+ : undefined;
162
+ }
163
+
155
164
  function normalizeDesignTool(value: unknown): string | undefined {
156
165
  return typeof value === "string" &&
157
166
  DESIGN_EDITOR_TOOLS.includes(value as (typeof DESIGN_EDITOR_TOOLS)[number])
@@ -166,7 +175,7 @@ export function editorPathFromCommand(cmd: NavigationState): string | null {
166
175
  const params = new URLSearchParams();
167
176
  const editorView = normalizeEditorView(cmd.editorView);
168
177
  if (editorView) params.set("view", editorView);
169
- if (editorView === "single") params.set("mode", "interact");
178
+ if (editorView === "single") params.set("mode", cmd.mode ?? "interact");
170
179
  const inspectorTab = normalizeInspectorTab(cmd.inspectorTab ?? cmd.inspector);
171
180
  if (inspectorTab) params.set("inspector", inspectorTab);
172
181
  const leftPanel = normalizeLeftPanel(cmd.leftPanel ?? cmd.panel);
@@ -202,7 +211,7 @@ export function editorCommandFromNavigate(
202
211
  path,
203
212
  };
204
213
  if (editorView) command.editorView = editorView;
205
- if (editorView === "single") command.mode = "interact";
214
+ if (editorView === "single") command.mode = cmd.mode ?? "interact";
206
215
  if (inspectorTab) command.inspectorTab = inspectorTab;
207
216
  if (leftPanel) command.leftPanel = leftPanel;
208
217
  if (cmd.fileId) command.fileId = cmd.fileId;
@@ -244,6 +253,8 @@ export function useNavigationState(enabled = true) {
244
253
  state.designId = params.id;
245
254
  const editorView = normalizeEditorView(searchParams.get("view"));
246
255
  if (editorView) state.editorView = editorView;
256
+ const mode = normalizeEditorMode(searchParams.get("mode"));
257
+ if (mode) state.mode = mode;
247
258
  const inspectorTab = normalizeInspectorTab(
248
259
  searchParams.get("inspector"),
249
260
  );
@@ -1177,6 +1177,7 @@ const enUS = {
1177
1177
  multiScreenCanvas: {
1178
1178
  addBreakpointToAllScreens:
1179
1179
  "Add {{label}} breakpoint ({{width}}px) to all screens",
1180
+ preparingLiveEditor: "Preparing the live editor…",
1180
1181
  duplicate: "Duplicate",
1181
1182
  fork: "Fork",
1182
1183
  fullView: "Full view",
@@ -8054,6 +8055,7 @@ const designModeFeatureOverrides = {
8054
8055
  },
8055
8056
  },
8056
8057
  multiScreenCanvas: {
8058
+ preparingLiveEditor: "正在準備即時編輯器…",
8057
8059
  addBreakpointToAllScreens:
8058
8060
  "為所有畫面新增 {{label}} 中斷點({{width}}px)",
8059
8061
  duplicate: "複製",
@@ -8226,6 +8228,7 @@ const designModeFeatureOverrides = {
8226
8228
  },
8227
8229
  },
8228
8230
  multiScreenCanvas: {
8231
+ preparingLiveEditor: "正在准备实时编辑器…",
8229
8232
  addBreakpointToAllScreens: "为所有画面添加 {{label}} 断点({{width}}px)",
8230
8233
  duplicate: "复制",
8231
8234
  fork: "分支",
@@ -8398,6 +8401,7 @@ const designModeFeatureOverrides = {
8398
8401
  },
8399
8402
  },
8400
8403
  multiScreenCanvas: {
8404
+ preparingLiveEditor: "Preparando el editor en vivo…",
8401
8405
  addBreakpointToAllScreens:
8402
8406
  "Añadir punto de ruptura {{label}} ({{width}}px) a todas las pantallas",
8403
8407
  duplicate: "Duplicar",
@@ -8592,6 +8596,7 @@ const designModeFeatureOverrides = {
8592
8596
  },
8593
8597
  },
8594
8598
  multiScreenCanvas: {
8599
+ preparingLiveEditor: "Préparation de l'éditeur en direct…",
8595
8600
  addBreakpointToAllScreens:
8596
8601
  "Ajouter le point de rupture {{label}} ({{width}}px) à tous les écrans",
8597
8602
  duplicate: "Dupliquer",
@@ -8783,6 +8788,7 @@ const designModeFeatureOverrides = {
8783
8788
  },
8784
8789
  },
8785
8790
  multiScreenCanvas: {
8791
+ preparingLiveEditor: "Live-Editor wird vorbereitet…",
8786
8792
  addBreakpointToAllScreens:
8787
8793
  "{{label}}-Breakpoint ({{width}}px) zu allen Screens hinzufügen",
8788
8794
  duplicate: "Duplizieren",
@@ -8979,6 +8985,7 @@ const designModeFeatureOverrides = {
8979
8985
  },
8980
8986
  },
8981
8987
  multiScreenCanvas: {
8988
+ preparingLiveEditor: "ライブエディターを準備しています…",
8982
8989
  addBreakpointToAllScreens:
8983
8990
  "すべての画面に {{label}} ブレークポイント({{width}}px)を追加",
8984
8991
  duplicate: "複製",
@@ -9170,6 +9177,7 @@ const designModeFeatureOverrides = {
9170
9177
  },
9171
9178
  },
9172
9179
  multiScreenCanvas: {
9180
+ preparingLiveEditor: "실시간 편집기를 준비하는 중…",
9173
9181
  addBreakpointToAllScreens:
9174
9182
  "모든 화면에 {{label}} 중단점({{width}}px) 추가",
9175
9183
  duplicate: "복제",
@@ -9358,6 +9366,7 @@ const designModeFeatureOverrides = {
9358
9366
  },
9359
9367
  },
9360
9368
  multiScreenCanvas: {
9369
+ preparingLiveEditor: "Preparando o editor ao vivo…",
9361
9370
  addBreakpointToAllScreens:
9362
9371
  "Adicionar ponto de quebra {{label}} ({{width}}px) a todas as telas",
9363
9372
  duplicate: "Duplicar",
@@ -9546,6 +9555,7 @@ const designModeFeatureOverrides = {
9546
9555
  },
9547
9556
  },
9548
9557
  multiScreenCanvas: {
9558
+ preparingLiveEditor: "लाइव एडिटर तैयार किया जा रहा है…",
9549
9559
  addBreakpointToAllScreens:
9550
9560
  "सभी स्क्रीन में {{label}} ब्रेकपॉइंट ({{width}}px) जोड़ें",
9551
9561
  duplicate: "डुप्लिकेट",
@@ -9734,6 +9744,7 @@ const designModeFeatureOverrides = {
9734
9744
  },
9735
9745
  },
9736
9746
  multiScreenCanvas: {
9747
+ preparingLiveEditor: "جارٍ تحضير المحرر المباشر…",
9737
9748
  addBreakpointToAllScreens:
9738
9749
  "إضافة نقطة توقف {{label}} ({{width}}px) إلى جميع الشاشات",
9739
9750
  duplicate: "تكرار",
@@ -5,6 +5,13 @@ import {
5
5
  type AgentChatMessage,
6
6
  type SendToAgentChatAndConfirmResult,
7
7
  } from "@agent-native/core/client/agent-chat";
8
+ import { sendToBuilderChat } from "@agent-native/core/client/host";
9
+
10
+ import {
11
+ getVerifiedBuilderHostOrigin,
12
+ isBuilderHostEmbed,
13
+ } from "./builder-host-origin";
14
+ import { isEmbedChromeRequested } from "./embed-chrome";
8
15
 
9
16
  export const DESIGN_CHAT_STORAGE_KEY = "design";
10
17
 
@@ -39,6 +46,12 @@ export function sendToDesignAgentChatAndConfirm(
39
46
  export interface DesignSourceHandoffResult {
40
47
  target: "host" | "local";
41
48
  delivered: boolean;
49
+ /**
50
+ * The host prefilled its composer and the user has not sent it yet. Callers
51
+ * must not treat this as applied: discarding pending edits here loses work
52
+ * the agent was never asked to do.
53
+ */
54
+ staged?: boolean;
42
55
  reason?: string;
43
56
  tabId?: string;
44
57
  }
@@ -58,6 +71,23 @@ export async function sendDesignSourceHandoffAndConfirm(
58
71
  opts: AgentChatMessage,
59
72
  options?: { timeoutMs?: number },
60
73
  ): Promise<DesignSourceHandoffResult> {
74
+ // `embedChrome` alone is a display preference any embedder can ask for, so the
75
+ // shell route is what decides who receives source-edit context. `submit` is
76
+ // the host's call there, and its composer filling is the delivery evidence.
77
+ if (isEmbedChromeRequested() && isBuilderHostEmbed()) {
78
+ const posted = sendToBuilderChat({
79
+ message: opts.message,
80
+ context: opts.context,
81
+ submit: false,
82
+ targetOrigin: getVerifiedBuilderHostOrigin() ?? undefined,
83
+ });
84
+ return {
85
+ target: "host",
86
+ delivered: posted,
87
+ ...(posted ? { staged: true } : { reason: "host-post-failed" }),
88
+ };
89
+ }
90
+
61
91
  const hostDelivery = sendMcpAppHostMessage({
62
92
  message: opts.message,
63
93
  context: opts.context,
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Hand selection and pending visual edits to the embedding host's chat instead
3
+ * of the editor's own agent.
4
+ *
5
+ * `submit: false` throughout: the host prefills its composer so the user can
6
+ * say what they actually want before the turn is spent.
7
+ */
8
+
9
+ import { sendToBuilderChat } from "@agent-native/core/client/host";
10
+
11
+ /** The host's chip syntax is attribute-quoted, so a stray `"` truncates it. */
12
+ function chipAttribute(value: string, max: number): string {
13
+ return value.replace(/\s+/g, " ").replace(/"/g, "'").trim().slice(0, max);
14
+ }
15
+
16
+ export function builderSelectionChip(args: {
17
+ label: string;
18
+ detail: string;
19
+ }): string {
20
+ return `<chip text="${chipAttribute(args.label, 80)}" detail="${chipAttribute(
21
+ args.detail,
22
+ 240,
23
+ )}" />`;
24
+ }
25
+
26
+ export function sendBuilderSelectionContext(args: {
27
+ label: string;
28
+ detail: string;
29
+ }): boolean {
30
+ return sendToBuilderChat({
31
+ message: `${builderSelectionChip(args)} `,
32
+ submit: false,
33
+ });
34
+ }
@@ -0,0 +1,31 @@
1
+ import { SHELL_CANVAS_PATH } from "@shared/shell-screens";
2
+
3
+ /**
4
+ * The parent origin the Builder handshake arrived from, recorded once a
5
+ * `design:init` message passes the host check.
6
+ *
7
+ * Origin sniffing cannot confirm a Builder running on localhost — every local
8
+ * dev session — so the handshake is the only signal that works in both.
9
+ */
10
+ let verifiedBuilderHostOrigin: string | null = null;
11
+
12
+ export function rememberBuilderHostOrigin(origin: string): void {
13
+ if (origin) verifiedBuilderHostOrigin = origin;
14
+ }
15
+
16
+ export function getVerifiedBuilderHostOrigin(): string | null {
17
+ return verifiedBuilderHostOrigin;
18
+ }
19
+
20
+ /**
21
+ * True on the host-driven canvas. The route is the whole signal now: there is no
22
+ * token to inspect, and this picks chrome, never access.
23
+ */
24
+ export function isBuilderHostEmbed(): boolean {
25
+ if (typeof window === "undefined") return false;
26
+ return window.location.pathname === SHELL_CANVAS_PATH;
27
+ }
28
+
29
+ export function _resetBuilderHostEmbedForTests(): void {
30
+ verifiedBuilderHostOrigin = null;
31
+ }