diff --git a/.changeset/agent-chat-running-event.md b/.changeset/agent-chat-running-event.md new file mode 100644 index 000000000..6234b9ada --- /dev/null +++ b/.changeset/agent-chat-running-event.md @@ -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. diff --git a/.changeset/chat-running-status.md b/.changeset/chat-running-status.md new file mode 100644 index 000000000..671234aca --- /dev/null +++ b/.changeset/chat-running-status.md @@ -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. diff --git a/packages/core/src/client/AssistantChat.tsx b/packages/core/src/client/AssistantChat.tsx index 0adf54c34..c928b247b 100644 --- a/packages/core/src/client/AssistantChat.tsx +++ b/packages/core/src/client/AssistantChat.tsx @@ -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(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); diff --git a/packages/core/src/client/agent-chat.ts b/packages/core/src/client/agent-chat.ts index 96bf67e2c..a264425d8 100644 --- a/packages/core/src/client/agent-chat.ts +++ b/packages/core/src/client/agent-chat.ts @@ -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; diff --git a/packages/core/src/client/use-agent-chat.ts b/packages/core/src/client/use-agent-chat.ts index 327f0962a..c38484ecb 100644 --- a/packages/core/src/client/use-agent-chat.ts +++ b/packages/core/src/client/use-agent-chat.ts @@ -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(): [ @@ -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); diff --git a/templates/design/actions/export-coding-handoff.ts b/templates/design/actions/export-coding-handoff.ts new file mode 100644 index 000000000..22c6639e0 --- /dev/null +++ b/templates/design/actions/export-coding-handoff.ts @@ -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(), + }; + }, +}); diff --git a/templates/design/server/lib/coding-handoff.ts b/templates/design/server/lib/coding-handoff.ts new file mode 100644 index 000000000..4eefc7b9c --- /dev/null +++ b/templates/design/server/lib/coding-handoff.ts @@ -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; + }; + 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 { + 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"); +} diff --git a/templates/design/server/routes/api/design-handoff/[id].get.ts b/templates/design/server/routes/api/design-handoff/[id].get.ts new file mode 100644 index 000000000..055220837 --- /dev/null +++ b/templates/design/server/routes/api/design-handoff/[id].get.ts @@ -0,0 +1,78 @@ +import { + defineEventHandler, + getQuery, + getRouterParam, + setResponseHeader, + setResponseStatus, + type H3Event, +} from "h3"; +import { eq } from "drizzle-orm"; +import { verifyShortLivedToken } from "@agent-native/core/server"; +import { getDb, schema } from "../../../db/index.js"; +import { + buildDesignHandoffMarkdown, + buildDesignHandoffPayload, + normalizeHandoffFormat, +} from "../../../lib/coding-handoff.js"; + +function notFound(event: H3Event) { + setResponseStatus(event, 404); + return { error: "Not found" }; +} + +export default defineEventHandler(async (event: H3Event) => { + const id = getRouterParam(event, "id"); + if (!id) { + setResponseStatus(event, 400); + return { error: "Design ID is required" }; + } + + const q = getQuery(event) as { + token?: string; + t?: string; + format?: string; + }; + const token = + typeof q.token === "string" ? q.token : typeof q.t === "string" ? q.t : ""; + + const verified = verifyShortLivedToken(token, id); + if (!verified.ok) { + setResponseStatus(event, 403); + return { error: "Invalid or expired handoff link" }; + } + + const db = getDb(); + // guard:allow-unscoped — this unauthenticated endpoint is gated by a signed, expiring handoff token bound to the design id. + const [design] = await db + .select() + .from(schema.designs) + .where(eq(schema.designs.id, id)) + .limit(1); + if (!design) return notFound(event); + + 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) return notFound(event); + + const payload = buildDesignHandoffPayload({ design, files }); + const format = normalizeHandoffFormat(q.format); + + setResponseHeader(event, "Cache-Control", "no-store"); + setResponseHeader(event, "Referrer-Policy", "no-referrer"); + setResponseHeader(event, "X-Content-Type-Options", "nosniff"); + setResponseHeader(event, "Access-Control-Allow-Origin", "*"); + + if (format === "json") { + setResponseHeader(event, "Content-Type", "application/json; charset=utf-8"); + return payload; + } + + setResponseHeader(event, "Content-Type", "text/plain; charset=utf-8"); + return buildDesignHandoffMarkdown(payload); +}); diff --git a/templates/macros/supabase/migrations/20260506_enable_rls.sql b/templates/macros/supabase/migrations/20260506_enable_rls.sql new file mode 100644 index 000000000..0fdfb2b03 --- /dev/null +++ b/templates/macros/supabase/migrations/20260506_enable_rls.sql @@ -0,0 +1,105 @@ +-- ============================================================ +-- Macros — Row Level Security +-- ============================================================ +-- The app connects to Supabase Postgres as the `postgres` superuser via +-- postgres.js (see packages/core/src/db/client.ts). That connection bypasses +-- RLS, so the running app is unaffected by the policies below. The policies +-- exist to lock down the PostgREST surface that Supabase exposes via the +-- public anon key, scoped per authenticated user's email. +-- +-- Why auth.email() and not auth.uid(): meals/exercises/weights are keyed by +-- a `.notNull()` `owner_email` column; the legacy `user_id` column is unused +-- and nullable. See server/db/schema.ts. +-- +-- Idempotent: drops + recreates each policy so re-running is safe. +-- ============================================================ + +-- ---------------------------------------------------------------- +-- meals +-- ---------------------------------------------------------------- +ALTER TABLE public.meals ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS "meals_select_own" ON public.meals; +CREATE POLICY "meals_select_own" + ON public.meals FOR SELECT + TO authenticated + USING (auth.email() = owner_email); + +DROP POLICY IF EXISTS "meals_insert_own" ON public.meals; +CREATE POLICY "meals_insert_own" + ON public.meals FOR INSERT + TO authenticated + WITH CHECK (auth.email() = owner_email); + +DROP POLICY IF EXISTS "meals_update_own" ON public.meals; +CREATE POLICY "meals_update_own" + ON public.meals FOR UPDATE + TO authenticated + USING (auth.email() = owner_email) + WITH CHECK (auth.email() = owner_email); + +DROP POLICY IF EXISTS "meals_delete_own" ON public.meals; +CREATE POLICY "meals_delete_own" + ON public.meals FOR DELETE + TO authenticated + USING (auth.email() = owner_email); + +-- ---------------------------------------------------------------- +-- exercises +-- ---------------------------------------------------------------- +ALTER TABLE public.exercises ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS "exercises_select_own" ON public.exercises; +CREATE POLICY "exercises_select_own" + ON public.exercises FOR SELECT + TO authenticated + USING (auth.email() = owner_email); + +DROP POLICY IF EXISTS "exercises_insert_own" ON public.exercises; +CREATE POLICY "exercises_insert_own" + ON public.exercises FOR INSERT + TO authenticated + WITH CHECK (auth.email() = owner_email); + +DROP POLICY IF EXISTS "exercises_update_own" ON public.exercises; +CREATE POLICY "exercises_update_own" + ON public.exercises FOR UPDATE + TO authenticated + USING (auth.email() = owner_email) + WITH CHECK (auth.email() = owner_email); + +DROP POLICY IF EXISTS "exercises_delete_own" ON public.exercises; +CREATE POLICY "exercises_delete_own" + ON public.exercises FOR DELETE + TO authenticated + USING (auth.email() = owner_email); + +-- ---------------------------------------------------------------- +-- weights +-- ---------------------------------------------------------------- +ALTER TABLE public.weights ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS "weights_select_own" ON public.weights; +CREATE POLICY "weights_select_own" + ON public.weights FOR SELECT + TO authenticated + USING (auth.email() = owner_email); + +DROP POLICY IF EXISTS "weights_insert_own" ON public.weights; +CREATE POLICY "weights_insert_own" + ON public.weights FOR INSERT + TO authenticated + WITH CHECK (auth.email() = owner_email); + +DROP POLICY IF EXISTS "weights_update_own" ON public.weights; +CREATE POLICY "weights_update_own" + ON public.weights FOR UPDATE + TO authenticated + USING (auth.email() = owner_email) + WITH CHECK (auth.email() = owner_email); + +DROP POLICY IF EXISTS "weights_delete_own" ON public.weights; +CREATE POLICY "weights_delete_own" + ON public.weights FOR DELETE + TO authenticated + USING (auth.email() = owner_email); diff --git a/templates/mail/app/hooks/use-emails.ts b/templates/mail/app/hooks/use-emails.ts index a54c10b7e..c875ce3f3 100644 --- a/templates/mail/app/hooks/use-emails.ts +++ b/templates/mail/app/hooks/use-emails.ts @@ -44,6 +44,12 @@ export function fetchThreadMessages(threadId: string): Promise { return apiFetch(`/api/threads/${threadId}/messages`); } +let externalRefreshAt = 0; + +export function markExternalEmailRefresh() { + externalRefreshAt = Date.now(); +} + function parseRecipients(value?: string): EmailMessage["to"] { return (value || "") .split(",") @@ -281,6 +287,9 @@ export function useEmails( if (search) params.set("q", search); if (label) params.set("label", label); if (pageParam) params.set("pageToken", pageParam); + if (externalRefreshAt && Date.now() - externalRefreshAt < 5000) { + params.set("forceRefresh", String(externalRefreshAt)); + } return apiFetch(`/api/emails?${params}`); }, initialPageParam: undefined as string | undefined, diff --git a/templates/mail/app/root.tsx b/templates/mail/app/root.tsx index aae820fc0..ea7fd3781 100644 --- a/templates/mail/app/root.tsx +++ b/templates/mail/app/root.tsx @@ -14,6 +14,7 @@ import { ClientOnly, DefaultSpinner, appPath } from "@agent-native/core/client"; import { getThemeInitScript } from "@agent-native/core/client"; import { appApiPath } from "@/lib/api-path"; import { TAB_ID } from "@/lib/tab-id"; +import { markExternalEmailRefresh } from "@/hooks/use-emails"; import type { LinksFunction } from "react-router"; import stylesheet from "./global.css?url"; import { configureTracking } from "@agent-native/core/client"; @@ -110,18 +111,26 @@ function AutomationTrigger() { return null; } -/** Invalidate email queries when the window regains visibility */ +/** Invalidate email queries when the window regains focus or visibility */ function VisibilityRefresh() { const qc = useQueryClient(); + const lastRefresh = useRef(0); useEffect(() => { - const onVisible = () => { - if (document.visibilityState === "visible") { - qc.invalidateQueries({ queryKey: ["emails"] }); - qc.invalidateQueries({ queryKey: ["labels"] }); - } + const refresh = () => { + if (document.visibilityState !== "visible") return; + const now = Date.now(); + if (now - lastRefresh.current < 1000) return; + lastRefresh.current = now; + markExternalEmailRefresh(); + qc.invalidateQueries({ queryKey: ["emails"] }); + qc.invalidateQueries({ queryKey: ["labels"] }); + }; + document.addEventListener("visibilitychange", refresh); + window.addEventListener("focus", refresh); + return () => { + document.removeEventListener("visibilitychange", refresh); + window.removeEventListener("focus", refresh); }; - document.addEventListener("visibilitychange", onVisible); - return () => document.removeEventListener("visibilitychange", onVisible); }, [qc]); return null; } diff --git a/templates/mail/server/handlers/emails.ts b/templates/mail/server/handlers/emails.ts index bc4e83fdb..76cb908f9 100644 --- a/templates/mail/server/handlers/emails.ts +++ b/templates/mail/server/handlers/emails.ts @@ -41,11 +41,13 @@ import { } from "../lib/google-api.js"; import { isConnected, + invalidateListCacheForOwner, listGmailMessages, gmailToEmailMessage, getAccountDisplayName, setAccountDisplayName, } from "../lib/google-auth.js"; +import { buildGmailEmailSearchQuery } from "../lib/gmail-query.js"; import { incrementSendFrequency, getContactFrequencyMap, @@ -122,12 +124,6 @@ function threadCacheKey(ownerEmail: string, threadId: string) { return `${ownerEmail}:${threadId}`; } -function gmailLabelSearchClause(label: string): string { - const value = label.trim().replace(/\s+/g, "-").replace(/"/g, '\\"'); - if (!value) return ""; - return /[/"()]/.test(value) ? `label:"${value}"` : `label:${value}`; -} - export function invalidateThreadCache(ownerEmail: string, threadId: string) { threadMessagesCache.delete(threadCacheKey(ownerEmail, threadId)); } @@ -374,10 +370,12 @@ export const listEmails = defineEventHandler(async (event: H3Event) => { view = "inbox", q, label, + forceRefresh, } = getQuery(event) as { view?: string; q?: string; label?: string; + forceRefresh?: string; }; if (view === "snoozed" || view === "scheduled") { @@ -399,6 +397,8 @@ export const listEmails = defineEventHandler(async (event: H3Event) => { // If Google is connected, fetch from Gmail directly (skip demo data) if (await isConnected(email)) { try { + if (forceRefresh) invalidateListCacheForOwner(email); + const { pageToken } = getQuery(event) as { pageToken?: string }; // Decode composite page tokens (one per Gmail account) let pageTokens: Record | undefined; @@ -412,70 +412,7 @@ export const listEmails = defineEventHandler(async (event: H3Event) => { } } - // Map view to Gmail search query. - // `-in:sent` excludes user-sent messages (replies the user wrote get - // both INBOX and SENT labels in Gmail) so they don't pollute the inbox. - const gmailQuery: Record = { - inbox: "in:inbox -in:sent", - unread: "is:unread in:inbox -in:sent", - starred: "is:starred", - sent: "in:sent", - drafts: "in:drafts", - archive: "-in:inbox -in:sent -in:drafts -in:trash", - trash: "in:trash", - all: "", - }; - let searchQuery: string; - if (q) { - // Gmail's plain search already matches subject, body, sender name, - // sender address, recipient, and Cc. A prior `{from:(q) to:(q) cc:(q) q}` - // wrapper silently dropped results (e.g. the user's own "SPMB Status - // Update 4/17" thread) — Gmail parses the braces in surprising ways - // when mixed with field qualifiers and bare terms. - searchQuery = q; - } else { - searchQuery = gmailQuery[view] ?? `label:${view}`; - } - // If a specific label filter is active (e.g. a pinned label tab), scope - // the Gmail query server-side. gmailToEmailMessage normalizes Gmail's - // CATEGORY_* labels into friendly IDs like "updates"/"promotions" and - // synthesizes virtual IDs like "note-to-self" (self-sent mail). Translate - // those back into the right Gmail search operator — plain `label:updates` - // doesn't match the Updates category. - if (label) { - let labelClause = ""; - const id = label.toLowerCase(); - const categoryIds = new Set([ - "personal", - "social", - "updates", - "promotions", - "forums", - ]); - if (categoryIds.has(id)) { - // Gmail normalizes CATEGORY_PERSONAL → "personal" on the client, but - // its search operator for the Primary tab is `category:primary`. - labelClause = `category:${id === "personal" ? "primary" : id}`; - } else if (id === "important") { - labelClause = "is:important"; - } else if (id === "note-to-self") { - // "Note to self" is synthesized client-side for self-sent mail that - // still landed in the inbox. Gmail has no such label — match by - // sender (each account's `from:me` resolves per-account). - labelClause = "from:me"; - } else { - // User-created label: Gmail's `label:` operator wants the display - // name with spaces replaced by hyphens; nested labels use `/`. - labelClause = gmailLabelSearchClause(label); - } - // Gmail-like label views are not limited to Inbox: archived mail with - // that label should still appear. Category/Important filters remain - // valid without an explicit `in:inbox` clause. - if (!q) searchQuery = ""; - searchQuery = searchQuery - ? `${searchQuery} ${labelClause}` - : labelClause; - } + const searchQuery = buildGmailEmailSearchQuery({ view, q, label }); // Fetch label name mapping from all accounts (cached) const accountTokens = await getAccountTokens(email); diff --git a/templates/mail/server/lib/gmail-query.spec.ts b/templates/mail/server/lib/gmail-query.spec.ts new file mode 100644 index 000000000..a02943691 --- /dev/null +++ b/templates/mail/server/lib/gmail-query.spec.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { + buildGmailEmailSearchQuery, + gmailLabelSearchClause, +} from "./gmail-query.js"; + +describe("buildGmailEmailSearchQuery", () => { + it("scopes inbox searches to inbox results", () => { + expect(buildGmailEmailSearchQuery({ view: "inbox", q: "receipt" })).toBe( + "in:inbox -in:sent receipt", + ); + }); + + it("keeps all-mail searches unscoped", () => { + expect(buildGmailEmailSearchQuery({ view: "all", q: "receipt" })).toBe( + "receipt", + ); + }); + + it("scopes archive searches to archived mail", () => { + expect(buildGmailEmailSearchQuery({ view: "archive", q: "receipt" })).toBe( + "-in:inbox -in:sent -in:drafts -in:trash receipt", + ); + }); + + it("keeps label tabs independent from inbox/archive views", () => { + expect( + buildGmailEmailSearchQuery({ + view: "inbox", + label: "customer success", + q: "renewal", + }), + ).toBe("renewal label:customer-success"); + }); + + it("translates app category labels to Gmail search operators", () => { + expect( + buildGmailEmailSearchQuery({ view: "inbox", label: "updates" }), + ).toBe("category:updates"); + expect( + buildGmailEmailSearchQuery({ view: "inbox", label: "personal" }), + ).toBe("category:primary"); + }); +}); + +describe("gmailLabelSearchClause", () => { + it("quotes Gmail labels that need quoting", () => { + expect(gmailLabelSearchClause("Team/Foo Bar")).toBe('label:"Team/Foo-Bar"'); + }); +}); diff --git a/templates/mail/server/lib/gmail-query.ts b/templates/mail/server/lib/gmail-query.ts new file mode 100644 index 000000000..89a369cad --- /dev/null +++ b/templates/mail/server/lib/gmail-query.ts @@ -0,0 +1,53 @@ +export const VIEW_QUERIES: Record = { + inbox: "in:inbox -in:sent", + unread: "is:unread in:inbox -in:sent", + starred: "is:starred", + sent: "in:sent", + drafts: "in:drafts", + archive: "-in:inbox -in:sent -in:drafts -in:trash", + trash: "in:trash", + all: "", +}; + +export function gmailLabelSearchClause(label: string): string { + const value = label.trim().replace(/\s+/g, "-").replace(/"/g, '\\"'); + if (!value) return ""; + return /[/"()]/.test(value) ? `label:"${value}"` : `label:${value}`; +} + +export function gmailAppLabelSearchClause(label: string): string { + const id = label.toLowerCase(); + const categoryIds = new Set([ + "personal", + "social", + "updates", + "promotions", + "forums", + ]); + if (categoryIds.has(id)) { + return `category:${id === "personal" ? "primary" : id}`; + } + if (id === "important") return "is:important"; + if (id === "note-to-self") return "from:me"; + return gmailLabelSearchClause(label); +} + +export function buildGmailEmailSearchQuery({ + view = "inbox", + q, + label, +}: { + view?: string; + q?: string; + label?: string; +}): string { + const trimmedQuery = q?.trim(); + + if (label) { + const labelClause = gmailAppLabelSearchClause(label); + return [trimmedQuery, labelClause].filter(Boolean).join(" "); + } + + const viewQuery = VIEW_QUERIES[view] ?? `label:${view}`; + return [viewQuery, trimmedQuery].filter(Boolean).join(" "); +} diff --git a/templates/slides/AGENTS.md b/templates/slides/AGENTS.md index 4d0efd819..85d7a2b81 100644 --- a/templates/slides/AGENTS.md +++ b/templates/slides/AGENTS.md @@ -208,6 +208,8 @@ If your cwd is the monorepo root instead (e.g., running from the Frame wrapper), `.env` is loaded automatically — **never manually set `DATABASE_URL` or other env vars**. +In the built-in agent chat, use the framework `manage-progress` tool for long-running generation work. Start it before multi-slide deck generation or design-system extraction, update the current step after each visible batch, and complete it when the user can see the finished result. + ### Reading & Searching | Action | Args | Purpose | @@ -236,12 +238,13 @@ If a metric or source would make the slide stronger but is not available, use qu 1. If a deck is already open (check `` for `deckId`), skip to step 3. 2. Otherwise, create an empty deck: `create-deck --title "X" --slides '[]'`, then `navigate --deckId=`. -3. Call `add-slide --deckId= --content=""` once per slide. **Fire multiple `add-slide` calls in parallel in the same turn** — they run concurrently and the user sees each slide appear live. +3. For decks larger than a few slides, start a `manage-progress` run so the header runs tray shows visible progress outside the chat pane. Update it after each batch and complete it when the requested slide count is reached. +4. Call `add-slide --deckId= --content=""` once per slide. Add slide 1 as soon as it is ready, then continue in small visible batches. For requests above 6 slides, use at most 4 `add-slide` calls per model turn, pass `position` values so slide order stays stable, and continue after tool results until the requested slide count is reached. Do not spend a long turn designing the whole deck before slide 1 lands. **Why add-slide is preferred over create-deck with all slides:** - The user sees slides stream in one-by-one (create-deck drops them all at once). -- Parallel tool calls mean all slides generate concurrently. +- Small batched tool calls keep the editor visibly filling in without making the user wait for the whole deck. - If one slide fails, the others still land. **Other operations:** @@ -249,15 +252,15 @@ If a metric or source would make the slide stronger but is not available, use qu - **Replace one slide's content:** `update-slide --find/--replace` (surgical, syncs live via Yjs) or `--fullContent`. - **Bulk replace (rare):** `create-deck --deckId ` to atomically replace ALL slides in one deck. -| Action | Args | Purpose | -| -------------------------- | ---------------------------------------------------------------- | --------------------------------------------------------------------- | -| `add-slide` | `--deckId --content "" [--layout ...] [--position N]` | **PREFERRED** — add one slide to an existing deck; parallel-safe | -| `create-deck` | `--title "X" --slides '[]' [--aspectRatio 16:9\|1:1\|9:16\|4:5]` | Create a new empty deck (optionally set the aspect ratio) | -| `create-deck` | `--title "X" --slides '[...]'` | Create a new deck with all slides (bulk, rarely preferred) | -| `create-deck` | `--title "X" --slides '[...]' --deckId ` | Replace all slides in an existing deck (atomic bulk replace) | -| `update-slide` | `--deckId --slideId --find "old" --replace "new"` | Surgical text edit — syncs live to editors | -| `update-slide` | `--deckId --slideId --fullContent ""` | Full slide content replacement | -| `update-deck-aspect-ratio` | `--deckId --aspectRatio 16:9\|1:1\|9:16\|4:5` | Set the deck's aspect ratio (affects editor, presentation, PDF, PPTX) | +| Action | Args | Purpose | +| -------------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | +| `add-slide` | `--deckId --content "" [--layout ...] [--position N]` | **PREFERRED** — add one slide to an existing deck; parallel-safe | +| `create-deck` | `--title "X" --slides '[]' [--aspectRatio 16:9\|1:1\|9:16\|4:5] [--designSystemId ]` | Create a new empty deck (optionally set aspect ratio / design system) | +| `create-deck` | `--title "X" --slides '[...]'` | Create a new deck with all slides (bulk, rarely preferred) | +| `create-deck` | `--title "X" --slides '[...]' --deckId [--designSystemId ]` | Replace all slides in an existing deck (atomic bulk replace) | +| `update-slide` | `--deckId --slideId --find "old" --replace "new"` | Surgical text edit — syncs live to editors | +| `update-slide` | `--deckId --slideId --fullContent ""` | Full slide content replacement | +| `update-deck-aspect-ratio` | `--deckId --aspectRatio 16:9\|1:1\|9:16\|4:5` | Set the deck's aspect ratio (affects editor, presentation, PDF, PPTX) | ### Navigation diff --git a/templates/slides/actions/add-slide.ts b/templates/slides/actions/add-slide.ts index d63ea5031..0a9f4e997 100644 --- a/templates/slides/actions/add-slide.ts +++ b/templates/slides/actions/add-slide.ts @@ -27,6 +27,7 @@ export default defineAction({ description: "Add a single slide to an existing deck. Use this to build decks slide-by-slide — " + "you can call this in parallel for multiple slides at once to generate an entire deck concurrently. " + + "For larger decks, use small visible batches of at most 4 add-slide calls per model turn. " + "Returns the new slide ID and updated slide count.", schema: z.object({ deckId: z.string().describe("Target deck ID"), diff --git a/templates/slides/actions/apply-design-system.ts b/templates/slides/actions/apply-design-system.ts index 12886a007..469acb342 100644 --- a/templates/slides/actions/apply-design-system.ts +++ b/templates/slides/actions/apply-design-system.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { eq } from "drizzle-orm"; import { getDb, schema } from "../server/db/index.js"; import { assertAccess } from "@agent-native/core/sharing"; +import { notifyClients } from "../server/handlers/decks.js"; export default defineAction({ description: @@ -25,6 +26,8 @@ export default defineAction({ .set({ designSystemId, updatedAt: now }) .where(eq(schema.decks.id, deckId)); + notifyClients(deckId); + return { deckId, designSystemId, applied: true }; }, }); diff --git a/templates/slides/actions/create-deck.ts b/templates/slides/actions/create-deck.ts index 6330b3d71..6c566ddea 100644 --- a/templates/slides/actions/create-deck.ts +++ b/templates/slides/actions/create-deck.ts @@ -39,8 +39,10 @@ const SlidesSchema = z.preprocess( export default defineAction({ description: - "Create a new deck with slides, or replace all slides in an existing deck. " + - "Pass deckId to populate an existing deck (e.g. one the user already has open). " + + "Create an empty deck, or atomically replace all slides in an existing deck. " + + "For AI-generated decks, create the deck with slides: [] and then use add-slide so progress appears live. " + + "Use non-empty slides here only for imports or intentional bulk replacement. " + + "Pass deckId to replace an existing deck. " + "Returns the deck id, title, and slide count.", schema: z.object({ title: z.string().describe("Deck title"), @@ -59,11 +61,18 @@ export default defineAction({ .describe( "Slide aspect ratio for the deck (defaults to 16:9 when omitted)", ), + designSystemId: z + .string() + .optional() + .describe("Optional design system ID to link to the deck"), }), http: false, - run: async ({ title, slides, deckId, aspectRatio }) => { + run: async ({ title, slides, deckId, aspectRatio, designSystemId }) => { const db = getDb(); const now = new Date().toISOString(); + if (designSystemId) { + await assertAccess("design-system", designSystemId, "viewer"); + } if (deckId) { // Update existing deck — requires editor access. @@ -79,10 +88,16 @@ export default defineAction({ slides, updatedAt: now, aspectRatio: aspectRatio ?? prevData.aspectRatio, + designSystemId: designSystemId ?? prevData.designSystemId, }; await db .update(schema.decks) - .set({ title, data: JSON.stringify(data), updatedAt: now }) + .set({ + title, + data: JSON.stringify(data), + designSystemId: designSystemId ?? existing[0]?.designSystemId ?? null, + updatedAt: now, + }) .where(eq(schema.decks.id, deckId)); // Broadcast to open editors (in-process SSE) + application-state // refresh signal (cross-process polling fallback for serverless). @@ -104,10 +119,12 @@ export default defineAction({ updatedAt: now, }; if (aspectRatio) data.aspectRatio = aspectRatio; + if (designSystemId) data.designSystemId = designSystemId; await db.insert(schema.decks).values({ id, title, data: JSON.stringify(data), + designSystemId: designSystemId ?? null, ownerEmail: (() => { const e = getRequestUserEmail(); if (!e) throw new Error("no authenticated user"); diff --git a/templates/slides/actions/duplicate-deck.ts b/templates/slides/actions/duplicate-deck.ts index 5061a1a02..4a8ca4f02 100644 --- a/templates/slides/actions/duplicate-deck.ts +++ b/templates/slides/actions/duplicate-deck.ts @@ -45,6 +45,7 @@ export default defineAction({ deckData.title = newTitle; deckData.createdAt = now; deckData.updatedAt = now; + deckData.designSystemId = source.designSystemId ?? deckData.designSystemId; await db.insert(schema.decks).values({ id: newId, diff --git a/templates/slides/actions/generate-slides-ai.ts b/templates/slides/actions/generate-slides-ai.ts index 9a7edde56..e0f85189f 100644 --- a/templates/slides/actions/generate-slides-ai.ts +++ b/templates/slides/actions/generate-slides-ai.ts @@ -3,7 +3,8 @@ import type { GeneratedSlide } from "@shared/api"; import { z } from "zod"; export default defineAction({ - description: "Generate slide deck content using Gemini AI.", + description: + "Legacy helper for the Generate Slides dialog. It returns markdown slide drafts, not the app's rendered slide HTML. Agent chat should create decks with create-deck slides: [] plus add-slide HTML instead of this action.", schema: z.object({ topic: z.string().describe("Presentation topic"), slideCount: z.coerce diff --git a/templates/slides/app/components/deck/DeckCard.tsx b/templates/slides/app/components/deck/DeckCard.tsx index 2c94daa04..da14cfae7 100644 --- a/templates/slides/app/components/deck/DeckCard.tsx +++ b/templates/slides/app/components/deck/DeckCard.tsx @@ -1,5 +1,11 @@ import { Link } from "react-router"; -import { IconDots, IconTrash, IconCopy, IconPencil } from "@tabler/icons-react"; +import { + IconDots, + IconTrash, + IconCopy, + IconPencil, + IconPalette, +} from "@tabler/icons-react"; import { useState, useRef, useEffect } from "react"; import type { Deck } from "@/context/DeckContext"; import SlideRenderer from "./SlideRenderer"; @@ -18,6 +24,7 @@ interface DeckCardProps { onRename: (id: string, newTitle: string) => void; onDuplicate: (id: string) => void; isDuplicating?: boolean; + designSystemTitle?: string | null; } export default function DeckCard({ @@ -26,6 +33,7 @@ export default function DeckCard({ onRename, onDuplicate, isDuplicating = false, + designSystemTitle, }: DeckCardProps) { const firstSlide = deck.slides?.[0]; const [isRenaming, setIsRenaming] = useState(false); @@ -97,8 +105,18 @@ export default function DeckCard({ )} -
- {deck.slides.length} slide{deck.slides.length !== 1 ? "s" : ""} +
+ + {deck.slides.length} slide{deck.slides.length !== 1 ? "s" : ""} + + {deck.designSystemId && ( + + + + {designSystemTitle || "Design system"} + + + )}
diff --git a/templates/slides/app/components/design-system/DesignSystemSetup.tsx b/templates/slides/app/components/design-system/DesignSystemSetup.tsx index 11c8f64ad..b3fb2eb5b 100644 --- a/templates/slides/app/components/design-system/DesignSystemSetup.tsx +++ b/templates/slides/app/components/design-system/DesignSystemSetup.tsx @@ -311,6 +311,11 @@ export function DesignSystemSetup({ openAgentSidebar(); sendToAgentChat({ message: parts.join("\n"), submit: true }); + toast({ + title: "Design system generation started", + description: + "You can keep working while the agent processes the sources.", + }); onComplete(); }, [ editingId, diff --git a/templates/slides/app/components/editor/EditorSidebar.tsx b/templates/slides/app/components/editor/EditorSidebar.tsx index 72b3cd92b..037ea77ac 100644 --- a/templates/slides/app/components/editor/EditorSidebar.tsx +++ b/templates/slides/app/components/editor/EditorSidebar.tsx @@ -351,7 +351,13 @@ function AddSlidePopover({ : "", fileContext, "", - "Create the slide content and insert it at the correct position using the app's slide data structure.", + "Create the slide content and insert it at the correct position using `add-slide` with --deckId=" + + deckId + + ".", + "If the user asked for multiple slides, call `add-slide` once per slide. Use positions starting at " + + (activeSlideIndex + 1) + + " so the new slides land after the active slide in order.", + "For larger requests, use visible batches: add at most 4 slides in one model turn, then continue with the next batch after the tool results. Start the first batch immediately; do not wait to design the entire sequence before adding slide 1.", ].join("\n"); agentSubmit( diff --git a/templates/slides/app/components/editor/EditorToolbar.tsx b/templates/slides/app/components/editor/EditorToolbar.tsx index c7a54720a..5e463784b 100644 --- a/templates/slides/app/components/editor/EditorToolbar.tsx +++ b/templates/slides/app/components/editor/EditorToolbar.tsx @@ -26,6 +26,7 @@ import { IconSun, IconMoon, IconDots, + IconPalette, } from "@tabler/icons-react"; import type { Deck, Slide, SlideLayout } from "@/context/DeckContext"; import { useSaveState } from "@/context/DeckContext"; @@ -45,6 +46,7 @@ import { PresenceBar, type CollabUser, } from "@agent-native/core/client"; +import { RunsTray } from "@agent-native/core/client/progress"; import { Tooltip, TooltipContent, @@ -119,6 +121,8 @@ interface EditorToolbarProps { aspectRatio?: AspectRatio; /** Change the deck's aspect ratio */ onSetAspectRatio?: (ratio: AspectRatio) => void; + /** Title of the design system linked to this deck, if any */ + designSystemTitle?: string | null; } const slideLayoutOptions: { value: SlideLayout; label: string }[] = [ @@ -238,6 +242,7 @@ export default function EditorToolbar({ onExportPdf, aspectRatio, onSetAspectRatio, + designSystemTitle, }: EditorToolbarProps) { const activeAspectRatio: AspectRatio = aspectRatio ?? DEFAULT_ASPECT_RATIO; const saveState = useSaveState(); @@ -332,6 +337,24 @@ export default function EditorToolbar({ {currentSlideIndex + 1}/{slideCount} + {deck.designSystemId && ( + + +
+ + + {designSystemTitle || "Design system"} + +
+
+ + {designSystemTitle + ? `Using ${designSystemTitle}` + : "Using a linked design system"} + +
+ )} + {/* Spacer */}
@@ -816,6 +839,7 @@ graph TD resourceTitle={deckTitle} />
+ {/* Present button — matches Share trigger height (h-9) */} -
+
-

- Generating deck... -

+
+

+ Preparing the first slides +

+

+ The deck will start filling in here as soon as each slide lands. +

+
); diff --git a/templates/slides/app/components/editor/PromptDialog.tsx b/templates/slides/app/components/editor/PromptDialog.tsx index 3d6fd5ff9..b9c26e749 100644 --- a/templates/slides/app/components/editor/PromptDialog.tsx +++ b/templates/slides/app/components/editor/PromptDialog.tsx @@ -24,6 +24,7 @@ interface PromptPopoverProps { centered?: boolean; /** Forwarded to PromptComposer/TipTap for draft persistence in localStorage. */ draftScope?: string; + children?: React.ReactNode; } export default function PromptPopover({ @@ -38,6 +39,7 @@ export default function PromptPopover({ anchorRef, centered = false, draftScope, + children, }: PromptPopoverProps) { const [uploading, setUploading] = useState(false); const [promptText, setPromptText] = useState(""); @@ -174,6 +176,8 @@ export default function PromptPopover({ /> + {children} + { + const handler = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (typeof detail?.isRunning === "boolean") { + setRunning(detail.isRunning); + } + }; + window.addEventListener("agentNative.chatRunning", handler); + return () => window.removeEventListener("agentNative.chatRunning", handler); + }, []); + + if (!running) return null; + + return ( +
+
+
+ + Agent is working +
+ +
+
+ ); +} diff --git a/templates/slides/app/components/layout/Header.tsx b/templates/slides/app/components/layout/Header.tsx index 09cbe2fc3..dd9bdfc3b 100644 --- a/templates/slides/app/components/layout/Header.tsx +++ b/templates/slides/app/components/layout/Header.tsx @@ -2,6 +2,7 @@ import { useLocation, useParams } from "react-router"; import { useDecks } from "@/context/DeckContext"; import { useHeaderTitle, useHeaderActions } from "./HeaderActions"; import { AgentToggleButton } from "@agent-native/core/client"; +import { RunsTray } from "@agent-native/core/client/progress"; const pageTitles: Record = { "/": "Decks", @@ -56,6 +57,7 @@ export function Header() {
{actions} +
diff --git a/templates/slides/app/components/layout/Layout.tsx b/templates/slides/app/components/layout/Layout.tsx index 019b0d36f..1c8c519d3 100644 --- a/templates/slides/app/components/layout/Layout.tsx +++ b/templates/slides/app/components/layout/Layout.tsx @@ -8,6 +8,7 @@ import { InvitationBanner } from "@agent-native/core/client/org"; import { IconMenu2 } from "@tabler/icons-react"; import { cn } from "@/lib/utils"; import { useSidebarCollapsed } from "@/hooks/use-sidebar-collapsed"; +import { AgentWorkIndicator } from "./AgentWorkIndicator"; interface LayoutProps { children: React.ReactNode; @@ -101,6 +102,7 @@ export function Layout({ children }: LayoutProps) { {children} + diff --git a/templates/slides/app/context/DeckContext.tsx b/templates/slides/app/context/DeckContext.tsx index 389a47925..f8cdebcad 100644 --- a/templates/slides/app/context/DeckContext.tsx +++ b/templates/slides/app/context/DeckContext.tsx @@ -80,7 +80,10 @@ export interface HistoryEntry { interface DeckContextType { decks: Deck[]; loading: boolean; - createDeck: (title?: string, options?: { noDefaultSlides?: boolean }) => Deck; + createDeck: ( + title?: string, + options?: { noDefaultSlides?: boolean; designSystemId?: string | null }, + ) => Deck; /** * Optimistically duplicate a deck. Inserts a copy into local state with the * supplied `newId` immediately so the UI can navigate without awaiting the @@ -517,12 +520,16 @@ export function DeckProvider({ children }: { children: ReactNode }) { }, [undo, redo]); const createDeck = useCallback( - (title?: string, options?: { noDefaultSlides?: boolean }): Deck => { + ( + title?: string, + options?: { noDefaultSlides?: boolean; designSystemId?: string | null }, + ): Deck => { const newDeck: Deck = { id: nanoid(10), title: title || "Untitled Deck", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + designSystemId: options?.designSystemId ?? undefined, slides: options?.noDefaultSlides ? [] : [ diff --git a/templates/slides/app/hooks/use-deck-design-system.ts b/templates/slides/app/hooks/use-deck-design-system.ts index edd936866..6154e845c 100644 --- a/templates/slides/app/hooks/use-deck-design-system.ts +++ b/templates/slides/app/hooks/use-deck-design-system.ts @@ -34,14 +34,26 @@ export function useDeckDesignSystem(designSystemId?: string | null) { }); if (!designSystemId || !data?.data) { - return { designSystem: DEFAULT_DESIGN_SYSTEM, isLoading: false }; + return { + designSystem: DEFAULT_DESIGN_SYSTEM, + designSystemTitle: null, + isLoading: false, + }; } try { const parsed = JSON.parse(data.data) as DesignSystemData; - return { designSystem: parsed, isLoading }; + return { + designSystem: parsed, + designSystemTitle: data.title ?? null, + isLoading, + }; } catch { - return { designSystem: DEFAULT_DESIGN_SYSTEM, isLoading }; + return { + designSystem: DEFAULT_DESIGN_SYSTEM, + designSystemTitle: data.title ?? null, + isLoading, + }; } } diff --git a/templates/slides/app/lib/generation-state.test.ts b/templates/slides/app/lib/generation-state.test.ts new file mode 100644 index 000000000..85faf6018 --- /dev/null +++ b/templates/slides/app/lib/generation-state.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { + shouldClearNewDeckGeneratingState, + shouldShowNewDeckGeneratingOverlay, +} from "./generation-state"; + +describe("new deck generation state", () => { + it("shows the blocking overlay only while a fresh deck has no slides", () => { + expect( + shouldShowNewDeckGeneratingOverlay({ + generating: true, + isNewDeckCreation: true, + slideCount: 0, + }), + ).toBe(true); + + expect( + shouldShowNewDeckGeneratingOverlay({ + generating: true, + isNewDeckCreation: true, + slideCount: 1, + }), + ).toBe(false); + + expect( + shouldShowNewDeckGeneratingOverlay({ + generating: false, + isNewDeckCreation: true, + slideCount: 0, + }), + ).toBe(false); + }); + + it("clears new-deck generating state when work finishes or a slide lands", () => { + expect( + shouldClearNewDeckGeneratingState({ + generating: true, + slideCount: 0, + }), + ).toBe(false); + + expect( + shouldClearNewDeckGeneratingState({ + generating: true, + slideCount: 1, + }), + ).toBe(true); + + expect( + shouldClearNewDeckGeneratingState({ + generating: false, + slideCount: 0, + }), + ).toBe(true); + }); +}); diff --git a/templates/slides/app/lib/generation-state.ts b/templates/slides/app/lib/generation-state.ts new file mode 100644 index 000000000..802e209e1 --- /dev/null +++ b/templates/slides/app/lib/generation-state.ts @@ -0,0 +1,21 @@ +export function shouldShowNewDeckGeneratingOverlay({ + generating, + isNewDeckCreation, + slideCount, +}: { + generating: boolean; + isNewDeckCreation: boolean; + slideCount?: number | null; +}): boolean { + return generating && isNewDeckCreation && (slideCount ?? 0) === 0; +} + +export function shouldClearNewDeckGeneratingState({ + generating, + slideCount, +}: { + generating: boolean; + slideCount?: number | null; +}): boolean { + return !generating || (slideCount ?? 0) > 0; +} diff --git a/templates/slides/app/pages/DeckEditor.tsx b/templates/slides/app/pages/DeckEditor.tsx index ca4b4a186..77576a397 100644 --- a/templates/slides/app/pages/DeckEditor.tsx +++ b/templates/slides/app/pages/DeckEditor.tsx @@ -54,6 +54,10 @@ import { useDeckDesignSystem } from "@/hooks/use-deck-design-system"; import { TweaksPanel } from "@/components/editor/TweaksPanel"; import { getPreset } from "@/lib/design-systems"; import { exportDeckAsPdf } from "@/lib/export-pdf-client"; +import { + shouldClearNewDeckGeneratingState, + shouldShowNewDeckGeneratingOverlay, +} from "@/lib/generation-state"; import { toast } from "@/hooks/use-toast"; import { ToastAction } from "@/components/ui/toast"; import { nanoid } from "nanoid"; @@ -87,9 +91,9 @@ export default function DeckEditor() { } = useDecks(); const [activeSlideId, setActiveSlideId] = useState(null); const { generating } = useAgentGenerating(); - // Track new-deck-creation intent: set once on mount if ?generating=1, cleared when done + // Track new-deck-creation intent: set once on mount if ?generating=1. + // The editor reveals partial slides as soon as the first one lands. const wasNewDeckCreation = useRef(searchParams.get("generating") === "1"); - const isNewDeckGenerating = generating && wasNewDeckCreation.current; const [activeTab, setActiveTab] = useState<"visual" | "code">("visual"); const [sidebarOpen, setSidebarOpen] = useState( () => typeof window !== "undefined" && window.innerWidth >= 768, @@ -120,7 +124,15 @@ export default function DeckEditor() { const uploadInputRef = useRef(null); const deck = getDeck(id || ""); - const { designSystem } = useDeckDesignSystem(deck?.designSystemId); + const slideCount = deck?.slides.length ?? 0; + const isNewDeckGenerating = shouldShowNewDeckGeneratingOverlay({ + generating, + isNewDeckCreation: wasNewDeckCreation.current, + slideCount, + }); + const { designSystem, designSystemTitle } = useDeckDesignSystem( + deck?.designSystemId, + ); // Poll for question flow from agent (show-questions application state) const { data: questionFlowData } = useQuery<{ @@ -164,9 +176,9 @@ export default function DeckEditor() { "Answers:", formatted, "", - "Now generate the slides based on these preferences. Use add-slide with --deckId=" + + "Now generate the slides based on these preferences. Start a manage-progress run, add the first slide as soon as it is ready, then continue in small batches so the editor visibly fills in. Use add-slide with --deckId=" + id + - " to add slides one at a time in parallel.", + " to add slides one at a time. Use positions when batching so slide order stays stable.", ].join("\n"); sendToAgentChat({ @@ -191,7 +203,7 @@ export default function DeckEditor() { sendToAgentChat({ message: "Skip the questions — just go ahead and create the slides with your best judgment.", - context: `The user skipped the pre-generation questions for deck ${id}. Proceed with reasonable defaults and generate slides using add-slide with --deckId=${id}.`, + context: `The user skipped the pre-generation questions for deck ${id}. Proceed with reasonable defaults. Start a manage-progress run, add the first slide as soon as it is ready, then continue in small batches using add-slide with --deckId=${id}. Use positions when batching so slide order stays stable.`, submit: true, }); @@ -202,29 +214,24 @@ export default function DeckEditor() { }).catch(() => {}); }, [id, queryClient]); - // If deck already has slides on mount, it's not a fresh new-deck creation + // Clean up the generating URL param/ref when generation completes or when + // the first slide lands, so partial progress is visible during long decks. useEffect(() => { - if (deck && deck.slides.length > 0 && wasNewDeckCreation.current) { - wasNewDeckCreation.current = false; + if (!shouldClearNewDeckGeneratingState({ generating, slideCount })) { + return; } - }, []); // only on mount - - // Clean up the generating URL param and ref when generation completes - useEffect(() => { - if (!generating) { - wasNewDeckCreation.current = false; - if (searchParams.get("generating")) { - setSearchParams( - (prev) => { - const next = new URLSearchParams(prev); - next.delete("generating"); - return next; - }, - { replace: true }, - ); - } + wasNewDeckCreation.current = false; + if (searchParams.get("generating")) { + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + next.delete("generating"); + return next; + }, + { replace: true }, + ); } - }, [generating, searchParams, setSearchParams]); + }, [generating, searchParams, setSearchParams, slideCount]); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), @@ -600,6 +607,7 @@ export default function DeckEditor() { } }} aspectRatio={deck.aspectRatio} + designSystemTitle={designSystemTitle} onSetAspectRatio={(ratio: AspectRatio) => { const previous = deck.aspectRatio; // Optimistic UI: update local cache immediately so canvas resizes. @@ -656,7 +664,19 @@ export default function DeckEditor() { )} - {isNewDeckGenerating && } + {isNewDeckGenerating && deck.slides.length === 0 && ( + + )} + + {isNewDeckGenerating && deck.slides.length > 0 && ( +
+ Building deck + + {deck.slides.length} slide{deck.slides.length === 1 ? "" : "s"}{" "} + added + +
+ )} {showQuestionFlow && !isNewDeckGenerating && ( )} - {!isNewDeckGenerating && !showQuestionFlow && currentSlide && ( - - updateSlide(id, currentSlide.id, updates) - } - activeTab={activeTab} - onGenerateImage={() => setImageGenOpen(true)} - onOpenAssetLibrary={(src) => { - setReplaceImageSrc(src); - setAssetLibraryOpen(true); - }} - onUploadImage={(src) => { - setReplaceImageSrc(src); - uploadInputRef.current?.click(); - }} - onSearchImage={(src) => { - setReplaceImageSrc(src); - setImageSearchOpen(true); - }} - onLogoSearch={(src) => { - setReplaceImageSrc(src); - setLogoSearchOpen(true); - }} - onToggleObjectFit={toggleObjectFit} - slideIndex={currentIndex >= 0 ? currentIndex : 0} - slideCount={deck.slides.length} - designSystem={designSystem} - aspectRatio={deck.aspectRatio} - ydoc={ydoc} - awareness={awareness} - collabUser={ - currentUser - ? { name: currentUser.name, color: currentUser.color } - : undefined - } - agentActive={agentActive} - onComment={(quotedText) => { - setPendingComment({ quotedText }); - setCommentsOpen(true); - }} - drawMode={drawMode} - onExitDrawMode={() => setDrawMode(false)} - pinMode={pinMode} - onExitPinMode={() => setPinMode(false)} - slideId={currentSlide.id} - slideTitle={(() => { - const m = currentSlide.content?.match( - /]*>([^<]+)<\/h[12]>/i, - ); - return ( - m?.[1]?.trim() || - `Slide ${(currentIndex >= 0 ? currentIndex : 0) + 1}` - ); - })()} - /> - )} + {!(isNewDeckGenerating && deck.slides.length === 0) && + !showQuestionFlow && + currentSlide && ( + + updateSlide(id, currentSlide.id, updates) + } + activeTab={activeTab} + onGenerateImage={() => setImageGenOpen(true)} + onOpenAssetLibrary={(src) => { + setReplaceImageSrc(src); + setAssetLibraryOpen(true); + }} + onUploadImage={(src) => { + setReplaceImageSrc(src); + uploadInputRef.current?.click(); + }} + onSearchImage={(src) => { + setReplaceImageSrc(src); + setImageSearchOpen(true); + }} + onLogoSearch={(src) => { + setReplaceImageSrc(src); + setLogoSearchOpen(true); + }} + onToggleObjectFit={toggleObjectFit} + slideIndex={currentIndex >= 0 ? currentIndex : 0} + slideCount={deck.slides.length} + designSystem={designSystem} + aspectRatio={deck.aspectRatio} + ydoc={ydoc} + awareness={awareness} + collabUser={ + currentUser + ? { name: currentUser.name, color: currentUser.color } + : undefined + } + agentActive={agentActive} + onComment={(quotedText) => { + setPendingComment({ quotedText }); + setCommentsOpen(true); + }} + drawMode={drawMode} + onExitDrawMode={() => setDrawMode(false)} + pinMode={pinMode} + onExitPinMode={() => setPinMode(false)} + slideId={currentSlide.id} + slideTitle={(() => { + const m = currentSlide.content?.match( + /]*>([^<]+)<\/h[12]>/i, + ); + return ( + m?.[1]?.trim() || + `Slide ${(currentIndex >= 0 ? currentIndex : 0) + 1}` + ); + })()} + /> + )} {commentsOpen && ( { export default function Index() { const { decks, createDeck, deleteDeck, updateDeck, loading } = useDecks(); + const { designSystems, defaultSystem } = useDesignSystems(); const { session } = useSession(); const navigate = useNavigate(); const [deckToDelete, setDeckToDelete] = useState(null); const [showNewDeckPrompt, setShowNewDeckPrompt] = useState(false); + const [selectedDesignSystemId, setSelectedDesignSystemId] = useState(""); const [showSignInDialog, setShowSignInDialog] = useState(false); const [duplicating, setDuplicating] = useState(null); const duplicatingRef = useRef(null); @@ -106,12 +116,39 @@ export default function Index() { const anchorRef = useRef(null); // Keep anchorRef.current in sync so PromptPopover can read it anchorRef.current = anchorElRef.current; + const designSystemTitleById = useMemo( + () => new Map(designSystems.map((ds) => [ds.id, ds.title])), + [designSystems], + ); - const openNewDeck = useCallback((e: React.MouseEvent) => { - anchorElRef.current = e.currentTarget; - setShowNewDeckPrompt(true); + const openNewDeck = useCallback( + (e: React.MouseEvent) => { + anchorElRef.current = e.currentTarget; + setSelectedDesignSystemId(defaultSystem?.id ?? ""); + setShowNewDeckPrompt(true); + }, + [defaultSystem?.id], + ); + + const setNewDeckPromptOpen = useCallback((open: boolean) => { + setShowNewDeckPrompt(open); + if (!open) setSelectedDesignSystemId(""); }, []); + useEffect(() => { + if (!showNewDeckPrompt || selectedDesignSystemId) return; + if (defaultSystem?.id) { + setSelectedDesignSystemId(defaultSystem.id); + } else if (designSystems.length > 0) { + setSelectedDesignSystemId("none"); + } + }, [ + defaultSystem?.id, + designSystems.length, + selectedDesignSystemId, + showNewDeckPrompt, + ]); + // Restore a prompt that was held back when the user wasn't signed in: // we wrote the text to sessionStorage before redirecting to sign-in, // and now that they're back and authenticated, replay it into the @@ -139,13 +176,20 @@ export default function Index() { ); sessionStorage.removeItem(PENDING_PROMPT_KEY); } catch {} + setSelectedDesignSystemId(defaultSystem?.id ?? "none"); setShowNewDeckPrompt(true); - }, [session]); + }, [defaultSystem?.id, session]); const handleCreateDeckBlank = () => { + const selectedDesignSystem = + selectedDesignSystemId && selectedDesignSystemId !== "none" + ? designSystems.find((ds) => ds.id === selectedDesignSystemId) + : undefined; let deck: ReturnType | undefined; flushSync(() => { - deck = createDeck(); + deck = createDeck(undefined, { + designSystemId: selectedDesignSystem?.id ?? null, + }); }); if (!deck) return; navigate(`/deck/${deck.id}`); @@ -164,17 +208,24 @@ export default function Index() { try { sessionStorage.setItem(PENDING_PROMPT_KEY, prompt); } catch {} - setShowNewDeckPrompt(false); + setNewDeckPromptOpen(false); setShowSignInDialog(true); return; } + const selectedDesignSystem = + selectedDesignSystemId && selectedDesignSystemId !== "none" + ? designSystems.find((ds) => ds.id === selectedDesignSystemId) + : undefined; let deck: ReturnType | undefined; flushSync(() => { - deck = createDeck(undefined, { noDefaultSlides: true }); + deck = createDeck(undefined, { + noDefaultSlides: true, + designSystemId: selectedDesignSystem?.id ?? null, + }); }); if (!deck) return; - setShowNewDeckPrompt(false); + setNewDeckPromptOpen(false); navigate(`/deck/${deck.id}?generating=1`); const trimmedPrompt = prompt.trim(); @@ -194,6 +245,20 @@ export default function Index() { "If the action cannot read a private document, tell the user the exact sharing step from the action error instead of generating from the URL alone.", ].join("\n") : ""; + const designSystemContext = selectedDesignSystem + ? [ + "", + "Design system selection:", + `- Use "${selectedDesignSystem.title}" (id: ${selectedDesignSystem.id}).`, + "- The deck has already been linked to this design system.", + `- Before adding slides, call \`get-design-system --id ${selectedDesignSystem.id}\` and use its tokens for colors, typography, spacing, imagery, and slide defaults.`, + "- Do not choose or apply a different design system.", + ].join("\n") + : [ + "", + "Design system selection:", + "- None selected. Do not apply a design system unless the user asks for one.", + ].join("\n"); const context = [ `The user just created a new empty deck (id: "${deck.id}") and wants to fill it with slides.`, @@ -206,10 +271,13 @@ export default function Index() { : "", googleDocContext, fileContext, + designSystemContext, "", + "Start a `manage-progress` run so progress appears in the app header. Add the first slide as soon as it is ready, then continue in small batches of 3-5 slides so the editor visibly fills in.", "Add slides ONE AT A TIME using the `add-slide` action with --deckId=" + deck.id + - ". You can fire multiple add-slide calls in parallel — they run concurrently and the user sees each slide appear as soon as it lands.", + ". You may batch add-slide calls, but keep batches small and pass `position` values so slide order stays stable.", + "If the user asked for a specific slide count, keep going in batches until that count is reached unless a tool error blocks you.", "Each slide's --content must be full HTML. Slide HTML templates are in your AGENTS.md.", "Do NOT use create-deck (the deck already exists). Do NOT call db-schema, resource-read, or search-files.", ].join("\n"); @@ -336,6 +404,11 @@ export default function Index() { onRename={handleRename} onDuplicate={handleDuplicate} isDuplicating={duplicating === deck.id} + designSystemTitle={ + deck.designSystemId + ? designSystemTitleById.get(deck.designSystemId) + : null + } /> ))} @@ -369,7 +442,7 @@ export default function Index() { + > + {designSystems.length > 0 && ( +
+ + +
+ )} +
{/* Sign-in required to create a deck. Shown when an unauthenticated user submits a prompt — the typed prompt is preserved in diff --git a/templates/slides/server/handlers/decks.ts b/templates/slides/server/handlers/decks.ts index aff5a0e19..5fa82a534 100644 --- a/templates/slides/server/handlers/decks.ts +++ b/templates/slides/server/handlers/decks.ts @@ -156,6 +156,7 @@ export const listDecks = defineEventHandler(async (event) => { id: row.id, title: row.title, visibility: row.visibility, + designSystemId: row.designSystemId ?? deck.designSystemId ?? null, slides: deck.slides || [], }; }); @@ -180,6 +181,7 @@ export const getDeck = defineEventHandler(async (event) => { id: row.id, title: row.title, visibility: row.visibility, + designSystemId: row.designSystemId ?? deck.designSystemId ?? null, }; } catch (err) { if (err instanceof ForbiddenError) { @@ -217,6 +219,10 @@ export const updateDeck = defineEventHandler(async (event) => { deck.id = id; deck.updatedAt = now; const title = deck.title || "Untitled"; + const nextDesignSystemId = + typeof deck.designSystemId === "string" && deck.designSystemId + ? deck.designSystemId + : null; // Resolve access first — this loads the row AND tells us the caller's // effective role in one pass, so we never run an unscoped existence @@ -235,11 +241,15 @@ export const updateDeck = defineEventHandler(async (event) => { new ForbiddenError("Sign in to create a deck"), ); } + if (nextDesignSystemId) { + await assertAccess("design-system", nextDesignSystemId, "viewer"); + } try { await db.insert(schema.decks).values({ id, title, data: JSON.stringify(deck), + designSystemId: nextDesignSystemId, ownerEmail: email, orgId: orgId ?? null, createdAt: now, @@ -258,6 +268,9 @@ export const updateDeck = defineEventHandler(async (event) => { access.role === "admin" || access.role === "editor" ) { + if (nextDesignSystemId) { + await assertAccess("design-system", nextDesignSystemId, "viewer"); + } // Caller has editor+ access — perform the update. The access check // above already confirmed the row exists and the caller can write. await db @@ -265,6 +278,7 @@ export const updateDeck = defineEventHandler(async (event) => { .set({ title, data: JSON.stringify(deck), + designSystemId: nextDesignSystemId ?? access.resource.designSystemId, updatedAt: now, }) .where(eq(schema.decks.id, id)); @@ -304,11 +318,20 @@ export const createDeck = defineEventHandler(async (event) => { const db = getDb(); const now = new Date().toISOString(); + const designSystemId = + typeof deck.designSystemId === "string" && deck.designSystemId + ? deck.designSystemId + : null; + + if (designSystemId) { + await assertAccess("design-system", designSystemId, "viewer"); + } await db.insert(schema.decks).values({ id: deck.id, title: deck.title || "Untitled", data: JSON.stringify(deck), + designSystemId, ownerEmail: email, orgId: orgId ?? null, createdAt: now,