Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/agent-chat-running-event.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@agent-native/core": patch
---

`agentNative.chatRunning` event now reflects both true and false transitions of `isRunning`, allowing UI consumers to track agent work state in real time.
5 changes: 5 additions & 0 deletions .changeset/chat-running-status.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@agent-native/core": patch
---

Broadcast agent chat running state when normal runs start or stop, and switch the agent panel back to chat when submitting a visible prompt.
11 changes: 11 additions & 0 deletions packages/core/src/client/AssistantChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2271,8 +2271,19 @@ const AssistantChatInner = forwardRef<
// UI-only running state — drives the stop button and thinking indicator.
const showRunningInUI = isRunning;
const wasRunningRef = useRef(false);
const lastBroadcastRunningRef = useRef(isRunning);
const tiptapRef = useRef<TiptapComposerHandle>(null);

useEffect(() => {
if (lastBroadcastRunningRef.current === isRunning) return;
lastBroadcastRunningRef.current = isRunning;
window.dispatchEvent(
new CustomEvent("agentNative.chatRunning", {
detail: { isRunning, tabId: tabId || threadId },
}),
);
}, [isRunning, tabId, threadId]);

// ─── Chat persistence ──────────────────────────────────────────────
const hasRestoredRef = useRef(false);
const [isRestoring, setIsRestoring] = useState(!!threadId && !isNewThread);
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/client/agent-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,11 @@ export function sendToAgentChat(opts: AgentChatMessage): string {
// listens for this event; the parent-frame case is handled by whoever
// owns that sidebar receiving the postMessage above.
if (opts.openSidebar !== false && !opts.background) {
window.dispatchEvent(
new CustomEvent("agent-panel:set-mode", {
detail: { mode: "chat" },
}),
);
window.dispatchEvent(new CustomEvent("agent-panel:open"));
}
return tabId;
Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/client/use-agent-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { sendToAgentChat, type AgentChatMessage } from "./agent-chat.js";
*
* Returns [isGenerating, send] where:
* - isGenerating: true after send() is called, false when the
* builder.chatRunning event fires with detail.isRunning === false
* agentNative.chatRunning event reports that the run has stopped
* - send: wrapper around sendToAgentChat that sets isGenerating to true
*/
export function useAgentChatGenerating(): [
Expand All @@ -18,8 +18,8 @@ export function useAgentChatGenerating(): [
useEffect(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent).detail;
if (detail?.isRunning === false) {
setIsGenerating(false);
if (typeof detail?.isRunning === "boolean") {
setIsGenerating(detail.isRunning);
}
};
window.addEventListener("agentNative.chatRunning", handler);
Expand Down
82 changes: 82 additions & 0 deletions templates/design/actions/export-coding-handoff.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { defineAction } from "@agent-native/core";
import { signShortLivedToken } from "@agent-native/core/server";
import { assertAccess } from "@agent-native/core/sharing";
import { eq } from "drizzle-orm";
import { z } from "zod";
import { getDb, schema } from "../server/db/index.js";
import {
buildCodingHandoffPrompt,
buildRawHandoffUrl,
normalizeHandoffFormat,
} from "../server/lib/coding-handoff.js";
import "../server/db/index.js"; // ensure registerShareableResource runs

const HANDOFF_TTL_SECONDS = 7 * 24 * 60 * 60;

export default defineAction({
description:
"Create a coding-tool handoff for a design project. Returns a tokenized raw-code URL " +
"that external agents can fetch, plus a ready-to-copy prompt containing that URL.",
schema: z.object({
id: z.string().describe("Design ID to export for coding tools"),
origin: z
.string()
.optional()
.describe(
"Optional app origin such as https://design.agent-native.com. Used to return an absolute raw-code URL.",
),
format: z
.enum(["markdown", "json"])
.optional()
.default("markdown")
.describe("Raw bundle response format for the generated URL"),
}),
readOnly: true,
run: async ({ id, origin, format }) => {
const access = await assertAccess("design", id, "viewer");
const design = access.resource as typeof schema.designs.$inferSelect;
const db = getDb();

const files = await db
.select({
filename: schema.designFiles.filename,
fileType: schema.designFiles.fileType,
content: schema.designFiles.content,
})
.from(schema.designFiles)
.where(eq(schema.designFiles.designId, id));

if (files.length === 0) {
throw new Error("This design has no files to hand off yet");
}

const token = signShortLivedToken({
resourceId: id,
ttlSeconds: HANDOFF_TTL_SECONDS,
});
const handoffFormat = normalizeHandoffFormat(format);
const rawUrl = buildRawHandoffUrl({
id,
token,
origin,
format: handoffFormat,
});
const prompt = buildCodingHandoffPrompt({
rawUrl,
title: design.title,
fileCount: files.length,
});

return {
designId: id,
rawUrl,
prompt,
clipboardText: prompt,
format: handoffFormat,
fileCount: files.length,
expiresAt: new Date(
Date.now() + HANDOFF_TTL_SECONDS * 1000,
).toISOString(),
};
},
});
216 changes: 216 additions & 0 deletions templates/design/server/lib/coding-handoff.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
export type HandoffFormat = "markdown" | "json";

export interface HandoffDesign {
id: string;
title: string;
description?: string | null;
data?: string | null;
projectType?: string | null;
updatedAt?: string | null;
}

export interface HandoffFile {
filename: string;
fileType?: string | null;
content: string;
}

export interface DesignHandoffPayload {
exportedAt: string;
design: {
id: string;
title: string;
description?: string | null;
projectType?: string | null;
updatedAt?: string | null;
lastPrompt?: string;
data?: Record<string, unknown>;
};
files: Array<{
filename: string;
fileType: string;
content: string;
}>;
}

function appPath(path: string): string {
if (!path.startsWith("/")) return path;
const raw = process.env.VITE_APP_BASE_PATH || process.env.APP_BASE_PATH || "";
const base = raw.trim().replace(/^\/+/, "").replace(/\/+$/, "");
return base ? `/${base}${path}` : path;
}

export function normalizeHandoffOrigin(origin?: string | null): string | null {
if (!origin) return null;
try {
const parsed = new URL(origin);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return null;
}
return parsed.origin;
} catch {
return null;
}
}

export function normalizeHandoffFormat(format?: string | null): HandoffFormat {
return format === "json" ? "json" : "markdown";
}

export function buildRawHandoffUrl({
id,
token,
origin,
format = "markdown",
}: {
id: string;
token: string;
origin?: string | null;
format?: HandoffFormat;
}): string {
const params = new URLSearchParams({
token,
format,
});
const path = appPath(
`/api/design-handoff/${encodeURIComponent(id)}?${params.toString()}`,
);
const normalizedOrigin = normalizeHandoffOrigin(origin);
return normalizedOrigin ? `${normalizedOrigin}${path}` : path;
}

function parseDesignData(data?: string | null): Record<string, unknown> {
if (!data) return {};
try {
const parsed = JSON.parse(data);
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? parsed
: {};
} catch {
return {};
}
}

function sortHandoffFiles(files: HandoffFile[]) {
return [...files].sort((a, b) => {
if (a.filename === "index.html") return -1;
if (b.filename === "index.html") return 1;
return a.filename.localeCompare(b.filename);
});
}

export function buildDesignHandoffPayload({
design,
files,
exportedAt = new Date().toISOString(),
}: {
design: HandoffDesign;
files: HandoffFile[];
exportedAt?: string;
}): DesignHandoffPayload {
const data = parseDesignData(design.data);
const lastPrompt =
typeof data.lastPrompt === "string" ? data.lastPrompt : undefined;

return {
exportedAt,
design: {
id: design.id,
title: design.title,
description: design.description,
projectType: design.projectType,
updatedAt: design.updatedAt,
lastPrompt,
data,
},
files: sortHandoffFiles(files).map((file) => ({
filename: file.filename,
fileType: file.fileType || "html",
content: file.content,
})),
};
}

function languageForFile(filename: string, fileType: string): string {
const lower = filename.toLowerCase();
if (lower.endsWith(".css") || fileType === "css") return "css";
if (lower.endsWith(".json")) return "json";
if (lower.endsWith(".tsx") || lower.endsWith(".jsx") || fileType === "jsx") {
return "jsx";
}
if (lower.endsWith(".ts")) return "ts";
if (lower.endsWith(".js")) return "js";
if (lower.endsWith(".md")) return "md";
return "html";
}

function fenceFor(content: string): string {
let longest = 2;
for (const match of content.matchAll(/`{3,}/g)) {
longest = Math.max(longest, match[0].length);
}
return "`".repeat(longest + 1);
}

export function buildDesignHandoffMarkdown(
payload: DesignHandoffPayload,
): string {
const lines = [
`# Design Handoff: ${payload.design.title}`,
"",
`Design ID: ${payload.design.id}`,
`Exported: ${payload.exportedAt}`,
];

if (payload.design.description) {
lines.push(`Description: ${payload.design.description}`);
}
if (payload.design.projectType) {
lines.push(`Project type: ${payload.design.projectType}`);
}
if (payload.design.updatedAt) {
lines.push(`Last updated: ${payload.design.updatedAt}`);
}
if (payload.design.lastPrompt) {
lines.push("", "## Last Prompt", "", payload.design.lastPrompt);
}

lines.push(
"",
"## Files",
"",
"Use these source files as the visual and interaction reference for the implementation.",
);

for (const file of payload.files) {
const fence = fenceFor(file.content);
lines.push(
"",
`### ${file.filename}`,
"",
`${fence}${languageForFile(file.filename, file.fileType)}`,
file.content,
fence,
);
}

return `${lines.join("\n")}\n`;
}

export function buildCodingHandoffPrompt({
rawUrl,
title,
fileCount,
}: {
rawUrl: string;
title: string;
fileCount: number;
}): string {
return [
`Build this design as production code: ${title}`,
"",
`Fetch the raw design bundle here: ${rawUrl}`,
"",
`The bundle contains ${fileCount} file${fileCount === 1 ? "" : "s"} with the exact HTML/CSS/JSX source from the Design app. Use it as the source of truth for layout, typography, colors, spacing, responsive behavior, copy, and interactions. Convert it into the target project stack or Builder.io page/component while preserving the visual intent. If multiple screens are included, implement the primary page first and map the rest to routes, sections, or components as appropriate.`,
].join("\n");
}
Loading
Loading