From efd0735f1754c3e8e29070414d941072d8858e04 Mon Sep 17 00:00:00 2001 From: Steve Sewell Date: Thu, 13 Aug 2026 09:07:46 -0400 Subject: [PATCH 01/70] =?UTF-8?q?chore:=20publish=20branch=20work=20in=20.?= =?UTF-8?q?changeset/brand-kit-motion-token-type.md,=20.changeset/chat-ima?= =?UTF-8?q?ge-lightbox.md,=20.changeset/compact-share-dialog.md,=20?= =?UTF-8?q?=E2=80=A6=20(179=20files)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/brand-kit-motion-token-type.md | 7 + .changeset/chat-image-lightbox.md | 5 + .changeset/compact-share-dialog.md | 5 + .../durable-github-design-system-sources.md | 5 + .changeset/extension-shell-x-cloak.md | 8 + .changeset/fix-native-sqlite-runtime.md | 5 + .changeset/guided-questions-submit-context.md | 8 + .changeset/steady-chat-replay.md | 5 + package.json | 2 +- packages/core/src/agent/production-agent.ts | 2 +- packages/core/src/brand-kit/types.ts | 11 +- packages/core/src/cli/code.ts | 2 +- packages/core/src/cli/index.ts | 36 +- .../core/src/cli/native-dependencies.spec.ts | 52 ++ packages/core/src/cli/native-dependencies.ts | 381 +++++++++++ packages/core/src/cli/plan-local.ts | 2 +- .../design-visual-edit-skill.ts | 4 +- packages/core/src/cli/visualize-repo.ts | 2 +- .../src/client/AssistantChat.display.spec.ts | 10 + packages/core/src/client/FeedbackButton.tsx | 24 +- .../src/client/agent-chat-adapter.spec.ts | 31 + .../core/src/client/agent-chat-adapter.ts | 13 +- .../core/src/client/assistant-ui-recovery.tsx | 6 +- .../client/chat/message-components.spec.tsx | 120 ++++ .../src/client/chat/message-components.tsx | 101 ++- .../client/chat/tool-call-display.spec.tsx | 49 ++ .../src/client/chat/tool-call-display.tsx | 48 +- .../core/src/client/db-admin/SqlEditor.tsx | 4 +- .../src/client/dev-overlay/DevOverlay.tsx | 4 +- .../client/extensions/portable-extension.ts | 4 + .../src/client/guided-questions.flow.spec.tsx | 47 ++ packages/core/src/client/guided-questions.tsx | 22 +- .../src/client/sharing/ShareButton.spec.tsx | 66 +- .../core/src/client/sharing/ShareButton.tsx | 443 ++++++------- .../sse-event-processor.runtime.spec.tsx | 108 ++++ .../src/client/sse-event-processor.spec.ts | 38 +- .../core/src/client/sse-event-processor.ts | 66 +- packages/core/src/client/tool-display.ts | 60 ++ .../src/client/vite-dev-recovery-script.ts | 2 +- .../core/src/extensions/html-shell.spec.ts | 15 + packages/core/src/extensions/html-shell.ts | 5 + .../src/server/builder-design-systems.spec.ts | 238 ++++++- .../core/src/server/builder-design-systems.ts | 432 ++++++++++++- .../src/server/design-token-utils.spec.ts | 39 ++ .../core/src/server/design-token-utils.ts | 113 +++- packages/core/src/server/index.ts | 4 + .../shared/desktop-shortcuts.spec.ts | 13 +- .../desktop-app/shared/desktop-shortcuts.ts | 2 +- .../canvas-interactions.spec.ts | 81 ++- .../canvas-interactions.ts | 80 ++- packages/toolkit/src/composer/VoiceButton.tsx | 2 +- scripts/prebuild-workspace-packages.ts | 13 + .../app/components/layout/Sidebar.tsx | 2 +- templates/analytics/app/lib/utils.spec.ts | 29 +- templates/analytics/app/lib/utils.ts | 2 +- .../adhoc/sql-dashboard/ViewSqlPopover.tsx | 2 +- .../app/components/calendar/EventDialog.tsx | 2 +- templates/calendar/app/lib/utils.ts | 2 +- .../components/player/share-dialog.test.ts | 10 + .../clips/app/components/sharing/share-ui.tsx | 30 +- templates/clips/app/i18n/ar-SA.ts | 6 +- templates/clips/app/i18n/de-DE.ts | 6 +- templates/clips/app/i18n/en-US.ts | 6 +- templates/clips/app/i18n/es-ES.ts | 6 +- templates/clips/app/i18n/fr-FR.ts | 6 +- templates/clips/app/i18n/hi-IN.ts | 6 +- templates/clips/app/i18n/ja-JP.ts | 6 +- templates/clips/app/i18n/ko-KR.ts | 6 +- templates/clips/app/i18n/pt-BR.ts | 6 +- templates/clips/app/i18n/zh-CN.ts | 6 +- templates/clips/app/i18n/zh-TW.ts | 4 +- templates/clips/app/lib/utils.test.ts | 28 + templates/clips/app/lib/utils.ts | 11 +- templates/clips/desktop/src/app.tsx | 38 +- templates/content/app/i18n-data.ts | 20 +- templates/content/app/i18n/zh-TW.ts | 2 +- .../content/scripts/check-native-deps.mjs | 196 +++++- .../.agents/skills/design-generation/SKILL.md | 86 ++- .../.agents/skills/design-systems/SKILL.md | 16 +- .../design/actions/get-design-system.spec.ts | 65 ++ templates/design/actions/get-design-system.ts | 106 ++- .../actions/import-design-tokens.spec.ts | 30 + .../design/actions/import-design-tokens.ts | 45 +- .../design/actions/import-github.test.ts | 37 ++ templates/design/actions/import-github.ts | 170 +---- .../index-design-system-with-builder.ts | 65 +- .../actions/present-design-variants.spec.ts | 94 +++ .../design/actions/present-design-variants.ts | 97 ++- .../design/actions/run-design-audit.spec.ts | 89 +++ templates/design/actions/run-design-audit.ts | 224 +++++++ .../sync-design-system-with-builder.ts | 67 ++ .../design/code-workbench/commands.ts | 4 +- .../design/inspector/DesignColorPicker.tsx | 19 +- .../design/inspector/GradientEditor.tsx | 4 +- .../design/inspector/ImageFillControls.tsx | 4 +- .../app/hooks/use-question-flow.spec.ts | 52 ++ .../design/app/hooks/use-question-flow.ts | 86 ++- templates/design/app/i18n-data.ts | 33 +- .../design/app/i18n-keyboard-shortcuts.ts | 20 +- templates/design/app/i18n/ar-SA.ts | 2 +- templates/design/app/i18n/de-DE.ts | 2 +- templates/design/app/i18n/en-US.ts | 2 +- templates/design/app/i18n/es-ES.ts | 2 +- templates/design/app/i18n/fr-FR.ts | 2 +- templates/design/app/i18n/hi-IN.ts | 2 +- templates/design/app/i18n/ja-JP.ts | 2 +- templates/design/app/i18n/ko-KR.ts | 2 +- templates/design/app/i18n/pt-BR.ts | 2 +- templates/design/app/i18n/zh-CN.ts | 2 +- templates/design/app/i18n/zh-TW.ts | 5 +- templates/design/app/pages/DesignEditor.tsx | 39 +- .../design/app/pages/DesignSystemSetup.tsx | 94 ++- templates/design/app/pages/DesignSystems.tsx | 14 + .../generation-prompt-directives.ts | 22 + ...ender-behind-an-invisible-alpine-overla.md | 6 + ...t-and-the-design-is-reproduced-not-rein.md | 6 + ...em-means-you-never-get-generic-directio.md | 6 + ...reports-when-a-screen-ignores-its-linke.md | 6 + ...view-now-shows-how-many-named-tokens-we.md | 6 + ...ompt-and-reference-screenshots-now-reac.md | 6 + .../e2e/design-system-github-import.spec.ts | 90 +++ .../server/lib/builder-design-system-proxy.ts | 20 +- templates/design/shared/design-review.ts | 1 + .../design/shared/html-integrity.test.ts | 100 +++ templates/design/shared/html-integrity.ts | 171 +++++ templates/mail/app/lib/utils.spec.ts | 9 +- templates/mail/app/lib/utils.ts | 2 +- .../.agents/skills/design-systems/SKILL.md | 16 +- .../index-design-system-with-builder.ts | 44 +- .../sync-design-system-with-builder.ts | 67 ++ .../design-system/DesignSystemSetup.tsx | 213 +++++- .../components/editor/EditorSidebar.test.tsx | 158 +++++ .../app/components/editor/EditorSidebar.tsx | 91 ++- .../editor/EditorToolbar.layout.test.ts | 23 + .../components/editor/EditorToolbar.test.tsx | 5 +- .../app/components/editor/EditorToolbar.tsx | 69 +- .../app/components/editor/ExportMenu.test.tsx | 72 ++- .../app/components/editor/ExportMenu.tsx | 210 +++--- .../components/editor/ImageOverlay.test.tsx | 14 +- .../app/components/editor/ImageOverlay.tsx | 16 + .../SlideEditor.overflow-warning.test.tsx | 23 +- .../app/components/editor/SlideEditor.tsx | 189 +++++- .../editor/SlideOverflowWarning.tsx | 44 +- .../editor/SpeakerNotesPanel.test.tsx | 25 + .../components/editor/SpeakerNotesPanel.tsx | 4 +- .../slides-canvas-adapter.test.ts | 49 +- .../slides-canvas-adapter.ts | 5 +- ...slides-canvas-pointer-integration.test.tsx | 17 +- .../editor/rich-text-selection.test.ts | 9 + .../components/editor/rich-text-selection.ts | 14 + .../editor/slide-object-interactions.test.ts | 4 +- .../editor/slide-text-targets.test.ts | 16 + .../components/editor/slide-text-targets.ts | 24 + templates/slides/app/global.css | 24 + .../slides/app/hooks/use-navigation-state.ts | 6 +- templates/slides/app/i18n/ar-SA.ts | 10 + templates/slides/app/i18n/de-DE.ts | 10 + templates/slides/app/i18n/en-US.ts | 29 +- templates/slides/app/i18n/es-ES.ts | 10 + templates/slides/app/i18n/fr-FR.ts | 10 + templates/slides/app/i18n/hi-IN.ts | 10 + templates/slides/app/i18n/ja-JP.ts | 10 + templates/slides/app/i18n/ko-KR.ts | 10 + templates/slides/app/i18n/pt-BR.ts | 10 + templates/slides/app/i18n/zh-CN.ts | 10 + templates/slides/app/i18n/zh-TW.ts | 10 + templates/slides/app/lib/deck-filter.test.ts | 61 ++ templates/slides/app/lib/deck-filter.ts | 45 ++ .../lib/export-google-slides-client.test.ts | 57 ++ .../app/lib/export-google-slides-client.ts | 19 +- .../slides/app/lib/export-pptx-client.test.ts | 21 + .../slides/app/lib/export-pptx-client.ts | 608 +++++++++++++++++- .../slides/app/lib/image-download.test.ts | 55 ++ templates/slides/app/lib/image-download.ts | 81 +++ templates/slides/app/lib/utils.test.ts | 14 + templates/slides/app/lib/utils.ts | 2 +- templates/slides/app/pages/Index.tsx | 34 +- templates/slides/netlify.toml | 1 + .../server/lib/builder-design-system-proxy.ts | 16 +- 179 files changed, 6853 insertions(+), 1060 deletions(-) create mode 100644 .changeset/brand-kit-motion-token-type.md create mode 100644 .changeset/chat-image-lightbox.md create mode 100644 .changeset/compact-share-dialog.md create mode 100644 .changeset/durable-github-design-system-sources.md create mode 100644 .changeset/extension-shell-x-cloak.md create mode 100644 .changeset/fix-native-sqlite-runtime.md create mode 100644 .changeset/guided-questions-submit-context.md create mode 100644 .changeset/steady-chat-replay.md create mode 100644 packages/core/src/cli/native-dependencies.spec.ts create mode 100644 packages/core/src/cli/native-dependencies.ts create mode 100644 packages/core/src/client/sse-event-processor.runtime.spec.tsx create mode 100644 templates/clips/app/lib/utils.test.ts create mode 100644 templates/design/actions/sync-design-system-with-builder.ts create mode 100644 templates/design/app/hooks/use-question-flow.spec.ts create mode 100644 templates/design/changelog/2026-08-13-a-screen-that-would-render-behind-an-invisible-alpine-overla.md create mode 100644 templates/design/changelog/2026-08-13-attach-a-ui-screenshot-and-the-design-is-reproduced-not-rein.md create mode 100644 templates/design/changelog/2026-08-13-linking-a-design-system-means-you-never-get-generic-directio.md create mode 100644 templates/design/changelog/2026-08-13-the-design-audit-now-reports-when-a-screen-ignores-its-linke.md create mode 100644 templates/design/changelog/2026-08-13-the-design-system-preview-now-shows-how-many-named-tokens-we.md create mode 100644 templates/design/changelog/2026-08-13-your-design-system-prompt-and-reference-screenshots-now-reac.md create mode 100644 templates/design/e2e/design-system-github-import.spec.ts create mode 100644 templates/slides/actions/sync-design-system-with-builder.ts create mode 100644 templates/slides/app/components/editor/EditorSidebar.test.tsx create mode 100644 templates/slides/app/components/editor/SpeakerNotesPanel.test.tsx create mode 100644 templates/slides/app/lib/deck-filter.test.ts create mode 100644 templates/slides/app/lib/deck-filter.ts create mode 100644 templates/slides/app/lib/export-google-slides-client.test.ts create mode 100644 templates/slides/app/lib/image-download.test.ts create mode 100644 templates/slides/app/lib/image-download.ts create mode 100644 templates/slides/app/lib/utils.test.ts diff --git a/.changeset/brand-kit-motion-token-type.md b/.changeset/brand-kit-motion-token-type.md new file mode 100644 index 0000000000..d862b19c1e --- /dev/null +++ b/.changeset/brand-kit-motion-token-type.md @@ -0,0 +1,7 @@ +--- +"@agent-native/core": patch +--- + +Add `motion` to `BrandKitTokenType` so durations, easings, and transitions have +a real category. Extractors drop tokens they cannot classify, so the missing +bucket meant no imported design system ever carried its motion. diff --git a/.changeset/chat-image-lightbox.md b/.changeset/chat-image-lightbox.md new file mode 100644 index 0000000000..1675c51b26 --- /dev/null +++ b/.changeset/chat-image-lightbox.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Open agent chat image attachments in a full-size lightbox when their thumbnails are clicked. diff --git a/.changeset/compact-share-dialog.md b/.changeset/compact-share-dialog.md new file mode 100644 index 0000000000..db494d3dd6 --- /dev/null +++ b/.changeset/compact-share-dialog.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Simplify share controls with compact copy-link rows and always-visible access details. diff --git a/.changeset/durable-github-design-system-sources.md b/.changeset/durable-github-design-system-sources.md new file mode 100644 index 0000000000..a21f759b72 --- /dev/null +++ b/.changeset/durable-github-design-system-sources.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": minor +--- + +Support durable, scoped GitHub sources and replayable sync for Builder design-system imports. diff --git a/.changeset/extension-shell-x-cloak.md b/.changeset/extension-shell-x-cloak.md new file mode 100644 index 0000000000..96f6afe5d3 --- /dev/null +++ b/.changeset/extension-shell-x-cloak.md @@ -0,0 +1,8 @@ +--- +"@agent-native/core": patch +--- + +Hide `x-cloak` content in the extension iframe shell until Alpine boots. +Extension content is a body snippet, so it cannot define the rule itself: an +`x-cloak` overlay painted over the whole extension until the deferred Alpine +CDN script resolved, and permanently when it failed to. diff --git a/.changeset/fix-native-sqlite-runtime.md b/.changeset/fix-native-sqlite-runtime.md new file mode 100644 index 0000000000..af40ac38da --- /dev/null +++ b/.changeset/fix-native-sqlite-runtime.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Repair and validate native SQLite bindings against the Node runtime used by development, builds, and production starts. diff --git a/.changeset/guided-questions-submit-context.md b/.changeset/guided-questions-submit-context.md new file mode 100644 index 0000000000..422028c3e4 --- /dev/null +++ b/.changeset/guided-questions-submit-context.md @@ -0,0 +1,8 @@ +--- +"@agent-native/core": patch +--- + +Add an optional `submitContext` to the guided-questions payload, appended to the +context of whichever message the card sends. A question card's answer opens a +continuation turn that inherits nothing from the turn that posed it, so context +the follow-up work depends on had no way to survive the hop. diff --git a/.changeset/steady-chat-replay.md b/.changeset/steady-chat-replay.md new file mode 100644 index 0000000000..137cdc59a0 --- /dev/null +++ b/.changeset/steady-chat-replay.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Prevent assistant panel crashes when dense chat replays synchronously update React. diff --git a/package.json b/package.json index e402acb608..e05647dc8c 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "type": "module", "main": "index.js", "scripts": { - "postinstall": "node scripts/prebuild-workspace-packages.ts postinstall && pnpm rebuild better-sqlite3", + "postinstall": "node scripts/prebuild-workspace-packages.ts postinstall", "setup": "pnpm install", "build": "pnpm -r build", "dev": "node scripts/dev-lazy.ts", diff --git a/packages/core/src/agent/production-agent.ts b/packages/core/src/agent/production-agent.ts index 917d566378..a1ebd85eca 100644 --- a/packages/core/src/agent/production-agent.ts +++ b/packages/core/src/agent/production-agent.ts @@ -8583,7 +8583,7 @@ export function createProductionAgentHandler( typeof sel.capturedAt === "number" ? sel.capturedAt : 0; if (Date.now() - capturedAt > SELECTION_TTL_MS) return ""; return ( - `\n\nThe user has selected the following text and pressed Cmd+I to focus the agent. ` + + `\n\nThe user has selected the following text and pressed Cmd I to focus the agent. ` + `Treat this as the immediate context to act on:\n` + `\n${capSelectionContext(sel.text)}\n` ); diff --git a/packages/core/src/brand-kit/types.ts b/packages/core/src/brand-kit/types.ts index 487a15eab8..3167204829 100644 --- a/packages/core/src/brand-kit/types.ts +++ b/packages/core/src/brand-kit/types.ts @@ -25,13 +25,22 @@ export interface BrandKitColors { textMuted: string; } -/** Semantic category a {@link BrandKitToken} belongs to. */ +/** + * Semantic category a {@link BrandKitToken} belongs to. + * + * `motion` covers durations, easings, and transitions — the DTCG `duration`, + * `cubicBezier`, and `transition` types. It exists because a design system's + * motion is part of its identity, and a category-less token is a dropped one: + * extractors discard what they cannot classify, so "no bucket" silently became + * "no motion in any imported system". + */ export type BrandKitTokenType = | "color" | "typography" | "spacing" | "radius" | "shadow" + | "motion" | "other"; /** diff --git a/packages/core/src/cli/code.ts b/packages/core/src/cli/code.ts index 0eec4dfd28..741cf7e704 100644 --- a/packages/core/src/cli/code.ts +++ b/packages/core/src/cli/code.ts @@ -1279,7 +1279,7 @@ async function attachCodeAgentRun( writeLine(output, `Attaching to Agent-Native Code run ${run.id}`); writeLine( output, - "Press Ctrl+C to detach. The session keeps its transcript.", + "Press Ctrl C to detach. The session keeps its transcript.", ); writeLine(output, ""); diff --git a/packages/core/src/cli/index.ts b/packages/core/src/cli/index.ts index 519e7fe9f8..ba3815edaa 100644 --- a/packages/core/src/cli/index.ts +++ b/packages/core/src/cli/index.ts @@ -13,6 +13,12 @@ import { resolveAgentNativeNitroPreset, } from "../deploy/nitro-preset.js"; import { resolveDeployPostBuildInvocation } from "./deploy-build.js"; +import { + assertNativeDependencies, + assertNodeRuntimeMarker, + ensureNativeDependencies, + writeNodeRuntimeMarker, +} from "./native-dependencies.js"; import { cliSpawnOptions } from "./process.js"; import { shouldTrackCliRun } from "./telemetry-routing.js"; import { createCliTelemetry } from "./telemetry.js"; @@ -614,6 +620,12 @@ if (shouldTrackCliRun(command, args)) trackCli("cli.run"); switch (command) { case "dev": { + try { + ensureNativeDependencies({ repair: true, label: "dev" }); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } if (isWorkspaceRoot()) { import("./workspace-dev.js") .then((m) => m.runWorkspaceDev({ args })) @@ -680,6 +692,8 @@ switch (command) { // child exits non-zero, runBuildStep calls process.exit itself; the // continuation only runs on success. (async () => { + ensureNativeDependencies({ repair: true, label: "build" }); + // Doctor pre-step: scans app source for the security-critical guard // invariants (see `agent-native doctor --help`). Findings fail by // default; only an explicit `doctor.failOnBuild: false` opt-out keeps @@ -740,6 +754,15 @@ switch (command) { } } + const serverDirectory = path.resolve(".output/server"); + if (fs.existsSync(serverDirectory)) { + assertNativeDependencies({ + fromDirectory: serverDirectory, + label: "build output", + }); + writeNodeRuntimeMarker(serverDirectory); + } + console.log("\nBuild complete."); })().catch((err) => { // runBuildStep handles its own failures and exits, so reaching here @@ -765,7 +788,18 @@ switch (command) { ); process.exit(1); } - run("node", [serverEntry, ...args]); + const serverDirectory = path.dirname(serverEntry); + try { + assertNodeRuntimeMarker(serverDirectory); + assertNativeDependencies({ + fromDirectory: serverDirectory, + label: "start output", + }); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } + run(process.execPath, [serverEntry, ...args]); break; } diff --git a/packages/core/src/cli/native-dependencies.spec.ts b/packages/core/src/cli/native-dependencies.spec.ts new file mode 100644 index 0000000000..0c4c358601 --- /dev/null +++ b/packages/core/src/cli/native-dependencies.spec.ts @@ -0,0 +1,52 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { + assertNativeDependencies, + assertNodeRuntimeMarker, + checkNativeDependencies, + writeNodeRuntimeMarker, +} from "./native-dependencies.js"; + +const temporaryDirectories: string[] = []; + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +describe("native dependency preflight", () => { + it("loads better-sqlite3 from the current Node runtime", () => { + expect(checkNativeDependencies().status).toBe("healthy"); + expect(assertNativeDependencies().status).toBe("healthy"); + }); + + it("records the build ABI and rejects a different runtime ABI", () => { + const directory = mkdtempSync( + path.join(os.tmpdir(), "agent-native-native-runtime-"), + ); + temporaryDirectories.push(directory); + + const markerPath = writeNodeRuntimeMarker(directory); + expect(markerPath).toBeTruthy(); + expect(JSON.parse(readFileSync(markerPath!, "utf8"))).toMatchObject({ + nodeAbi: process.versions.modules, + platform: process.platform, + arch: process.arch, + }); + + const mismatchedMarker = JSON.parse(readFileSync(markerPath!, "utf8")) as { + nodeAbi: string; + }; + mismatchedMarker.nodeAbi = "different-node-abi"; + writeFileSync(markerPath!, `${JSON.stringify(mismatchedMarker)}\n`); + + expect(() => assertNodeRuntimeMarker(directory)).toThrow( + /Production output was built for a different Node runtime/, + ); + }); +}); diff --git a/packages/core/src/cli/native-dependencies.ts b/packages/core/src/cli/native-dependencies.ts new file mode 100644 index 0000000000..07bdfe7fc4 --- /dev/null +++ b/packages/core/src/cli/native-dependencies.ts @@ -0,0 +1,381 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const BETTER_SQLITE3 = "better-sqlite3"; +const REBUILD_LOCK = ".agent-native-better-sqlite3-rebuild.lock"; +const RUNTIME_MARKER = ".agent-native-node-runtime.json"; +const LOCK_INITIALIZATION_TIMEOUT_MS = 5_000; +const REBUILD_WAIT_MS = 250; +const REBUILD_TIMEOUT_MS = 10 * 60 * 1000; + +type NativeDependencyCheck = + | { + status: "absent"; + packageName: string; + } + | { + status: "healthy"; + packageName: string; + packageDir: string; + } + | { + status: "broken"; + packageName: string; + packageDir: string; + error: unknown; + }; + +export interface NativeDependencyOptions { + fromDirectory?: string; + label?: string; + repair?: boolean; +} + +interface NodeRuntimeMarker { + nodeVersion: string; + nodeAbi: string; + platform: NodeJS.Platform; + arch: string; +} + +function corePackageDirectory(): string { + return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +} + +function packageRootFromManifest( + packageName: string, + manifestPath: string, +): string | null { + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as { + name?: unknown; + }; + return manifest.name === packageName ? path.dirname(manifestPath) : null; +} + +function resolvePackageDirectory( + packageName: string, + fromDirectory: string, +): string | null { + const requireFromDirectory = createRequire( + path.join(path.resolve(fromDirectory), "package.json"), + ); + + try { + const manifestPath = requireFromDirectory.resolve( + `${packageName}/package.json`, + ); + return packageRootFromManifest(packageName, manifestPath); + } catch { + try { + const entryPath = requireFromDirectory.resolve(packageName); + let current = path.dirname(entryPath); + while (true) { + const manifestPath = path.join(current, "package.json"); + if (fs.existsSync(manifestPath)) { + const packageDir = packageRootFromManifest(packageName, manifestPath); + if (packageDir) return packageDir; + } + const parent = path.dirname(current); + if (parent === current) return null; + current = parent; + } + } catch { + return null; + } + } +} + +function probeBetterSqlite3(packageDir: string): unknown | null { + try { + const requireFromPackage = createRequire( + path.join(packageDir, "package.json"), + ); + const Database = requireFromPackage(BETTER_SQLITE3) as { + new (filename: string): { + close: () => void; + prepare: (sql: string) => { get: () => unknown }; + }; + }; + const database = new Database(":memory:"); + database.prepare("select 1 as ok").get(); + database.close(); + return null; + } catch (error) { + return error; + } +} + +export function checkNativeDependencies( + fromDirectory = corePackageDirectory(), +): NativeDependencyCheck { + const packageDir = resolvePackageDirectory(BETTER_SQLITE3, fromDirectory); + if (!packageDir) { + return { status: "absent", packageName: BETTER_SQLITE3 }; + } + + const error = probeBetterSqlite3(packageDir); + return error === null + ? { status: "healthy", packageName: BETTER_SQLITE3, packageDir } + : { status: "broken", packageName: BETTER_SQLITE3, packageDir, error }; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function npmExecutable(): string { + const sibling = path.join( + path.dirname(process.execPath), + process.platform === "win32" ? "npm.cmd" : "npm", + ); + return fs.existsSync(sibling) ? sibling : "npm"; +} + +function sleepSync(milliseconds: number): void { + const signal = new Int32Array(new SharedArrayBuffer(4)); + Atomics.wait(signal, 0, 0, milliseconds); +} + +function processIsRunning(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error instanceof Error && "code" in error && error.code !== "ESRCH"; + } +} + +function acquireRebuildLock(packageDir: string, label: string): () => void { + const lockPath = path.join(packageDir, REBUILD_LOCK); + const startedWaiting = Date.now(); + + while (true) { + try { + const lockFd = fs.openSync(lockPath, "wx"); + fs.writeSync( + lockFd, + JSON.stringify({ + pid: process.pid, + startedAt: new Date().toISOString(), + }), + ); + return () => { + try { + fs.closeSync(lockFd); + } finally { + try { + fs.unlinkSync(lockPath); + } catch (error) { + if ( + !( + error instanceof Error && + "code" in error && + error.code === "ENOENT" + ) + ) { + throw error; + } + } + } + }; + } catch (error) { + if ( + !(error instanceof Error && "code" in error && error.code === "EEXIST") + ) { + throw error; + } + + let ownerPid: number | null = null; + let lockAge = 0; + try { + const lock = JSON.parse(fs.readFileSync(lockPath, "utf8")) as { + pid?: unknown; + }; + ownerPid = typeof lock.pid === "number" ? lock.pid : null; + } catch { + // The owner may be between creating and writing the lock metadata. + } + try { + lockAge = Date.now() - fs.statSync(lockPath).mtimeMs; + } catch { + continue; + } + + if ( + (ownerPid !== null && !processIsRunning(ownerPid)) || + (ownerPid === null && lockAge > LOCK_INITIALIZATION_TIMEOUT_MS) + ) { + try { + fs.unlinkSync(lockPath); + continue; + } catch (unlinkError) { + if ( + !( + unlinkError instanceof Error && + "code" in unlinkError && + unlinkError.code === "ENOENT" + ) + ) { + throw unlinkError; + } + continue; + } + } + + if (Date.now() - startedWaiting > REBUILD_TIMEOUT_MS) { + throw new Error( + `[${label}] Timed out waiting for another process to finish rebuilding ${BETTER_SQLITE3}.`, + ); + } + sleepSync(REBUILD_WAIT_MS); + } + } +} + +function rebuildNativeDependency(packageDir: string): void { + execFileSync( + npmExecutable(), + ["run", "build-release", "--prefix", packageDir], + { + cwd: packageDir, + stdio: "inherit", + shell: process.platform === "win32", + }, + ); +} + +function brokenDependencyMessage( + check: Extract, + label: string, +): string { + return [ + `[${label}] ${check.packageName} cannot load its native binding.`, + `Package: ${check.packageDir}`, + `Node: ${process.version} (ABI ${process.versions.modules}, ${process.platform}-${process.arch})`, + `Original error: ${errorMessage(check.error)}`, + "The installed native binary was built for a different Node runtime or is otherwise stale.", + ].join("\n"); +} + +export function ensureNativeDependencies( + options: NativeDependencyOptions = {}, +): NativeDependencyCheck { + const label = options.label ?? "native preflight"; + const check = checkNativeDependencies(options.fromDirectory); + if (check.status !== "broken") return check; + + if (!options.repair) { + throw new Error(brokenDependencyMessage(check, label)); + } + + console.warn( + `${brokenDependencyMessage(check, label)}\n[${label}] Rebuilding it with the current Node runtime...`, + ); + const releaseLock = acquireRebuildLock(check.packageDir, label); + try { + const current = checkNativeDependencies(options.fromDirectory); + if (current.status === "healthy") return current; + if (current.status !== "broken") { + throw new Error( + `[${label}] ${BETTER_SQLITE3} disappeared before its native rebuild.`, + ); + } + + rebuildNativeDependency(current.packageDir); + + const repaired = checkNativeDependencies(options.fromDirectory); + if (repaired.status === "healthy") { + console.log( + `[${label}] ${repaired.packageName} is ready for Node ABI ${process.versions.modules}.`, + ); + return repaired; + } + + if (repaired.status === "broken") { + throw new Error(brokenDependencyMessage(repaired, label)); + } + + throw new Error( + `[${label}] ${BETTER_SQLITE3} disappeared after its native rebuild.`, + ); + } finally { + releaseLock(); + } +} + +export function assertNativeDependencies( + options: Omit = {}, +): NativeDependencyCheck { + const check = checkNativeDependencies(options.fromDirectory); + if (check.status === "broken") { + throw new Error(brokenDependencyMessage(check, options.label ?? "runtime")); + } + return check; +} + +export function writeNodeRuntimeMarker(serverDirectory: string): string | null { + if (!fs.existsSync(serverDirectory)) return null; + + const markerPath = path.join(serverDirectory, RUNTIME_MARKER); + const marker: NodeRuntimeMarker = { + nodeVersion: process.version, + nodeAbi: process.versions.modules, + platform: process.platform, + arch: process.arch, + }; + fs.writeFileSync(markerPath, `${JSON.stringify(marker)}\n`); + return markerPath; +} + +export function assertNodeRuntimeMarker(serverDirectory: string): void { + const markerPath = path.join(serverDirectory, RUNTIME_MARKER); + if (!fs.existsSync(markerPath)) return; + + let marker: NodeRuntimeMarker; + try { + marker = JSON.parse( + fs.readFileSync(markerPath, "utf8"), + ) as NodeRuntimeMarker; + } catch (error) { + throw new Error( + `[runtime] Could not read ${RUNTIME_MARKER}: ${errorMessage(error)}`, + ); + } + + const mismatches = [ + marker.nodeAbi !== process.versions.modules + ? `Node ABI ${marker.nodeAbi} (build) vs ${process.versions.modules} (runtime)` + : null, + marker.platform !== process.platform + ? `platform ${marker.platform} (build) vs ${process.platform} (runtime)` + : null, + marker.arch !== process.arch + ? `architecture ${marker.arch} (build) vs ${process.arch} (runtime)` + : null, + ].filter((value): value is string => value !== null); + + if (mismatches.length > 0) { + throw new Error( + [ + `[runtime] Production output was built for a different Node runtime: ${mismatches.join(", ")}.`, + `Build Node: ${marker.nodeVersion}; runtime Node: ${process.version}.`, + "Run the build and start commands with the same Node installation.", + ].join("\n"), + ); + } +} + +function isDirectExecution(): boolean { + const entryPoint = process.argv[1]; + return ( + entryPoint !== undefined && + path.resolve(entryPoint) === path.resolve(fileURLToPath(import.meta.url)) + ); +} + +if (isDirectExecution() && process.argv.includes("--repair")) { + ensureNativeDependencies({ repair: true, label: "native repair" }); +} diff --git a/packages/core/src/cli/plan-local.ts b/packages/core/src/cli/plan-local.ts index 2bea13e98f..d7bf0fb3a3 100644 --- a/packages/core/src/cli/plan-local.ts +++ b/packages/core/src/cli/plan-local.ts @@ -2962,7 +2962,7 @@ async function runServe(args: Record): Promise { bridgeUrl: bridge.result.bridgeUrl, }), "Keep this bridge command running while the Plan page is open; stopping it makes this URL unreachable.", - "Press Ctrl+C to stop.", + "Press Ctrl C to stop.", ] .filter(Boolean) .join("\n") + "\n", diff --git a/packages/core/src/cli/skills-content/design-visual-edit-skill.ts b/packages/core/src/cli/skills-content/design-visual-edit-skill.ts index 169b66afa3..cc62b4fbe7 100644 --- a/packages/core/src/cli/skills-content/design-visual-edit-skill.ts +++ b/packages/core/src/cli/skills-content/design-visual-edit-skill.ts @@ -51,8 +51,8 @@ Prefer the MCP App surface for a connected Design plugin, then the host's browser/preview tool as the universal fallback. Keep the canvas beside chat when the host supports rearrangeable panes. -Inside Design, use **Show/Hide UI** from the \`Cmd+K\` menu or press Figma's -\`Shift+\\\` shortcut to toggle all editing chrome so only the canvas remains. +Inside Design, use **Show/Hide UI** from the \`Cmd K\` menu or press Figma's +\`Shift \\\` shortcut to toggle all editing chrome so only the canvas remains. The same action is available from Design's empty-canvas context menu. ## Core Model diff --git a/packages/core/src/cli/visualize-repo.ts b/packages/core/src/cli/visualize-repo.ts index 686292789e..786d3c6e3e 100644 --- a/packages/core/src/cli/visualize-repo.ts +++ b/packages/core/src/cli/visualize-repo.ts @@ -689,7 +689,7 @@ export async function runVisualizeRepo(argv: string[]) { bridge.result.urlFile ? `Open URL written to ${bridge.result.urlFile}` : "", - "Press Ctrl+C to stop.", + "Press Ctrl C to stop.", ] .filter(Boolean) .join("\n") + "\n", diff --git a/packages/core/src/client/AssistantChat.display.spec.ts b/packages/core/src/client/AssistantChat.display.spec.ts index a854921322..951fc26d8a 100644 --- a/packages/core/src/client/AssistantChat.display.spec.ts +++ b/packages/core/src/client/AssistantChat.display.spec.ts @@ -2616,6 +2616,16 @@ describe("assistantUiRecoverableRenderErrorKind", () => { ).toBe("assistant-ui-react-fiber-unmount"); }); + it("matches React maximum update depth crashes from assistant-ui streaming", () => { + expect( + assistantUiRecoverableRenderErrorKind( + new Error( + "Minified React error #185; visit https://react.dev/errors/185", + ), + ), + ).toBe("assistant-ui-react-update-depth"); + }); + it("matches duplicate resource-key crashes from assistant-ui composer state", () => { expect( assistantUiRecoverableRenderErrorKind( diff --git a/packages/core/src/client/FeedbackButton.tsx b/packages/core/src/client/FeedbackButton.tsx index d59354a241..3582eba581 100644 --- a/packages/core/src/client/FeedbackButton.tsx +++ b/packages/core/src/client/FeedbackButton.tsx @@ -62,7 +62,7 @@ const FEEDBACK_COPY: Record< invalidUrl: "Invalid feedback URL", emptyError: "Please write something first", sendError: "Couldn't send feedback", - keyboardHint: "{{shortcut}}+Enter to send", + keyboardHint: "{{shortcut}} Enter to send", }, "zh-CN": { label: "反馈", @@ -74,7 +74,7 @@ const FEEDBACK_COPY: Record< invalidUrl: "反馈 URL 无效", emptyError: "请先写点内容", sendError: "无法发送反馈", - keyboardHint: "{{shortcut}}+Enter 发送", + keyboardHint: "{{shortcut}} Enter 发送", }, "zh-TW": { label: "意見回饋", @@ -86,7 +86,7 @@ const FEEDBACK_COPY: Record< invalidUrl: "意見回饋 URL 無效", emptyError: "請先輸入內容", sendError: "無法送出意見回饋", - keyboardHint: "{{shortcut}}+Enter 送出", + keyboardHint: "{{shortcut}} Enter 送出", }, "es-ES": { label: "Comentarios", @@ -98,7 +98,7 @@ const FEEDBACK_COPY: Record< invalidUrl: "URL de comentarios no válida", emptyError: "Escribe algo primero", sendError: "No se pudieron enviar los comentarios", - keyboardHint: "{{shortcut}}+Enter para enviar", + keyboardHint: "{{shortcut}} Enter para enviar", }, "fr-FR": { label: "Retour", @@ -110,7 +110,7 @@ const FEEDBACK_COPY: Record< invalidUrl: "URL de retour invalide", emptyError: "Écrivez quelque chose d'abord", sendError: "Impossible d'envoyer le retour", - keyboardHint: "{{shortcut}}+Entrée pour envoyer", + keyboardHint: "{{shortcut}} Entrée pour envoyer", }, "de-DE": { label: "Feedback", @@ -122,7 +122,7 @@ const FEEDBACK_COPY: Record< invalidUrl: "Ungültige Feedback-URL", emptyError: "Bitte zuerst etwas schreiben", sendError: "Feedback konnte nicht gesendet werden", - keyboardHint: "{{shortcut}}+Enter zum Senden", + keyboardHint: "{{shortcut}} Enter zum Senden", }, "ja-JP": { label: "フィードバック", @@ -134,7 +134,7 @@ const FEEDBACK_COPY: Record< invalidUrl: "フィードバック URL が無効です", emptyError: "先に内容を入力してください", sendError: "送信できませんでした", - keyboardHint: "{{shortcut}}+Enter で送信", + keyboardHint: "{{shortcut}} Enter で送信", }, "ko-KR": { label: "피드백", @@ -146,7 +146,7 @@ const FEEDBACK_COPY: Record< invalidUrl: "피드백 URL이 올바르지 않습니다", emptyError: "먼저 내용을 입력해 주세요", sendError: "피드백을 보낼 수 없습니다", - keyboardHint: "{{shortcut}}+Enter로 보내기", + keyboardHint: "{{shortcut}} Enter로 보내기", }, "pt-BR": { label: "Feedback", @@ -158,7 +158,7 @@ const FEEDBACK_COPY: Record< invalidUrl: "URL de feedback inválida", emptyError: "Escreva algo primeiro", sendError: "Não foi possível enviar o feedback", - keyboardHint: "{{shortcut}}+Enter para enviar", + keyboardHint: "{{shortcut}} Enter para enviar", }, "hi-IN": { label: "फ़ीडबैक", @@ -170,7 +170,7 @@ const FEEDBACK_COPY: Record< invalidUrl: "फ़ीडबैक URL अमान्य है", emptyError: "पहले कुछ लिखें", sendError: "फ़ीडबैक भेजा नहीं जा सका", - keyboardHint: "भेजने के लिए {{shortcut}}+Enter", + keyboardHint: "भेजने के लिए {{shortcut}} Enter", }, "ar-SA": { label: "ملاحظات", @@ -182,7 +182,7 @@ const FEEDBACK_COPY: Record< invalidUrl: "رابط الملاحظات غير صالح", emptyError: "اكتب شيئا أولا", sendError: "تعذر إرسال الملاحظات", - keyboardHint: "{{shortcut}}+Enter للإرسال", + keyboardHint: "{{shortcut}} Enter للإرسال", }, }; @@ -610,7 +610,7 @@ function FeedbackPopoverButton({ copy.keyboardHint.replace( "{{shortcut}}", /Mac|iPhone|iPad/.test(navigator.userAgent) - ? "⌘" + ? "Cmd" : "Ctrl", )} diff --git a/packages/core/src/client/agent-chat-adapter.spec.ts b/packages/core/src/client/agent-chat-adapter.spec.ts index 3784783427..e1dec6c547 100644 --- a/packages/core/src/client/agent-chat-adapter.spec.ts +++ b/packages/core/src/client/agent-chat-adapter.spec.ts @@ -8880,6 +8880,37 @@ describe("activeRunLooksAlive", () => { ).resolves.toBe(false); expect(fetchSpy).toHaveBeenCalledTimes(1); }); + + it("treats an unresolved delegated-agent activity card as in-flight work", async () => { + const fetchSpy = vi.fn(async () => + jsonResponse({ + active: true, + runId: "run-1", + status: "running", + hasInFlightWork: false, + }), + ); + vi.stubGlobal("fetch", fetchSpy); + + await expect( + activeRunLooksAlive({ + apiUrl: "/_agent-native/agent-chat", + threadId: "thread-1", + runId: "run-1", + content: [ + { + type: "tool-call", + toolCallId: "agent-call", + toolName: "agent:Analytics", + argsText: "", + args: {}, + activity: true, + }, + ] as any, + }), + ).resolves.toBe(true); + expect(fetchSpy).not.toHaveBeenCalled(); + }); }); describe("empty-run continuation backoff", () => { diff --git a/packages/core/src/client/agent-chat-adapter.ts b/packages/core/src/client/agent-chat-adapter.ts index 9ce3137581..36433ac10e 100644 --- a/packages/core/src/client/agent-chat-adapter.ts +++ b/packages/core/src/client/agent-chat-adapter.ts @@ -36,6 +36,10 @@ import { readSSEStream, settleInterruptedToolCalls, } from "./sse-event-processor.js"; +import { + isDelegatedAgentToolCall, + isToolCallInFlight, +} from "./tool-display.js"; import type { ChatThreadScope } from "./use-chat-threads.js"; export type AgentChatSurfaceKind = @@ -1135,7 +1139,7 @@ function describeContinuationAdvance( } continue; } - if (part.activity === true) { + if (part.activity === true && !isDelegatedAgentToolCall(part)) { work.add(`preparing ${part.toolName}`); stall ??= { kind: "preparing", toolName: part.toolName }; } else { @@ -1166,12 +1170,7 @@ function describeContinuationAdvance( * must not count this against the stalled/empty continuation budgets. */ export function hasInFlightToolCall(content: ContentPart[]): boolean { - return content.some( - (part) => - part.type === "tool-call" && - part.result === undefined && - part.activity !== true, - ); + return content.some((part) => isToolCallInFlight(part)); } /** diff --git a/packages/core/src/client/assistant-ui-recovery.tsx b/packages/core/src/client/assistant-ui-recovery.tsx index 83ba5dd37a..acc895315d 100644 --- a/packages/core/src/client/assistant-ui-recovery.tsx +++ b/packages/core/src/client/assistant-ui-recovery.tsx @@ -6,7 +6,8 @@ import { captureError } from "./analytics.js"; type AssistantUiRecoverableErrorKind = | "assistant-ui-stale-message-index" | "assistant-ui-duplicate-resource-key" - | "assistant-ui-react-fiber-unmount"; + | "assistant-ui-react-fiber-unmount" + | "assistant-ui-react-update-depth"; export function assistantUiRecoverableRenderErrorKind( error: unknown, @@ -23,6 +24,9 @@ export function assistantUiRecoverableRenderErrorKind( if (/^Tried to unmount a fiber that is already unmounted\b/.test(message)) { return "assistant-ui-react-fiber-unmount"; } + if (/Maximum update depth exceeded|Minified React error #185/.test(message)) { + return "assistant-ui-react-update-depth"; + } return null; } diff --git a/packages/core/src/client/chat/message-components.spec.tsx b/packages/core/src/client/chat/message-components.spec.tsx index c55e7c655f..733997abc1 100644 --- a/packages/core/src/client/chat/message-components.spec.tsx +++ b/packages/core/src/client/chat/message-components.spec.tsx @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { assistantMessageHasCompletedCustomUi, + assistantMessageHasActiveTool, assistantMessageHasCustomUi, assistantMessageHasUnresolvedTool, computeActiveTailToolCallId, @@ -29,6 +30,7 @@ import { assistantMessageRunId, assistantMessageTurnId, assistantMessageWasUserStopped, + ChatImageAttachmentPreview, resolveAssistantRequestId, } from "./message-components.js"; import { runErrorKey } from "./run-recovery.js"; @@ -118,6 +120,65 @@ describe("ThinkingIndicator", () => { }); }); +describe("ChatImageAttachmentPreview", () => { + let container: HTMLDivElement; + let root: Root; + const src = "data:image/png;base64,AAAA"; + + beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + document.body.innerHTML = ""; + vi.unstubAllGlobals(); + }); + + it("opens the full-size image in a lightbox and closes it", () => { + act(() => { + root.render(); + }); + + const thumbnail = container.querySelector( + 'button[aria-label="Preview Screenshot"]', + ); + expect(thumbnail).toBeTruthy(); + expect(document.body.querySelector('[role="dialog"]')).toBeNull(); + + act(() => { + thumbnail?.click(); + }); + + const dialog = document.body.querySelector('[role="dialog"]'); + expect(dialog).toBeTruthy(); + expect( + Array.from(dialog?.querySelectorAll("img") ?? []).some( + (image) => image.src === src, + ), + ).toBe(true); + expect( + dialog?.querySelector('button[aria-label="Close image preview"]'), + ).toBeTruthy(); + + act(() => { + dialog + ?.querySelector( + 'button[aria-label="Close image preview"]', + ) + ?.click(); + }); + + expect(document.body.querySelector('[role="dialog"]')).toBeNull(); + }); +}); + describe("shouldShowAssistantMessageFooter", () => { it("hides controls for the current assistant response while it is running", () => { expect( @@ -164,6 +225,18 @@ describe("shouldShowAssistantMessageFooter", () => { ).toBe(false); }); + it("hides controls while a delegated agent is still pending", () => { + expect( + shouldShowAssistantMessageFooter({ + isLast: true, + chatRunning: false, + hasRenderableContent: true, + statusIsTerminal: true, + hasActiveTool: true, + }), + ).toBe(false); + }); + it("keeps unrelated historical assistant controls while chat work runs", () => { expect( shouldShowAssistantMessageFooter({ @@ -620,6 +693,19 @@ describe("shouldShowAssistantWorkSummary", () => { ).toBe(true); }); + it("does not collapse active delegated work into a duration summary", () => { + expect( + shouldShowAssistantWorkSummary({ + isLast: true, + isComplete: false, + hasCollapsibleWork: true, + hasUnresolvedTool: false, + hasActiveTool: true, + chatRunning: false, + }), + ).toBe(false); + }); + it("groups historical work with a dangling tool", () => { expect( shouldShowAssistantWorkSummary({ @@ -1037,3 +1123,37 @@ describe("assistantMessageHasUnresolvedTool", () => { ).toBe(false); }); }); + +describe("assistantMessageHasActiveTool", () => { + it("detects a delegated agent that is pending after the parent call returns", () => { + expect( + assistantMessageHasActiveTool([ + { + type: "tool-call", + toolName: "agent:Analytics", + toolCallId: "agent-call", + argsText: "", + args: {}, + result: "Remote agent task is still pending", + activity: true, + structuredMeta: { agentPending: true }, + }, + ]), + ).toBe(true); + }); + + it("does not treat a generic activity placeholder as active by itself", () => { + expect( + assistantMessageHasActiveTool([ + { + type: "tool-call", + toolName: "edit-design", + toolCallId: "activity-only", + argsText: "", + args: {}, + activity: true, + }, + ]), + ).toBe(false); + }); +}); diff --git a/packages/core/src/client/chat/message-components.tsx b/packages/core/src/client/chat/message-components.tsx index 78034130e4..58d903aa96 100644 --- a/packages/core/src/client/chat/message-components.tsx +++ b/packages/core/src/client/chat/message-components.tsx @@ -49,6 +49,12 @@ import React, { useState, useEffect, useCallback, useRef } from "react"; import { getActiveRun } from "../active-run-state.js"; import { agentNativePath } from "../api-path.js"; import { writeClipboardText } from "../clipboard.js"; +import { + Dialog, + DialogClose, + DialogContent, + DialogTitle, +} from "../components/ui/dialog.js"; import { DropdownMenu, DropdownMenuContent, @@ -68,6 +74,7 @@ import { McpConnectionSuggestion } from "../resources/McpConnectionSuggestion.js import type { ContentPart } from "../sse-event-processor.js"; import { isCallAgentToolCallShadowed, + isToolCallActive, shadowedCallAgentToolCallIds, } from "../tool-display.js"; import { cn } from "../utils.js"; @@ -557,17 +564,11 @@ function UserMessageAttachments() { const imageSrc = uploadUrl || imagePart?.image || null; if (imageSrc) { return ( -
- {att.name} -
+ src={imageSrc} + alt={att.name} + /> ); } return ( @@ -585,6 +586,65 @@ function UserMessageAttachments() { ); } +export function ChatImageAttachmentPreview({ + src, + alt, +}: { + src: string; + alt: string; +}) { + const [open, setOpen] = useState(false); + + return ( + <> + + + + event.preventDefault()} + > + + {alt || "Image preview"} + +
{ + if (event.target === event.currentTarget) setOpen(false); + }} + > + {alt} +
+ + + +
+
+ + ); +} + // ─── UserMessageEditComposer ────────────────────────────────────────────────── function UserMessageEditComposer() { @@ -1034,6 +1094,14 @@ export function assistantMessageHasUnresolvedTool(content: unknown): boolean { }); } +export function assistantMessageHasActiveTool(content: unknown): boolean { + if (!Array.isArray(content)) return false; + return content.some((part): boolean => { + if (!part || typeof part !== "object") return false; + return isToolCallActive(part as ContentPart); + }); +} + export function assistantMessageHasCompletedCustomUi( content: unknown, ): boolean { @@ -1134,6 +1202,7 @@ export function shouldShowAssistantMessageFooter({ hasRenderableContent, statusIsTerminal, hasUnresolvedTool, + hasActiveTool, }: { isLast: boolean; chatRunning: boolean; @@ -1144,6 +1213,7 @@ export function shouldShowAssistantMessageFooter({ hasRenderableContent: boolean; statusIsTerminal: boolean; hasUnresolvedTool?: boolean; + hasActiveTool?: boolean; }): boolean { if (!hasRenderableContent) return false; const ownsActiveTurn = @@ -1160,6 +1230,7 @@ export function shouldShowAssistantMessageFooter({ activeRunId === messageRunId; const ownsActiveRun = isLast || ownsActiveTurn || ownsLegacyRun; if (chatRunning && ownsActiveRun) return false; + if (hasActiveTool) return false; if (!isLast) return true; if (hasUnresolvedTool) return false; return statusIsTerminal; @@ -1183,6 +1254,7 @@ export function shouldShowMissingFinalResponse({ hasAssistantText, hasUnresolvedTool, hasCompletedCustomUi, + hasActiveTool, userStoppedRun, }: { isCurrentTurnRunning: boolean; @@ -1191,6 +1263,7 @@ export function shouldShowMissingFinalResponse({ hasAssistantText: boolean; hasUnresolvedTool: boolean; hasCompletedCustomUi?: boolean; + hasActiveTool?: boolean; userStoppedRun?: boolean; }): boolean { if (userStoppedRun) return false; @@ -1202,6 +1275,7 @@ export function shouldShowMissingFinalResponse({ statusIsTerminal && !hasAssistantText && !hasUnresolvedTool && + !hasActiveTool && !hasCompletedCustomUi ); } @@ -1232,15 +1306,18 @@ export function shouldShowAssistantWorkSummary({ isComplete, hasCollapsibleWork, hasUnresolvedTool, + hasActiveTool, chatRunning, }: { isLast: boolean; isComplete: boolean; hasCollapsibleWork: boolean; hasUnresolvedTool: boolean; + hasActiveTool?: boolean; chatRunning: boolean; }): boolean { if (!hasCollapsibleWork) return false; + if (hasActiveTool) return false; // An unresolved tool means "still working" only while the turn is actually // running. On a stalled or interrupted turn it used to hide the summary @@ -1532,6 +1609,7 @@ export function AssistantMessage() { assistantMessageWasUserStopped(msg) || userStoppedRun(messageRunId); const hasRenderableContent = assistantMessageHasRenderableContent(msg); const hasUnresolvedTool = assistantMessageHasUnresolvedTool(msg.content); + const hasActiveTool = assistantMessageHasActiveTool(msg.content); const missingWarningText = missingFinalResponseWarningText(msg.content); const responseConnectionText = finalResponseTextFromContent(msg.content); const statusIsTerminal = assistantMessageStatusIsTerminal(msg); @@ -1556,6 +1634,7 @@ export function AssistantMessage() { statusIsTerminal, hasAssistantText: responseConnectionText.trim().length > 0, hasUnresolvedTool, + hasActiveTool, hasCompletedCustomUi, userStoppedRun: isUserStoppedRun, }); @@ -1629,6 +1708,7 @@ export function AssistantMessage() { hasRenderableContent, statusIsTerminal, hasUnresolvedTool, + hasActiveTool, }); const cpCtx = React.useContext(CheckpointContext); @@ -1787,6 +1867,7 @@ export function AssistantMessage() { isComplete, hasCollapsibleWork, hasUnresolvedTool, + hasActiveTool, chatRunning, }); if (!showSummary) return <>{children}; diff --git a/packages/core/src/client/chat/tool-call-display.spec.tsx b/packages/core/src/client/chat/tool-call-display.spec.tsx index b427590076..7c94810156 100644 --- a/packages/core/src/client/chat/tool-call-display.spec.tsx +++ b/packages/core/src/client/chat/tool-call-display.spec.tsx @@ -233,6 +233,55 @@ describe("ToolCallDisplay native renderers", () => { expect(container.textContent).not.toContain("Recent rows"); }); + it("keeps an unresolved delegated agent visibly running when chat state dips", () => { + act(() => { + root.render( + , + ); + }); + + expect(container.textContent).toContain("Asking Analytics..."); + expect(container.textContent).not.toContain("Asked Analytics"); + expect(container.querySelector(".animate-spin")).not.toBeNull(); + }); + + it("keeps a remote pending delegation out of the terminal state", () => { + act(() => { + root.render( + , + ); + }); + + expect(container.textContent).toContain("Asking Analytics..."); + expect(container.textContent).not.toContain("Asked Analytics"); + expect(container.querySelector(".animate-spin")).not.toBeNull(); + }); + it("shows activity tool cards as running while the chat runs", () => { act(() => { root.render( diff --git a/packages/core/src/client/chat/tool-call-display.tsx b/packages/core/src/client/chat/tool-call-display.tsx index 835d590b73..fa09a356d3 100644 --- a/packages/core/src/client/chat/tool-call-display.tsx +++ b/packages/core/src/client/chat/tool-call-display.tsx @@ -55,6 +55,7 @@ import { import { humanizeToolName, isCallAgentToolCallShadowed, + isToolCallActive, } from "../tool-display.js"; import { cn } from "../utils.js"; import { ActionChatUiSurface } from "./action-chat-ui-surface.js"; @@ -655,6 +656,7 @@ export function ToolCallDisplay({ isRunning, outcome, structuredMeta, + activity, approval, repeatCount, isLatestRunning = isRunning, @@ -671,6 +673,7 @@ export function ToolCallDisplay({ /** "unknown": the stream ended mid-flight, so the side effect may have landed. */ outcome?: "unknown"; structuredMeta?: Record; + activity?: boolean; approval?: { approvalKey: string; dismissed?: boolean }; repeatCount?: number; /** The latest tool shown while the overall chat turn is still active. */ @@ -678,6 +681,19 @@ export function ToolCallDisplay({ /** @deprecated Use isActiveTail. */ isLatestRunning?: boolean; }) { + const isDelegatedAgentCall = + toolName === "call-agent" || toolName.startsWith("agent:"); + const effectiveIsRunning = + isRunning || + (isDelegatedAgentCall && + isToolCallActive({ + type: "tool-call", + toolName, + result, + outcome, + activity, + structuredMeta, + })); const showActiveTail = isActiveTail ?? isLatestRunning; // Delegate to bespoke cells when structured metadata is present. // These must be separate components so hook order in ToolCallDisplayGeneric @@ -686,7 +702,7 @@ export function ToolCallDisplay({ const wrapToolDisplay = (children: React.ReactNode) => ( [0]["meta"] } output={result} - isRunning={isRunning} + isRunning={effectiveIsRunning} />, ); } @@ -712,7 +728,7 @@ export function ToolCallDisplay({ meta={ structuredMeta as unknown as Parameters[0]["meta"] } - isRunning={isRunning} + isRunning={effectiveIsRunning} />, ); } @@ -722,7 +738,7 @@ export function ToolCallDisplay({ meta={ structuredMeta as unknown as Parameters[0]["meta"] } - isRunning={isRunning} + isRunning={effectiveIsRunning} />, ); } @@ -735,7 +751,7 @@ export function ToolCallDisplay({ result={result} mcpApp={mcpApp} chatUI={chatUI} - isRunning={isRunning} + isRunning={effectiveIsRunning} outcome={outcome} isActiveTail={showActiveTail} structuredMeta={structuredMeta} @@ -1266,12 +1282,20 @@ export function ToolCallFallback({ isActiveTail?: boolean; }) { const chatRunning = React.useContext(ChatRunningContext); - // A spinner is a claim that something is running right now, so it needs an - // actually-running chat. `chatRunning` already stays true across - // auto-continuation gaps and server-active runs (resolveAssistantChatRunningState), - // so an activity placeholder alone must never resurrect one on rehydrated - // history. - const isRunning = result === undefined && chatRunning; + // `chatRunning` covers ordinary live activity. An unresolved tool or a + // delegated-agent row is also explicit work evidence, while a generic + // activity placeholder alone must stay frozen when history is rehydrated. + const isRunning = + rest.outcome !== "unknown" && + ((result === undefined && chatRunning) || + isToolCallActive({ + type: "tool-call", + toolName, + result, + outcome: rest.outcome, + activity: rest.activity, + structuredMeta: rest.structuredMeta, + })); return ( {running ? ( @@ -540,7 +540,7 @@ export function SqlEditor({ - {MOD}+Enter runs selection / statement · {MOD}+Shift+Enter runs all + {MOD} Enter runs selection / statement · {MOD} Shift Enter runs all
diff --git a/packages/core/src/client/dev-overlay/DevOverlay.tsx b/packages/core/src/client/dev-overlay/DevOverlay.tsx index c0013b316b..635f68b3a8 100644 --- a/packages/core/src/client/dev-overlay/DevOverlay.tsx +++ b/packages/core/src/client/dev-overlay/DevOverlay.tsx @@ -104,8 +104,8 @@ function DevOverlayPanel({ onClose }: { onClose: () => void }) { const shortcutHint = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent) - ? "Cmd+Ctrl+A" - : "Ctrl+Alt+A"; + ? "Cmd Ctrl A" + : "Ctrl Alt A"; return ( diff --git a/packages/core/src/client/extensions/portable-extension.ts b/packages/core/src/client/extensions/portable-extension.ts index aa6768357f..e70a723cad 100644 --- a/packages/core/src/client/extensions/portable-extension.ts +++ b/packages/core/src/client/extensions/portable-extension.ts @@ -511,6 +511,10 @@ export function buildAgentNativeExtensionHtml({ "; + const findings = checkDesignSystemAdherence( + html, + expectation, + "index.html", + ); + expect(findings.map((f) => f.id)).toEqual([ + "design-system-drift:index.html:fonts", + "design-system-drift:index.html:colors", + "design-system-drift:index.html:css-vars", + ]); + expect(findings[0].category).toBe("design-system-drift"); + }); + + it("stays silent when the screen actually uses the system", () => { + const html = + ""; + expect(checkDesignSystemAdherence(html, expectation, "index.html")).toEqual( + [], + ); + }); + + it("matches shorthand hex against the kit's six-digit value", () => { + const shortKit = designSystemExpectation( + "Flo", + JSON.stringify({ colors: { primary: "#ffffff" } }), + ); + const html = ""; + expect(checkDesignSystemAdherence(html, shortKit, "a.html")).toEqual([]); + }); + + it("reports nothing when there is no linked system to check", () => { + expect(checkDesignSystemAdherence("", null, "a.html")).toEqual( + [], + ); + }); +}); + // --------------------------------------------------------------------------- // extractRootTokens // --------------------------------------------------------------------------- diff --git a/templates/design/actions/run-design-audit.ts b/templates/design/actions/run-design-audit.ts index de2c85e484..5580a8a372 100644 --- a/templates/design/actions/run-design-audit.ts +++ b/templates/design/actions/run-design-audit.ts @@ -489,6 +489,203 @@ export function checkTokenDrift( return findings; } +// --------------------------------------------------------------------------- +// Design-system adherence check +// --------------------------------------------------------------------------- +// +// checkTokenDrift above only proves the screens agree with EACH OTHER. A design +// can be perfectly self-consistent and still ignore the brand it is linked to, +// which is the most-reported failure of generation: the user links their system, +// the agent writes its own palette, and nothing anywhere notices. This compares +// the saved HTML against the linked kit's own values so the drift is reportable +// instead of invisible. + +/** The subset of a Brand Kit this check can verify against rendered HTML. */ +export interface DesignSystemExpectation { + title: string; + fonts: string[]; + /** Lowercase six-digit hex values, normalized from the kit's own notation. */ + colors: string[]; + /** Custom-property names the kit names, e.g. `--color-primary`. */ + cssVars: string[]; + /** + * The kit's stored `data` could not be parsed. Adherence is then UNKNOWN, + * which is not the same as satisfied — a silent empty expectation would + * report a corrupt design system as a clean audit. + */ + unreadable?: boolean; +} + +function normalizeHex(value: unknown): string | null { + if (typeof value !== "string") return null; + const match = value.trim().match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i); + if (!match) return null; + const hex = match[1].toLowerCase(); + // Expand shorthand so 3- and 6-digit forms compare in one space. + return hex.length === 3 + ? `#${hex[0]}${hex[0]}${hex[1]}${hex[1]}${hex[2]}${hex[2]}` + : `#${hex}`; +} + +function expandShortHexes(html: string): string { + return html.replace( + /#([0-9a-f])([0-9a-f])([0-9a-f])\b/gi, + (_m, r: string, g: string, b: string) => + `#${r}${r}${g}${g}${b}${b}`.toLowerCase(), + ); +} + +/** + * Build the checkable expectation from a kit's stored `data` JSON. Returns null + * when the kit carries nothing verifiable — "we cannot check" must not render + * as "the design complies". + */ +export function designSystemExpectation( + title: string, + data: string | null | undefined, +): DesignSystemExpectation | null { + const unreadable: DesignSystemExpectation = { + title, + fonts: [], + colors: [], + cssVars: [], + unreadable: true, + }; + let parsed: Record | null = null; + try { + parsed = data ? (JSON.parse(data) as Record) : null; + } catch { + return unreadable; + } + if (!parsed) return null; + + const typography = (parsed.typography ?? {}) as Record; + const fonts = [typography.headingFont, typography.bodyFont] + .filter((font): font is string => typeof font === "string" && !!font.trim()) + .map((font) => font.trim()); + + const colorRoles = (parsed.colors ?? {}) as Record; + const colors = Object.values(colorRoles) + .map(normalizeHex) + .filter((hex): hex is string => hex !== null); + + const cssVars = Array.isArray(parsed.tokens) + ? parsed.tokens + .map((token) => + token && typeof token === "object" + ? (token as { cssVar?: unknown }).cssVar + : null, + ) + .filter( + (cssVar): cssVar is string => + typeof cssVar === "string" && cssVar.startsWith("--"), + ) + : []; + + if (fonts.length === 0 && colors.length === 0 && cssVars.length === 0) { + return null; + } + return { + title, + fonts: Array.from(new Set(fonts)), + colors: Array.from(new Set(colors)), + cssVars: Array.from(new Set(cssVars)), + }; +} + +/** + * Flag a screen that uses none of its linked design system's fonts, or none of + * its colors. Deliberately a "none of them" test rather than a per-value one: + * a design legitimately uses a subset of a kit, but a design using zero of it + * is not following it at all. Pure so it can be unit tested without a DB. + */ +export function checkDesignSystemAdherence( + html: string, + expectation: DesignSystemExpectation | null, + filename: string, +): A11yFinding[] { + if (!expectation) return []; + if (expectation.unreadable) { + return [ + { + id: `design-system-drift:${filename}:unreadable`, + severity: "warning", + category: "design-system-drift", + message: `"${expectation.title}" could not be read, so adherence was not checked.`, + detail: + "The linked design system's stored token data is not valid JSON. " + + "Adherence is unknown, not satisfied — re-import or re-index the " + + "system before treating this screen as on-brand.", + selector: ":root", + fixAvailable: false, + }, + ]; + } + const findings: A11yFinding[] = []; + const haystack = expandShortHexes(html.toLowerCase()); + + if (expectation.fonts.length > 0) { + const used = expectation.fonts.filter((font) => + haystack.includes(font.toLowerCase()), + ); + if (used.length === 0) { + findings.push({ + id: `design-system-drift:${filename}:fonts`, + severity: "warning", + category: "design-system-drift", + message: `${filename} uses none of "${expectation.title}"'s fonts.`, + detail: + `The linked design system specifies ${expectation.fonts.join(", ")}, ` + + `but ${filename} references none of them. Load and apply the system's ` + + "typography, or tell the user you are deviating and why.", + selector: ":root", + fixAvailable: false, + }); + } + } + + if (expectation.colors.length > 0) { + const used = expectation.colors.filter((hex) => haystack.includes(hex)); + if (used.length === 0) { + findings.push({ + id: `design-system-drift:${filename}:colors`, + severity: "warning", + category: "design-system-drift", + message: `${filename} uses none of "${expectation.title}"'s colors.`, + detail: + `The linked design system defines ${expectation.colors.slice(0, 6).join(", ")}` + + `${expectation.colors.length > 6 ? ", …" : ""}, but ${filename} uses none of ` + + "them. Put the system's values in the `:root` token block instead of an " + + "invented palette.", + selector: ":root", + fixAvailable: false, + }); + } + } + + if (expectation.cssVars.length > 0) { + const used = expectation.cssVars.filter((cssVar) => + haystack.includes(cssVar.toLowerCase()), + ); + if (used.length === 0) { + findings.push({ + id: `design-system-drift:${filename}:css-vars`, + severity: "info", + category: "design-system-drift", + message: `${filename} uses none of "${expectation.title}"'s token names.`, + detail: + `The system names ${expectation.cssVars.length} custom properties (e.g. ` + + `${expectation.cssVars.slice(0, 4).join(", ")}). Reusing those exact names ` + + "keeps the prototype swappable with the real codebase.", + selector: ":root", + fixAvailable: false, + }); + } + } + + return findings; +} + // --------------------------------------------------------------------------- // Live-content helper (matches the pattern in other actions) // --------------------------------------------------------------------------- @@ -519,6 +716,11 @@ export default defineAction({ "form labels, focus-visibility gaps, reduced-motion coverage, and — for " + "multi-screen designs — token drift (the audited screen's :root custom " + "properties diverging from index.html's). " + + "When the design is linked to a design system, also reports " + + "design-system drift: the screen using none of the linked system's fonts, " + + "colors, or token names. Treat that finding as a generation bug to fix, " + + "not a style opinion — reload the system with `get-design-system` and " + + "apply its values rather than explaining the deviation. " + "Returns A11yFinding[] that can be shown in the Review panel or persisted " + "via create-design-review-snapshot. No writes are performed.", schema: z.object({ @@ -612,6 +814,27 @@ export default defineAction({ finding.id.includes(`token-drift:${file.filename}:`), ); + // Adherence to the LINKED system, which token drift cannot see. + const [linkedSystem] = await db + .select({ + title: schema.designSystems.title, + data: schema.designSystems.data, + }) + .from(schema.designs) + .innerJoin( + schema.designSystems, + eq(schema.designs.designSystemId, schema.designSystems.id), + ) + .where(eq(schema.designs.id, designId)) + .limit(1); + const designSystemFindings = linkedSystem + ? checkDesignSystemAdherence( + html, + designSystemExpectation(linkedSystem.title, linkedSystem.data), + file.filename, + ) + : []; + // Run all audit checks over the static HTML. const findings: A11yFinding[] = [ ...checkMissingAlt(html), @@ -621,6 +844,7 @@ export default defineAction({ ...checkFocusVisibility(html), ...checkContrastHint(html), ...tokenDriftFindings, + ...designSystemFindings, ]; // Summarise by severity for the agent context. diff --git a/templates/design/actions/sync-design-system-with-builder.ts b/templates/design/actions/sync-design-system-with-builder.ts new file mode 100644 index 0000000000..20034d3ece --- /dev/null +++ b/templates/design/actions/sync-design-system-with-builder.ts @@ -0,0 +1,67 @@ +import { defineAction } from "@agent-native/core"; +import { + parseBuilderDesignSystemProxyReference, + startBuilderDesignSystemIndex, +} from "@agent-native/core/server"; +import { + getRequestOrgId, + getRequestUserEmail, +} from "@agent-native/core/server/request-context"; +import { assertAccess, resolveAccess } from "@agent-native/core/sharing"; +import { z } from "zod"; + +import "../server/db/index.js"; +import { upsertBuilderProxyDesignSystem } from "../server/lib/builder-design-system-proxy.js"; + +export default defineAction({ + description: + "Re-index a Builder-backed GitHub design system using its persisted repository, ref, and file/folder scope. Requires editor access.", + schema: z.object({ + id: z.string().min(1).describe("Local design system id"), + }), + run: async ({ id }) => { + await assertAccess("design-system", id, "editor"); + const access = await resolveAccess("design-system", id); + if (!access) throw new Error("Design system not found"); + + const reference = parseBuilderDesignSystemProxyReference( + access.resource.data, + ); + if (!reference?.githubSources?.length) { + throw new Error( + "This design system has no persisted GitHub source scope to sync.", + ); + } + if (reference.sourceKind === "mixed") { + throw new Error( + "This design system combines GitHub with non-replayable sources. Re-import all sources before syncing it.", + ); + } + + const result = await startBuilderDesignSystemIndex({ + projectName: access.resource.title, + description: access.resource.description ?? undefined, + githubRepos: reference.githubSources, + }); + const ownerEmail = getRequestUserEmail(); + if (!ownerEmail) throw new Error("no authenticated user"); + + const proxy = await upsertBuilderProxyDesignSystem({ + result, + ownerEmail, + orgId: getRequestOrgId(), + projectName: access.resource.title, + description: access.resource.description ?? undefined, + sourceKind: reference.sourceKind, + githubSources: reference.githubSources, + localDesignSystemId: id, + }); + + return { + ...result, + ...proxy, + synced: true, + githubSourceCount: reference.githubSources.length, + }; + }, +}); diff --git a/templates/design/app/components/design/code-workbench/commands.ts b/templates/design/app/components/design/code-workbench/commands.ts index 93e603aa54..9c4cf63af5 100644 --- a/templates/design/app/components/design/code-workbench/commands.ts +++ b/templates/design/app/components/design/code-workbench/commands.ts @@ -169,7 +169,7 @@ const MAC_KEY_LABELS: Record = { escape: "⎋", }; -/** Human-readable keybinding, e.g. "⇧⌘P" on macOS or "Ctrl+Shift+P". */ +/** Human-readable keybinding, e.g. "⇧⌘P" on macOS or "Ctrl Shift P". */ export function formatKeybinding(binding: string): string { const parsed = parseKeybinding(binding); const keyLabel = @@ -191,7 +191,7 @@ export function formatKeybinding(binding: string): string { if (parsed.alt) parts.push("Alt"); if (parsed.shift) parts.push("Shift"); parts.push(keyLabel.length === 1 ? keyLabel.toUpperCase() : keyLabel); - return parts.join("+"); + return parts.join(" "); } /** diff --git a/templates/design/app/components/design/inspector/DesignColorPicker.tsx b/templates/design/app/components/design/inspector/DesignColorPicker.tsx index 158958bb24..a901b9e113 100644 --- a/templates/design/app/components/design/inspector/DesignColorPicker.tsx +++ b/templates/design/app/components/design/inspector/DesignColorPicker.tsx @@ -270,9 +270,9 @@ const DEFAULT_LABELS: DesignColorPickerLabels = { diamond: "Diamond", // i18n-ignore fallback component label }; -// checkerboard: explicit light/dark tiles for legibility. -const CHECKER_A = "#d4d4d4"; -const CHECKER_B = "#a3a3a3"; +// Keep transparency tiles light on both light and dark editor surfaces. +const CHECKER_A = "#e5e5e5"; +const CHECKER_B = "#f5f5f5"; const CHECKERBOARD_IMAGE = `linear-gradient(45deg, ${CHECKER_A} 25%, transparent 25%), linear-gradient(-45deg, ${CHECKER_A} 25%, transparent 25%), linear-gradient(45deg, transparent 75%, ${CHECKER_A} 75%), linear-gradient(-45deg, transparent 75%, ${CHECKER_A} 75%)`; // ─── Paint-type icon SVGs (Tabler style, distinct per type) ──────────────────── @@ -1676,6 +1676,7 @@ export function DesignColorPicker({ max={100} disabled={disabled} backgroundImage={alphaTrackBackground(fieldColor)} + backgroundColor={CHECKER_B} backgroundSize="8px 8px, 8px 8px, 8px 8px, 8px 8px, 100% 100%" backgroundPosition="0 0, 0 4px, 4px -4px, -4px 0, 0 0" onChange={(next) => { @@ -2062,6 +2063,7 @@ function ColorTrack({ max, disabled, backgroundImage, + backgroundColor, backgroundSize, backgroundPosition, onChange, @@ -2073,6 +2075,7 @@ function ColorTrack({ max: number; disabled: boolean; backgroundImage: string; + backgroundColor?: string; backgroundSize?: string; backgroundPosition?: string; onChange: (value: number) => void; @@ -2160,7 +2163,12 @@ function ColorTrack({ "active:cursor-grabbing", disabled && "cursor-not-allowed opacity-60", )} - style={{ backgroundImage, backgroundSize, backgroundPosition }} + style={{ + backgroundImage, + backgroundColor, + backgroundSize, + backgroundPosition, + }} > {/* Thumb overhangs the track slightly, matching the design editor */} { }); } -const CHECKER_A = "#d4d4d4"; +const CHECKER_A = "#e5e5e5"; +const CHECKER_B = "#f5f5f5"; const CHECKERBOARD_IMAGE = `linear-gradient(45deg, ${CHECKER_A} 25%, transparent 25%), linear-gradient(-45deg, ${CHECKER_A} 25%, transparent 25%), linear-gradient(45deg, transparent 75%, ${CHECKER_A} 75%), linear-gradient(-45deg, transparent 75%, ${CHECKER_A} 75%)`; const FIT_MARKER_RE = /\/\*\s*agent-native-image-fit:(fill|fit|crop|tile)\s*\*\//i; @@ -345,6 +346,7 @@ export function ImageFillControls({ backgroundImage: value.url ? `url("${escapeForQuotedUrl(value.url.trim())}")` : CHECKERBOARD_IMAGE, + backgroundColor: value.url ? undefined : CHECKER_B, backgroundSize: value.url ? value.fit === "fit" ? "contain" diff --git a/templates/design/app/hooks/use-question-flow.spec.ts b/templates/design/app/hooks/use-question-flow.spec.ts new file mode 100644 index 0000000000..d31524a80d --- /dev/null +++ b/templates/design/app/hooks/use-question-flow.spec.ts @@ -0,0 +1,52 @@ +/** + * The intake turn is forced to emit only a questionnaire and stop, so the + * continuation is the turn that actually writes HTML. Anything the user + * supplied at kickoff — their words, their reference screenshot, their design + * system — reaches generation only if this builder replays it. + */ + +import { describe, expect, it } from "vitest"; + +import { buildGenerationBriefContext } from "./use-question-flow.js"; + +const DESIGN_SYSTEM_CONTEXT = + "## Selected Design System Context\nUse Flo System as the visual source of truth."; + +describe("buildGenerationBriefContext", () => { + it("replays the prompt verbatim rather than paraphrasing it", () => { + const prompt = "A dark ops console with a left rail and dense data tables."; + const context = buildGenerationBriefContext({ prompt }, ""); + expect(context).toContain(prompt); + expect(context).toContain("verbatim"); + }); + + it("carries the design system into the generating turn", () => { + const context = buildGenerationBriefContext( + { prompt: "Build it", designSystemId: "ds_1" }, + DESIGN_SYSTEM_CONTEXT, + ); + expect(context).toContain("Flo System"); + }); + + it("tells the model an attached screenshot is a layout spec", () => { + const context = buildGenerationBriefContext( + { images: ["data:image/png;base64,AAA", "data:image/png;base64,BBB"] }, + "", + ); + expect(context).toContain("2 reference image(s)"); + expect(context).toContain("layout specification"); + }); + + it("keeps extracted text from uploaded specs", () => { + const context = buildGenerationBriefContext( + { uploadedFileContext: "Extracted text:\nStep 1. Sign in screen" }, + "", + ); + expect(context).toContain("Step 1. Sign in screen"); + }); + + it("emits nothing when there is no brief, rather than empty scaffolding", () => { + expect(buildGenerationBriefContext(null, "")).toBe(""); + expect(buildGenerationBriefContext({ prompt: " " }, "")).toBe(""); + }); +}); diff --git a/templates/design/app/hooks/use-question-flow.ts b/templates/design/app/hooks/use-question-flow.ts index 0abaed66cb..1b3751dd01 100644 --- a/templates/design/app/hooks/use-question-flow.ts +++ b/templates/design/app/hooks/use-question-flow.ts @@ -7,6 +7,7 @@ import { type PromptComposerSubmitOptions } from "@agent-native/core/client/comp import { useCallback } from "react"; import { sendToDesignAgentChat } from "@/lib/agent-chat"; +import { loadDesignSystemGenerationContext } from "@/pages/design-editor/generation-prompt-directives"; export interface QuestionFlowModelSelection { model?: string; @@ -14,6 +15,23 @@ export interface QuestionFlowModelSelection { effort?: PromptComposerSubmitOptions["effort"]; } +/** + * What the user actually supplied at kickoff. The intake turn is forced to emit + * only a questionnaire and then stop, so THIS continuation is the turn that + * writes HTML — and a fresh thread inherits nothing. Re-sending the brief here + * is the only thing that puts the user's own prompt, reference screenshots, and + * design system in front of the model at the moment it generates. + */ +export interface QuestionFlowGenerationBrief { + /** The user's original words, replayed verbatim — never a paraphrase. */ + prompt?: string; + designSystemId?: string | null; + /** Data URLs; re-attached so the reference screenshot survives the hop. */ + images?: string[]; + /** Extracted text from uploaded files (specs, outlines, token dumps). */ + uploadedFileContext?: string; +} + interface UseQuestionFlowOptions { enabled?: boolean; continuationTabId?: string | null; @@ -25,12 +43,49 @@ interface UseQuestionFlowOptions { * filled after render, so a snapshot taken here would be the pre-kickoff one. */ getModelSelection?: () => QuestionFlowModelSelection | null | undefined; + /** Read at send time, for the same reason as `getModelSelection`. */ + getGenerationBrief?: () => QuestionFlowGenerationBrief | null | undefined; } function designQuestionsStateKey(designId: string | undefined): string { return designId ? `show-questions:${designId}` : "show-questions"; } +/** + * Render the kickoff brief as prompt context for the continuation turn. + * Exported and pure so the carry-through is testable without a DOM — this is + * the whole fix for "the agent ignored my design system / screenshot / brief". + */ +export function buildGenerationBriefContext( + brief: QuestionFlowGenerationBrief | null | undefined, + designSystemContext: string, +): string { + return [ + brief?.prompt?.trim() + ? [ + "## The user's original request (verbatim)", + "This is the spec for what to build. The answers below refine it;", + "they do not replace it. Do not restate it as a looser paraphrase.", + "", + brief.prompt.trim(), + ].join("\n") + : "", + brief?.images?.length + ? [ + `## ${brief.images.length} reference image(s) re-attached to this message`, + "Treat an attached UI screenshot as a layout specification to", + "reproduce — its structure, hierarchy, density, and component", + "grammar — not as loose inspiration. Match it unless an answer", + "below explicitly overrides a part of it.", + ].join("\n") + : "", + brief?.uploadedFileContext?.trim() ?? "", + designSystemContext, + ] + .filter(Boolean) + .join("\n\n"); +} + const RESPONSIVE_GENERATION_REQUIREMENTS = 'Responsive behavior is mandatory for every web design. Read the form-factor answer above: for Desktop or Both/responsive, call generate-design with `primaryViewport: "desktop"` and a 1440x1024 canvas frame; use `primaryViewport: "mobile"` only for an explicitly mobile-primary choice. Use mobile-first responsive CSS, then take desktop and mobile screenshots and fix any overflow before reporting the design complete.'; @@ -47,6 +102,7 @@ export function useQuestionFlow( continuationTabId, onContinue, getModelSelection, + getGenerationBrief, }: UseQuestionFlowOptions = {}, ) { const stateKey = designQuestionsStateKey(designId); @@ -79,9 +135,21 @@ export function useQuestionFlow( }); const sendContinuation = useCallback( - (message: string, context?: string) => { + async (message: string, context?: string) => { const selection = getModelSelection?.() ?? {}; const { model, engine, effort } = selection; + const brief = getGenerationBrief?.() ?? null; + // Re-hydrated rather than snapshotted at kickoff: the user can link or + // change the design system while the questionnaire is open. This never + // throws — a load failure returns instruction text telling the agent to + // stop rather than improvise a generic style. + const designSystemContext = brief?.designSystemId + ? await loadDesignSystemGenerationContext(brief.designSystemId) + : ""; + const briefContext = buildGenerationBriefContext( + brief, + designSystemContext, + ); // Always request `newTab` (mirroring useAgentGenerating.submit's // default). Without it, when there is no continuationTabId yet the // message goes to whatever tab is currently active, but the id we @@ -96,9 +164,10 @@ export function useQuestionFlow( // real destination thread. const tabId = sendToDesignAgentChat({ message, - context, + context: [briefContext, context].filter(Boolean).join("\n\n"), submit: true, newTab: true, + ...(brief?.images?.length ? { images: brief.images } : {}), ...(continuationTabId ? { tabId: continuationTabId } : {}), ...(model ? { model } : {}), ...(engine ? { engine } : {}), @@ -107,7 +176,14 @@ export function useQuestionFlow( onContinue?.(tabId); flow.clear(); }, - [continuationTabId, designId, flow, getModelSelection, onContinue], + [ + continuationTabId, + designId, + flow, + getGenerationBrief, + getModelSelection, + onContinue, + ], ); const handleSubmit = useCallback( @@ -129,13 +205,13 @@ export function useQuestionFlow( .filter(Boolean) .join("\n"); - sendContinuation("Here are my answers — go ahead.", context); + void sendContinuation("Here are my answers — go ahead.", context); }, [designId, sendContinuation], ); const handleSkip = useCallback(() => { - sendContinuation( + void sendContinuation( "Skip the questions — decide for me.", designId ? `The user skipped the pre-generation questions for design ${designId}. Proceed with reasonable defaults. ${RESPONSIVE_GENERATION_REQUIREMENTS} Generate one polished first direction unless the original prompt explicitly requested options.` diff --git a/templates/design/app/i18n-data.ts b/templates/design/app/i18n-data.ts index 32f8348b5b..08017c6c34 100644 --- a/templates/design/app/i18n-data.ts +++ b/templates/design/app/i18n-data.ts @@ -754,7 +754,7 @@ const enUS = { big: "Big nudge", unit: "px", description: - "Arrow keys move by the small amount and Shift+arrow by the big one. Inside an auto layout frame, arrows reorder the layer instead of moving it.", + "Arrow keys move by the small amount and Shift arrow by the big one. Inside an auto layout frame, arrows reorder the layer instead of moving it.", }, keys: { or: "or", @@ -1487,6 +1487,8 @@ const enUS = { websiteUrl: "Website-URL", add: "Add", githubRepository: "GitHub repository", + githubRef: "Branch, tag, or commit (optional)", + githubPaths: "Files or folders, comma-separated (optional)", privateRepoPrefix: "Private repos need a fine-grained token saved as", privateRepoSuffix: "with Contents read access.", localCodeFiles: "Connect code files", @@ -1658,6 +1660,7 @@ const enUS = { logos: "Logos", assets: "Assets", savedCount: "{{count}} saved", + namedTokens: "Named tokens", colorLabels: { primary: "Primary", secondary: "Secondary", @@ -1964,6 +1967,8 @@ const designLocaleOverrides = { websiteUrl: "网站URL", add: "添加", githubRepository: "GitHub 存储库", + githubRef: "分支、标签或提交(可选)", + githubPaths: "文件或文件夹,以逗号分隔(可选)", privateRepoPrefix: "私人仓库需要一个细粒度的令牌,另存为", privateRepoSuffix: "具有内容读取权限。", localCodeFiles: "连接代码文件", @@ -2128,6 +2133,7 @@ const designLocaleOverrides = { logos: "标志", assets: "资产", savedCount: "已保存 {{count}} 个", + namedTokens: "具名代币", colorLabels: { primary: "基本的", secondary: "中学", @@ -2379,6 +2385,8 @@ const designLocaleOverrides = { websiteUrl: "Sitio web URL", add: "Agregar", githubRepository: "repositorio GitHub", + githubRef: "Rama, etiqueta o commit (opcional)", + githubPaths: "Archivos o carpetas, separados por comas (opcional)", privateRepoPrefix: "Los repositorios privados necesitan un token detallado guardado como", privateRepoSuffix: "con acceso de lectura de contenidos.", @@ -2558,6 +2566,7 @@ const designLocaleOverrides = { logos: "Logotipos", assets: "Activos", savedCount: "{{count}} guardado", + namedTokens: "Tokens con nombre", colorLabels: { primary: "Primario", secondary: "Secundario", @@ -2814,6 +2823,9 @@ const designLocaleOverrides = { websiteUrl: "Site Web URL", add: "Ajouter", githubRepository: "référentiel GitHub", + githubRef: "Branche, tag ou commit (facultatif)", + githubPaths: + "Fichiers ou dossiers, séparés par des virgules (facultatif)", privateRepoPrefix: "Les dépôts privés nécessitent un jeton à granularité fine enregistré sous", privateRepoSuffix: "avec accès en lecture au contenu.", @@ -2993,6 +3005,7 @@ const designLocaleOverrides = { logos: "Logos", assets: "Actifs", savedCount: "{{count}} enregistré", + namedTokens: "Jetons nommés", colorLabels: { primary: "Primaire", secondary: "Secondaire", @@ -3249,6 +3262,8 @@ const designLocaleOverrides = { websiteUrl: "Website URL", add: "Hinzufügen", githubRepository: "GitHub-Repository", + githubRef: "Branch, Tag oder Commit (optional)", + githubPaths: "Dateien oder Ordner, durch Kommas getrennt (optional)", privateRepoPrefix: "Private Repos benötigen einen feinkörnigen Token, der unter gespeichert wird", privateRepoSuffix: "mit Lesezugriff auf Inhalte.", @@ -3429,6 +3444,7 @@ const designLocaleOverrides = { logos: "Logos", assets: "Vermögenswerte", savedCount: "{{count}} gespeichert", + namedTokens: "Benannte Tokens", colorLabels: { primary: "Primär", secondary: "Sekundär", @@ -3684,6 +3700,8 @@ const designLocaleOverrides = { websiteUrl: "ウェブサイト URL", add: "追加", githubRepository: "GitHub リポジトリ", + githubRef: "ブランチ、タグ、コミット(任意)", + githubPaths: "ファイルまたはフォルダー(カンマ区切り、任意)", privateRepoPrefix: "プライベート リポジトリには、次のように保存されたきめ細かいトークンが必要です。", privateRepoSuffix: "コンテンツ読み取りアクセス付き。", @@ -3860,6 +3878,7 @@ const designLocaleOverrides = { logos: "ロゴ", assets: "資産", savedCount: "{{count}} 保存しました", + namedTokens: "名前付きトークン", colorLabels: { primary: "主要な", secondary: "二次", @@ -4113,6 +4132,8 @@ const designLocaleOverrides = { websiteUrl: "웹사이트 URL", add: "추가하다", githubRepository: "GitHub 저장소", + githubRef: "브랜치, 태그 또는 커밋 (선택 사항)", + githubPaths: "파일 또는 폴더, 쉼표로 구분 (선택 사항)", privateRepoPrefix: "비공개 저장소에는 다음과 같이 저장된 세분화된 토큰이 필요합니다.", privateRepoSuffix: "콘텐츠 읽기 액세스 권한이 있습니다.", @@ -4284,6 +4305,7 @@ const designLocaleOverrides = { logos: "로고", assets: "자산", savedCount: "{{count}}가 저장되었습니다.", + namedTokens: "이름이 지정된 토큰", colorLabels: { primary: "주요한", secondary: "반성", @@ -4538,6 +4560,8 @@ const designLocaleOverrides = { websiteUrl: "Site URL", add: "Adicionar", githubRepository: "repositório GitHub", + githubRef: "Branch, tag ou commit (opcional)", + githubPaths: "Arquivos ou pastas, separados por vírgulas (opcional)", privateRepoPrefix: "Os repositórios privados precisam de um token refinado salvo como", privateRepoSuffix: "com acesso de leitura de conteúdo.", @@ -4715,6 +4739,7 @@ const designLocaleOverrides = { logos: "Logotipos", assets: "Ativos", savedCount: "{{count}} salvo", + namedTokens: "Tokens nomeados", colorLabels: { primary: "Primário", secondary: "Secundário", @@ -4967,6 +4992,8 @@ const designLocaleOverrides = { websiteUrl: "वेबसाइट URL", add: "जोड़ना", githubRepository: "GitHub रिपॉजिटरी", + githubRef: "ब्रांच, टैग या कमिट (वैकल्पिक)", + githubPaths: "फ़ाइलें या फ़ोल्डर, कॉमा से अलग (वैकल्पिक)", privateRepoPrefix: "निजी रेपो को एक बढ़िया टोकन के रूप में सहेजे जाने की आवश्यकता होती है", privateRepoSuffix: "सामग्री पढ़ने की पहुंच के साथ।", @@ -5137,6 +5164,7 @@ const designLocaleOverrides = { logos: "लोगो", assets: "संपत्ति", savedCount: "{{count}} सहेजा गया", + namedTokens: "नामित टोकन", colorLabels: { primary: "प्राथमिक", secondary: "माध्यमिक", @@ -5387,6 +5415,8 @@ const designLocaleOverrides = { websiteUrl: "موقع URL", add: "يضيف", githubRepository: "مستودع GitHub", + githubRef: "فرع أو وسم أو التزام (اختياري)", + githubPaths: "الملفات أو المجلدات، مفصولة بفواصل (اختياري)", privateRepoPrefix: "تحتاج عمليات إعادة الشراء الخاصة إلى رمز مميز محفوظ باسم", privateRepoSuffix: "مع محتويات الوصول للقراءة.", @@ -5558,6 +5588,7 @@ const designLocaleOverrides = { logos: "الشعارات", assets: "أصول", savedCount: "تم حفظ {{count}}", + namedTokens: "الرموز المميزة المسماة", colorLabels: { primary: "أساسي", secondary: "ثانوي", diff --git a/templates/design/app/i18n-keyboard-shortcuts.ts b/templates/design/app/i18n-keyboard-shortcuts.ts index 7d89c4a714..8151d30e35 100644 --- a/templates/design/app/i18n-keyboard-shortcuts.ts +++ b/templates/design/app/i18n-keyboard-shortcuts.ts @@ -58,7 +58,7 @@ export const keyboardShortcutLabels = { nudgeAmountSmall: "小距離", nudgeAmountBig: "大距離", nudgeAmountDescription: - "方向鍵以小距離移動,Shift+方向鍵以大距離移動。在自動版面配置框架內,方向鍵改為調整圖層順序。", + "方向鍵以小距離移動,Shift 方向鍵以大距離移動。在自動版面配置框架內,方向鍵改為調整圖層順序。", }, "zh-CN": { title: "键盘快捷键", @@ -76,7 +76,7 @@ export const keyboardShortcutLabels = { nudgeAmountSmall: "小距离", nudgeAmountBig: "大距离", nudgeAmountDescription: - "方向键以小距离移动,Shift+方向键以大距离移动。在自动布局框架内,方向键改为调整图层顺序。", + "方向键以小距离移动,Shift 方向键以大距离移动。在自动布局框架内,方向键改为调整图层顺序。", }, "es-ES": { title: "Atajos de teclado", @@ -95,7 +95,7 @@ export const keyboardShortcutLabels = { nudgeAmountSmall: "Desplazamiento pequeño", nudgeAmountBig: "Desplazamiento grande", nudgeAmountDescription: - "Las flechas mueven la cantidad pequeña y Mayús+flecha la grande. Dentro de un marco de autodiseño, las flechas reordenan la capa.", + "Las flechas mueven la cantidad pequeña y Mayús flecha la grande. Dentro de un marco de autodiseño, las flechas reordenan la capa.", }, "fr-FR": { title: "Raccourcis clavier", @@ -115,7 +115,7 @@ export const keyboardShortcutLabels = { nudgeAmountSmall: "Petit déplacement", nudgeAmountBig: "Grand déplacement", nudgeAmountDescription: - "Les flèches déplacent de la petite valeur et Maj+flèche de la grande. Dans un cadre en disposition automatique, les flèches réorganisent le calque.", + "Les flèches déplacent de la petite valeur et Maj flèche de la grande. Dans un cadre en disposition automatique, les flèches réorganisent le calque.", }, "de-DE": { title: "Tastenkürzel", @@ -135,7 +135,7 @@ export const keyboardShortcutLabels = { nudgeAmountSmall: "Kleiner Schritt", nudgeAmountBig: "Großer Schritt", nudgeAmountDescription: - "Pfeiltasten verschieben um den kleinen Wert, Umschalt+Pfeil um den großen. In einem Auto-Layout-Rahmen ordnen die Pfeiltasten die Ebene neu an.", + "Pfeiltasten verschieben um den kleinen Wert, Umschalt Pfeil um den großen. In einem Auto-Layout-Rahmen ordnen die Pfeiltasten die Ebene neu an.", }, "ja-JP": { title: "キーボードショートカット", @@ -153,7 +153,7 @@ export const keyboardShortcutLabels = { nudgeAmountSmall: "小さい移動量", nudgeAmountBig: "大きい移動量", nudgeAmountDescription: - "矢印キーは小さい移動量、Shift+矢印キーは大きい移動量で移動します。オートレイアウトフレーム内では、矢印キーはレイヤーの順序を変更します。", + "矢印キーは小さい移動量、Shift 矢印キーは大きい移動量で移動します。オートレイアウトフレーム内では、矢印キーはレイヤーの順序を変更します。", }, "ko-KR": { title: "키보드 단축키", @@ -171,7 +171,7 @@ export const keyboardShortcutLabels = { nudgeAmountSmall: "작은 이동", nudgeAmountBig: "큰 이동", nudgeAmountDescription: - "화살표 키는 작은 간격으로, Shift+화살표 키는 큰 간격으로 이동합니다. 오토 레이아웃 프레임 안에서는 화살표 키가 레이어 순서를 변경합니다.", + "화살표 키는 작은 간격으로, Shift 화살표 키는 큰 간격으로 이동합니다. 오토 레이아웃 프레임 안에서는 화살표 키가 레이어 순서를 변경합니다.", }, "pt-BR": { title: "Atalhos de teclado", @@ -191,7 +191,7 @@ export const keyboardShortcutLabels = { nudgeAmountSmall: "Deslocamento pequeno", nudgeAmountBig: "Deslocamento grande", nudgeAmountDescription: - "As setas movem pela distância pequena e Shift+seta pela grande. Dentro de um frame com layout automático, as setas reordenam a camada.", + "As setas movem pela distância pequena e Shift seta pela grande. Dentro de um frame com layout automático, as setas reordenam a camada.", }, "hi-IN": { title: "कीबोर्ड शॉर्टकट", @@ -209,7 +209,7 @@ export const keyboardShortcutLabels = { nudgeAmountSmall: "छोटी नज", nudgeAmountBig: "बड़ी नज", nudgeAmountDescription: - "ऐरो कुंजियाँ छोटी दूरी से और Shift+ऐरो बड़ी दूरी से ले जाती हैं। ऑटो लेआउट फ़्रेम के अंदर ऐरो कुंजियाँ लेयर का क्रम बदलती हैं।", + "ऐरो कुंजियाँ छोटी दूरी से और Shift ऐरो बड़ी दूरी से ले जाती हैं। ऑटो लेआउट फ़्रेम के अंदर ऐरो कुंजियाँ लेयर का क्रम बदलती हैं।", }, "ar-SA": { title: "اختصارات لوحة المفاتيح", @@ -227,7 +227,7 @@ export const keyboardShortcutLabels = { nudgeAmountSmall: "إزاحة صغيرة", nudgeAmountBig: "إزاحة كبيرة", nudgeAmountDescription: - "تحرّك مفاتيح الأسهم بالمقدار الصغير، وShift+سهم بالمقدار الكبير. داخل إطار التخطيط التلقائي، تعيد مفاتيح الأسهم ترتيب الطبقة.", + "تحرّك مفاتيح الأسهم بالمقدار الصغير، وShift سهم بالمقدار الكبير. داخل إطار التخطيط التلقائي، تعيد مفاتيح الأسهم ترتيب الطبقة.", }, } satisfies Record, KeyboardShortcutLabels>; diff --git a/templates/design/app/i18n/ar-SA.ts b/templates/design/app/i18n/ar-SA.ts index 4728cc3020..f7b794ab6a 100644 --- a/templates/design/app/i18n/ar-SA.ts +++ b/templates/design/app/i18n/ar-SA.ts @@ -57,7 +57,7 @@ const messages = { commentMode: "إضافة تعليق", sendToAgent: "إرسال إلى الوكيل", sendingToAgent: "جارٍ الإرسال إلى الوكيل…", - enterToPost: "اضغط Enter للنشر · وShift+Enter لسطر جديد", + enterToPost: "اضغط Enter للنشر · وShift Enter لسطر جديد", post: "نشر", posting: "جارٍ النشر…", postFailed: "تعذر نشر هذا التعليق", diff --git a/templates/design/app/i18n/de-DE.ts b/templates/design/app/i18n/de-DE.ts index c432f09348..860a9d37fb 100644 --- a/templates/design/app/i18n/de-DE.ts +++ b/templates/design/app/i18n/de-DE.ts @@ -57,7 +57,7 @@ const messages = { commentMode: "Kommentieren", sendToAgent: "An den Agenten senden", sendingToAgent: "Wird an den Agenten gesendet…", - enterToPost: "Mit Enter posten · Mit Umschalt+Enter neue Zeile", + enterToPost: "Mit Enter posten · Mit Umschalt Enter neue Zeile", post: "Posten", posting: "Wird gepostet…", postFailed: "Dieser Kommentar konnte nicht gepostet werden", diff --git a/templates/design/app/i18n/en-US.ts b/templates/design/app/i18n/en-US.ts index e7abeec678..fa20d84df5 100644 --- a/templates/design/app/i18n/en-US.ts +++ b/templates/design/app/i18n/en-US.ts @@ -25,7 +25,7 @@ const messages = { commentMode: "Comment", sendToAgent: "Send to agent", sendingToAgent: "Sending to agent…", - enterToPost: "Enter to post · Shift+Enter for a new line", + enterToPost: "Enter to post · Shift Enter for a new line", post: "Post", posting: "Posting…", postFailed: "Could not post this comment", diff --git a/templates/design/app/i18n/es-ES.ts b/templates/design/app/i18n/es-ES.ts index 21e82c8aec..7cd3a054fd 100644 --- a/templates/design/app/i18n/es-ES.ts +++ b/templates/design/app/i18n/es-ES.ts @@ -57,7 +57,7 @@ const messages = { commentMode: "Comentar", sendToAgent: "Enviar al agente", sendingToAgent: "Enviando al agente…", - enterToPost: "Pulsa Enter para publicar · Shift+Enter para una línea nueva", + enterToPost: "Pulsa Enter para publicar · Shift Enter para una línea nueva", post: "Publicar", posting: "Publicando…", postFailed: "No se pudo publicar este comentario", diff --git a/templates/design/app/i18n/fr-FR.ts b/templates/design/app/i18n/fr-FR.ts index 7528031a49..69aa9a4d62 100644 --- a/templates/design/app/i18n/fr-FR.ts +++ b/templates/design/app/i18n/fr-FR.ts @@ -59,7 +59,7 @@ const messages = { sendToAgent: "Envoyer à l’agent", sendingToAgent: "Envoi à l’agent…", enterToPost: - "Appuyez sur Entrée pour publier · Maj+Entrée pour insérer une nouvelle ligne", + "Appuyez sur Entrée pour publier · Maj Entrée pour insérer une nouvelle ligne", post: "Publier", posting: "Publication…", postFailed: "Impossible de publier ce commentaire", diff --git a/templates/design/app/i18n/hi-IN.ts b/templates/design/app/i18n/hi-IN.ts index 1fbd8e054d..c814980c56 100644 --- a/templates/design/app/i18n/hi-IN.ts +++ b/templates/design/app/i18n/hi-IN.ts @@ -57,7 +57,7 @@ const messages = { commentMode: "टिप्पणी करें", sendToAgent: "एजेंट को भेजें", sendingToAgent: "एजेंट को भेजा जा रहा है…", - enterToPost: "पोस्ट करने के लिए Enter · नई पंक्ति के लिए Shift+Enter", + enterToPost: "पोस्ट करने के लिए Enter · नई पंक्ति के लिए Shift Enter", post: "पोस्ट करें", posting: "पोस्ट किया जा रहा है…", postFailed: "यह टिप्पणी पोस्ट नहीं की जा सकी", diff --git a/templates/design/app/i18n/ja-JP.ts b/templates/design/app/i18n/ja-JP.ts index 9cce5ecc31..e501b4de67 100644 --- a/templates/design/app/i18n/ja-JP.ts +++ b/templates/design/app/i18n/ja-JP.ts @@ -57,7 +57,7 @@ const messages = { commentMode: "コメント", sendToAgent: "エージェントに送信", sendingToAgent: "エージェントに送信中…", - enterToPost: "Enter で投稿 · Shift+Enter で改行", + enterToPost: "Enter で投稿 · Shift Enter で改行", post: "投稿", posting: "投稿中…", postFailed: "このコメントを投稿できませんでした", diff --git a/templates/design/app/i18n/ko-KR.ts b/templates/design/app/i18n/ko-KR.ts index 8fa2195c35..fad99c4bd6 100644 --- a/templates/design/app/i18n/ko-KR.ts +++ b/templates/design/app/i18n/ko-KR.ts @@ -57,7 +57,7 @@ const messages = { commentMode: "댓글 달기", sendToAgent: "에이전트에게 보내기", sendingToAgent: "에이전트에게 보내는 중…", - enterToPost: "Enter로 게시 · Shift+Enter로 줄 바꿈", + enterToPost: "Enter로 게시 · Shift Enter로 줄 바꿈", post: "게시", posting: "게시 중…", postFailed: "이 댓글을 게시할 수 없습니다", diff --git a/templates/design/app/i18n/pt-BR.ts b/templates/design/app/i18n/pt-BR.ts index 9a7d94c310..cb1a35339e 100644 --- a/templates/design/app/i18n/pt-BR.ts +++ b/templates/design/app/i18n/pt-BR.ts @@ -58,7 +58,7 @@ const messages = { sendToAgent: "Enviar ao agente", sendingToAgent: "Enviando ao agente…", enterToPost: - "Pressione Enter para publicar · Shift+Enter para uma nova linha", + "Pressione Enter para publicar · Shift Enter para uma nova linha", post: "Publicar", posting: "Publicando…", postFailed: "Não foi possível publicar este comentário", diff --git a/templates/design/app/i18n/zh-CN.ts b/templates/design/app/i18n/zh-CN.ts index f21a74cf23..b03f5e70f3 100644 --- a/templates/design/app/i18n/zh-CN.ts +++ b/templates/design/app/i18n/zh-CN.ts @@ -57,7 +57,7 @@ const messages = { commentMode: "评论", sendToAgent: "发送给智能体", sendingToAgent: "正在发送给智能体…", - enterToPost: "按 Enter 发布 · 按 Shift+Enter 换行", + enterToPost: "按 Enter 发布 · 按 Shift Enter 换行", post: "发布", posting: "正在发布…", postFailed: "无法发布此评论", diff --git a/templates/design/app/i18n/zh-TW.ts b/templates/design/app/i18n/zh-TW.ts index 18b8ccaf97..54d7c3762b 100644 --- a/templates/design/app/i18n/zh-TW.ts +++ b/templates/design/app/i18n/zh-TW.ts @@ -64,7 +64,7 @@ const messages = { commentMode: "評論", sendToAgent: "傳送給代理", sendingToAgent: "正在傳送給代理…", - enterToPost: "按 Enter 發布 · 按 Shift+Enter 換行", + enterToPost: "按 Enter 發布 · 按 Shift Enter 換行", post: "發布", posting: "正在發布…", postFailed: "無法發布這則評論", @@ -1287,6 +1287,8 @@ const messages = { websiteUrl: "網站 URL", add: "新增", githubRepository: "GitHub 儲存庫", + githubRef: "分支、標籤或提交(選用)", + githubPaths: "檔案或資料夾,以逗號分隔(選用)", privateRepoPrefix: "私人倉庫需要一個細粒度的權杖,另存為", privateRepoSuffix: "具有內容讀取權限。", localCodeFiles: "連接程式碼檔案", @@ -1451,6 +1453,7 @@ const messages = { logos: "標誌", assets: "資產", savedCount: "已儲存 {{count}} 個", + namedTokens: "具名代幣", colorLabels: { primary: "基本的", secondary: "中學", diff --git a/templates/design/app/pages/DesignEditor.tsx b/templates/design/app/pages/DesignEditor.tsx index 6c4a454e89..efa967913a 100644 --- a/templates/design/app/pages/DesignEditor.tsx +++ b/templates/design/app/pages/DesignEditor.tsx @@ -3854,6 +3854,20 @@ function DesignEditor() { readPendingGeneration(id, { allowUntimestamped: true }), [id], ); + // The intake turn carries the prompt, screenshots, and design system, but is + // forced to stop after asking questions. The continuation is what actually + // generates, so it has to carry them again or the design is built blind. + const getQuestionFlowGenerationBrief = useCallback(() => { + const pending = readPendingGeneration(id, { allowUntimestamped: true }); + if (!pending) return null; + const files = pending.files ?? []; + return { + prompt: pending.prompt, + designSystemId: pending.designSystemId, + images: imageAttachmentsFromUploadedFiles(files), + uploadedFileContext: formatUploadedFileContext(files), + }; + }, [id]); const { questions: pendingQuestions, title: pendingQuestionsTitle, @@ -3867,6 +3881,7 @@ function DesignEditor() { continuationTabId: generationChatTabId, onContinue: handleQuestionFlowContinue, getModelSelection: getQuestionFlowModelSelection, + getGenerationBrief: getQuestionFlowGenerationBrief, }); const pendingQuestionsVisible = Boolean( pendingQuestions && pendingQuestions.length > 0, @@ -6077,8 +6092,14 @@ function DesignEditor() { let cancelled = false; void (async () => { const shouldExploreVariants = promptRequestsVariantExploration(prompt); + // A reference screenshot already answers the questions the intake flow + // asks. Spending the one turn that can see the image on a questionnaire + // means the turn that writes HTML never sees it. + const hasReferenceImages = images.length > 0; const shouldSkipQuestions = - pending.skipQuestions === true || shouldExploreVariants; + pending.skipQuestions === true || + shouldExploreVariants || + hasReferenceImages; const designSystemContext = await loadDesignSystemGenerationContext( pendingDesignSystemId, ); @@ -6103,8 +6124,16 @@ function DesignEditor() { : shouldExploreVariants ? designVariantGenerationDirectives(id, pendingDesignSystemId) : shouldSkipQuestions - ? designGenerationDirectives(id, pendingDesignSystemId) - : designIntakeQuestionDirectives(id, pendingDesignSystemId)), + ? designGenerationDirectives( + id, + pendingDesignSystemId, + images.length, + ) + : designIntakeQuestionDirectives( + id, + pendingDesignSystemId, + images.length, + )), ].join("\n"); clearGenerationCompleteTimer(); @@ -30703,7 +30732,7 @@ function DesignEditor() { > {"Zoom in" /* i18n-ignore */} - ⌘+ + {"Cmd Plus" /* i18n-ignore shortcut key label */} {"Zoom out" /* i18n-ignore */} - ⌘− + {"Cmd Minus" /* i18n-ignore shortcut key label */} ; connectedProjectId?: string; codeFiles?: Array<{ filename: string; @@ -93,6 +101,8 @@ export default function DesignSystemSetup() { const [websiteUrl, setWebsiteUrl] = useState(""); const [websiteUrls, setWebsiteUrls] = useState([]); const [githubUrl, setGithubUrl] = useState(""); + const [githubRef, setGithubRef] = useState(""); + const [githubPaths, setGithubPaths] = useState(""); const [githubLinks, setGithubLinks] = useState([]); const [codeFiles, setCodeFiles] = useState([]); const [docFiles, setDocFiles] = useState([]); @@ -308,10 +318,24 @@ export default function DesignSystemSetup() { setValidationError(t("designSystemSetup.errors.githubUrl")); return; } - setGithubLinks((prev) => [...prev, { id: crypto.randomUUID(), url }]); + const include = githubPaths + .split(/[\n,]/) + .map((path) => path.trim().replace(/^\/+|\/+$/g, "")) + .filter(Boolean); + setGithubLinks((prev) => [ + ...prev, + { + id: crypto.randomUUID(), + url, + ...(githubRef.trim() ? { ref: githubRef.trim() } : {}), + ...(include.length > 0 ? { include: [...new Set(include)] } : {}), + }, + ]); setGithubUrl(""); + setGithubRef(""); + setGithubPaths(""); setValidationError(null); - }, [githubUrl, t]); + }, [githubPaths, githubRef, githubUrl, t]); const removeGithubLink = useCallback((id: string) => { setGithubLinks((prev) => prev.filter((l) => l.id !== id)); @@ -441,12 +465,26 @@ export default function DesignSystemSetup() { const normalizedWebsiteUrls = pendingWebsiteUrl ? [...websiteUrls, pendingWebsiteUrl] : websiteUrls; + const pendingGithubInclude = githubPaths + .split(/[\n,]/) + .map((path) => path.trim().replace(/^\/+|\/+$/g, "")) + .filter(Boolean); const normalizedGithubLinks = pendingGithubUrl - ? [...githubLinks, { id: "pending", url: pendingGithubUrl }] + ? [ + ...githubLinks, + { + id: "pending", + url: pendingGithubUrl, + ...(githubRef.trim() ? { ref: githubRef.trim() } : {}), + ...(pendingGithubInclude.length > 0 + ? { include: [...new Set(pendingGithubInclude)] } + : {}), + }, + ] : githubLinks; - const isSingleGithubSource = - normalizedGithubLinks.length === 1 && + const isGithubOnlySource = + normalizedGithubLinks.length > 0 && normalizedWebsiteUrls.length === 0 && codeFiles.length === 0 && !builderIndexResult && @@ -455,10 +493,7 @@ export default function DesignSystemSetup() { assets.length === 0 && !selectedProjectId; - if (isSingleGithubSource) { - const githubRepoUrl = normalizedGithubLinks[0]?.url; - if (!githubRepoUrl) return; - + if (isGithubOnlySource) { setValidationError(null); try { await indexSystemMutation.mutateAsync({ @@ -467,7 +502,11 @@ export default function DesignSystemSetup() { [notes.trim(), customInstructions.trim()] .filter(Boolean) .join("\n\n") || undefined, - githubRepoUrl, + githubSources: normalizedGithubLinks.map((link) => ({ + repoUrl: link.url, + ...(link.ref ? { ref: link.ref } : {}), + ...(link.include?.length ? { include: link.include } : {}), + })), }); toast.success(t("designSystemSetup.githubIndexStarted")); navigate("/design-systems"); @@ -507,7 +546,15 @@ export default function DesignSystemSetup() { if (normalizedGithubLinks.length > 0) { parts.push( - `\n## Connect Code: GitHub Repositories\nStart Builder DSI indexing for each repository with \`index-design-system-with-builder\`:\n${normalizedGithubLinks.map((l) => `- ${l.url}`).join("\n")}\n\nBuilder is the source of truth for repo/code design-system indexing. The action also creates a local selectable proxy design system for Design flows. If Builder is not connected, stop and tell me to connect Builder (free tier available) from Settings instead of asking me to paste repository credentials into chat.`, + `\n## Connect Code: GitHub Repositories\nMake one call to \`index-design-system-with-builder\` with \`githubSources\` set to this JSON array:\n\n\`\`\`json\n${JSON.stringify( + normalizedGithubLinks.map((link) => ({ + repoUrl: link.url, + ...(link.ref ? { ref: link.ref } : {}), + ...(link.include?.length ? { include: link.include } : {}), + })), + null, + 2, + )}\n\`\`\`\n\nBuilder is the source of truth for repo/code design-system indexing. The action creates one local selectable proxy design system for Design flows. If Builder is not connected, stop and tell me to connect Builder (free tier available) from Settings instead of asking me to paste repository credentials into chat.`, ); } @@ -629,6 +676,8 @@ export default function DesignSystemSetup() { websiteUrl, websiteUrls, githubUrl, + githubRef, + githubPaths, githubLinks, codeFiles, builderIndexResult, @@ -935,6 +984,22 @@ export default function DesignSystemSetup() { {t("designSystemSetup.add")}
+
+ setGithubRef(e.target.value)} + placeholder={t("designSystemSetup.githubRef")} + aria-label={t("designSystemSetup.githubRef")} + className="bg-accent/50 border-border" + /> + setGithubPaths(e.target.value)} + placeholder={t("designSystemSetup.githubPaths")} + aria-label={t("designSystemSetup.githubPaths")} + className="bg-accent/50 border-border" + /> +

{t("designSystemSetup.privateRepoPrefix")}{" "} {link.url} + {link.ref || link.include?.length ? ( + + {[link.ref, link.include?.join(", ")] + .filter(Boolean) + .join(" · ")} + + ) : null}