From 76d80c8af15f7c1b0c01678064a12f4c5bf444a2 Mon Sep 17 00:00:00 2001 From: Steve Sewell Date: Wed, 6 May 2026 16:22:26 -0400 Subject: [PATCH 01/15] refactor(mail): extract gmail query builder into lib + tests --- templates/mail/app/root.tsx | 23 +++--- templates/mail/server/handlers/emails.ts | 72 +------------------ templates/mail/server/lib/gmail-query.spec.ts | 52 ++++++++++++++ templates/mail/server/lib/gmail-query.ts | 53 ++++++++++++++ 4 files changed, 122 insertions(+), 78 deletions(-) create mode 100644 templates/mail/server/lib/gmail-query.spec.ts create mode 100644 templates/mail/server/lib/gmail-query.ts diff --git a/templates/mail/app/root.tsx b/templates/mail/app/root.tsx index aae820fc0..9c407420f 100644 --- a/templates/mail/app/root.tsx +++ b/templates/mail/app/root.tsx @@ -110,18 +110,25 @@ 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; + 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..5fd74ac7a 100644 --- a/templates/mail/server/handlers/emails.ts +++ b/templates/mail/server/handlers/emails.ts @@ -46,6 +46,7 @@ import { getAccountDisplayName, setAccountDisplayName, } from "../lib/google-auth.js"; +import { buildGmailEmailSearchQuery } from "../lib/gmail-query.js"; import { incrementSendFrequency, getContactFrequencyMap, @@ -122,12 +123,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)); } @@ -412,70 +407,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..fad5c161f --- /dev/null +++ b/templates/mail/server/lib/gmail-query.spec.ts @@ -0,0 +1,52 @@ +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(" "); +} From 4eb26d7333e12e21667576de564f4ac16c6d828e Mon Sep 17 00:00:00 2001 From: Steve Sewell Date: Wed, 6 May 2026 16:22:45 -0400 Subject: [PATCH 02/15] test(mail): tighten gmail-query spec assertion --- templates/mail/server/lib/gmail-query.spec.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/templates/mail/server/lib/gmail-query.spec.ts b/templates/mail/server/lib/gmail-query.spec.ts index fad5c161f..a02943691 100644 --- a/templates/mail/server/lib/gmail-query.spec.ts +++ b/templates/mail/server/lib/gmail-query.spec.ts @@ -45,8 +45,6 @@ describe("buildGmailEmailSearchQuery", () => { describe("gmailLabelSearchClause", () => { it("quotes Gmail labels that need quoting", () => { - expect(gmailLabelSearchClause("Team/Foo Bar")).toBe( - 'label:"Team/Foo-Bar"', - ); + expect(gmailLabelSearchClause("Team/Foo Bar")).toBe('label:"Team/Foo-Bar"'); }); }); From d2d1d45f6bf41869adb795438e7d7c09d7a082b2 Mon Sep 17 00:00:00 2001 From: Steve Sewell Date: Wed, 6 May 2026 16:23:40 -0400 Subject: [PATCH 03/15] feat(macros): add Supabase RLS policies for meals/exercises/weights --- .../migrations/20260506_enable_rls.sql | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 templates/macros/supabase/migrations/20260506_enable_rls.sql 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); From fcedffb06fa6632c8bea9ff82c3ff6d1e94409e3 Mon Sep 17 00:00:00 2001 From: Steve Sewell Date: Wed, 6 May 2026 16:24:39 -0400 Subject: [PATCH 04/15] chore(clips): add macros RLS inspection script --- templates/clips/_apply-macros-rls.mjs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 templates/clips/_apply-macros-rls.mjs diff --git a/templates/clips/_apply-macros-rls.mjs b/templates/clips/_apply-macros-rls.mjs new file mode 100644 index 000000000..e878c35ec --- /dev/null +++ b/templates/clips/_apply-macros-rls.mjs @@ -0,0 +1,22 @@ +import postgres from "postgres"; + +const url = process.env.DATABASE_URL; +if (!url) { + console.error("DATABASE_URL not set"); + process.exit(1); +} +const sql = postgres(url, { prepare: false }); + +const detail = await sql` + SELECT tablename, policyname, cmd, roles, qual, with_check + FROM pg_policies + WHERE schemaname = 'public' + AND tablename = 'weights' + ORDER BY policyname; +`; +for (const p of detail) { + console.log(`${p.policyname} (cmd=${p.cmd}, roles=${p.roles})`); + console.log(` USING: ${p.qual ?? "(none)"}`); + console.log(` WITH CHECK: ${p.with_check ?? "(none)"}`); +} +await sql.end(); From f8e7b003ea9502f4f973c6b949b4280f25dcb07f Mon Sep 17 00:00:00 2001 From: Steve Sewell Date: Wed, 6 May 2026 16:25:32 -0400 Subject: [PATCH 05/15] feat(mail): add external-refresh signal to bypass email cache + remove RLS debug script --- templates/clips/_apply-macros-rls.mjs | 22 ---------------------- templates/mail/app/hooks/use-emails.ts | 9 +++++++++ templates/mail/app/root.tsx | 2 ++ templates/mail/server/handlers/emails.ts | 5 +++++ 4 files changed, 16 insertions(+), 22 deletions(-) delete mode 100644 templates/clips/_apply-macros-rls.mjs diff --git a/templates/clips/_apply-macros-rls.mjs b/templates/clips/_apply-macros-rls.mjs deleted file mode 100644 index e878c35ec..000000000 --- a/templates/clips/_apply-macros-rls.mjs +++ /dev/null @@ -1,22 +0,0 @@ -import postgres from "postgres"; - -const url = process.env.DATABASE_URL; -if (!url) { - console.error("DATABASE_URL not set"); - process.exit(1); -} -const sql = postgres(url, { prepare: false }); - -const detail = await sql` - SELECT tablename, policyname, cmd, roles, qual, with_check - FROM pg_policies - WHERE schemaname = 'public' - AND tablename = 'weights' - ORDER BY policyname; -`; -for (const p of detail) { - console.log(`${p.policyname} (cmd=${p.cmd}, roles=${p.roles})`); - console.log(` USING: ${p.qual ?? "(none)"}`); - console.log(` WITH CHECK: ${p.with_check ?? "(none)"}`); -} -await sql.end(); 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 9c407420f..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"; @@ -120,6 +121,7 @@ function VisibilityRefresh() { const now = Date.now(); if (now - lastRefresh.current < 1000) return; lastRefresh.current = now; + markExternalEmailRefresh(); qc.invalidateQueries({ queryKey: ["emails"] }); qc.invalidateQueries({ queryKey: ["labels"] }); }; diff --git a/templates/mail/server/handlers/emails.ts b/templates/mail/server/handlers/emails.ts index 5fd74ac7a..76cb908f9 100644 --- a/templates/mail/server/handlers/emails.ts +++ b/templates/mail/server/handlers/emails.ts @@ -41,6 +41,7 @@ import { } from "../lib/google-api.js"; import { isConnected, + invalidateListCacheForOwner, listGmailMessages, gmailToEmailMessage, getAccountDisplayName, @@ -369,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") { @@ -394,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; From e5ad808542ed25e6063b622ae45c0379307f41db Mon Sep 17 00:00:00 2001 From: Steve Sewell Date: Wed, 6 May 2026 16:30:44 -0400 Subject: [PATCH 06/15] feat(slides): add agent work indicator + reflect chat running state both ways --- packages/core/src/client/AssistantChat.tsx | 11 +++++ packages/core/src/client/use-agent-chat.ts | 4 +- .../app/components/editor/EditorToolbar.tsx | 24 +++++++++++ .../components/layout/AgentWorkIndicator.tsx | 41 +++++++++++++++++++ .../slides/app/components/layout/Header.tsx | 2 + .../slides/app/components/layout/Layout.tsx | 2 + 6 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 templates/slides/app/components/layout/AgentWorkIndicator.tsx 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/use-agent-chat.ts b/packages/core/src/client/use-agent-chat.ts index 327f0962a..d93dcf5e4 100644 --- a/packages/core/src/client/use-agent-chat.ts +++ b/packages/core/src/client/use-agent-chat.ts @@ -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/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) */} { + 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} + From 31bd28cf98d71f107cdfcf5e715f449cb735ce2f Mon Sep 17 00:00:00 2001 From: Steve Sewell Date: Wed, 6 May 2026 16:31:05 -0400 Subject: [PATCH 07/15] chore: add changeset for chatRunning event --- .changeset/agent-chat-running-event.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/agent-chat-running-event.md 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. From fbef71d2920a0f1ddeec5463478ccf27aff71b2f Mon Sep 17 00:00:00 2001 From: Steve Sewell Date: Wed, 6 May 2026 16:31:37 -0400 Subject: [PATCH 08/15] fix(slides): reset deck design system + overlay state on deck switch --- .../components/editor/GeneratingOverlay.tsx | 13 +++++--- templates/slides/app/context/DeckContext.tsx | 11 +++++-- .../app/hooks/use-deck-design-system.ts | 18 +++++++++-- templates/slides/app/pages/DeckEditor.tsx | 30 +++++++++++++++---- templates/slides/server/handlers/decks.ts | 19 ++++++++++++ 5 files changed, 76 insertions(+), 15 deletions(-) diff --git a/templates/slides/app/components/editor/GeneratingOverlay.tsx b/templates/slides/app/components/editor/GeneratingOverlay.tsx index ff01123f6..9ade72511 100644 --- a/templates/slides/app/components/editor/GeneratingOverlay.tsx +++ b/templates/slides/app/components/editor/GeneratingOverlay.tsx @@ -3,11 +3,16 @@ import { IconLoader2 } from "@tabler/icons-react"; export default function GeneratingOverlay() { return (
-
+
-

- 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/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/pages/DeckEditor.tsx b/templates/slides/app/pages/DeckEditor.tsx index ca4b4a186..0c978d1fc 100644 --- a/templates/slides/app/pages/DeckEditor.tsx +++ b/templates/slides/app/pages/DeckEditor.tsx @@ -120,7 +120,9 @@ export default function DeckEditor() { const uploadInputRef = useRef(null); const deck = getDeck(id || ""); - const { designSystem } = useDeckDesignSystem(deck?.designSystemId); + const { designSystem, designSystemTitle } = useDeckDesignSystem( + deck?.designSystemId, + ); // Poll for question flow from agent (show-questions application state) const { data: questionFlowData } = useQuery<{ @@ -164,9 +166,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 +193,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, }); @@ -600,6 +602,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 +659,20 @@ 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 && ( + {!(isNewDeckGenerating && deck.slides.length === 0) && + !showQuestionFlow && + currentSlide && ( diff --git a/templates/slides/server/handlers/decks.ts b/templates/slides/server/handlers/decks.ts index aff5a0e19..a76d5f7e6 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 @@ -258,6 +264,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 +274,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 +314,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, From 51d1574fbd5a18ff7f7c9a7e759608c90bd674b9 Mon Sep 17 00:00:00 2001 From: Steve Sewell Date: Wed, 6 May 2026 16:32:31 -0400 Subject: [PATCH 09/15] feat(slides): track per-deck generation state during AI design + creation --- .../slides/actions/apply-design-system.ts | 3 ++ templates/slides/actions/create-deck.ts | 19 ++++++- templates/slides/actions/duplicate-deck.ts | 1 + templates/slides/app/lib/generation-state.ts | 21 ++++++++ templates/slides/app/pages/DeckEditor.tsx | 49 ++++++++++--------- templates/slides/app/pages/Index.tsx | 2 + 6 files changed, 71 insertions(+), 24 deletions(-) create mode 100644 templates/slides/app/lib/generation-state.ts 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..47b21db17 100644 --- a/templates/slides/actions/create-deck.ts +++ b/templates/slides/actions/create-deck.ts @@ -59,11 +59,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 +86,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 +117,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/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 0c978d1fc..7b39284a5 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,6 +124,12 @@ export default function DeckEditor() { const uploadInputRef = useRef(null); const deck = getDeck(id || ""); + const slideCount = deck?.slides.length ?? 0; + const isNewDeckGenerating = shouldShowNewDeckGeneratingOverlay({ + generating, + isNewDeckCreation: wasNewDeckCreation.current, + slideCount, + }); const { designSystem, designSystemTitle } = useDeckDesignSystem( deck?.designSystemId, ); @@ -204,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 } }), diff --git a/templates/slides/app/pages/Index.tsx b/templates/slides/app/pages/Index.tsx index f7e807c21..35d403263 100644 --- a/templates/slides/app/pages/Index.tsx +++ b/templates/slides/app/pages/Index.tsx @@ -210,6 +210,8 @@ export default function Index() { "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.", + "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 deck before adding slide 1.", + "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"); From fcad95d9d5aa163a35130abcf5877cd61935dc32 Mon Sep 17 00:00:00 2001 From: Steve Sewell Date: Wed, 6 May 2026 16:33:30 -0400 Subject: [PATCH 10/15] feat(slides): track AI slide generation in generation-state + add tests --- templates/slides/AGENTS.md | 1 + templates/slides/actions/add-slide.ts | 1 + templates/slides/actions/create-deck.ts | 6 +- .../slides/actions/generate-slides-ai.ts | 3 +- .../app/components/editor/EditorSidebar.tsx | 8 ++- .../slides/app/lib/generation-state.test.ts | 56 +++++++++++++++++++ templates/slides/app/pages/Index.tsx | 43 ++++++++++++-- 7 files changed, 108 insertions(+), 10 deletions(-) create mode 100644 templates/slides/app/lib/generation-state.test.ts diff --git a/templates/slides/AGENTS.md b/templates/slides/AGENTS.md index 4d0efd819..adaf2fa93 100644 --- a/templates/slides/AGENTS.md +++ b/templates/slides/AGENTS.md @@ -237,6 +237,7 @@ 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. +4. For requests above 6 slides, work in visible batches of at most 4 `add-slide` calls per model turn. Add the first batch immediately, then continue with the next batch 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:** 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/create-deck.ts b/templates/slides/actions/create-deck.ts index 47b21db17..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"), 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/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/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/pages/Index.tsx b/templates/slides/app/pages/Index.tsx index 35d403263..73c20ac34 100644 --- a/templates/slides/app/pages/Index.tsx +++ b/templates/slides/app/pages/Index.tsx @@ -7,6 +7,7 @@ import DeckCard from "@/components/deck/DeckCard"; import PromptPopover from "@/components/editor/PromptDialog"; import type { UploadedFile } from "@/components/editor/PromptDialog"; import { useAgentGenerating } from "@/hooks/use-agent-generating"; +import { useDesignSystems } from "@/hooks/use-design-systems"; import { useSetHeaderActions, useSetPageTitle, @@ -29,6 +30,13 @@ import { } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; const MAX_SOURCE_CONTEXT_CHARS = 60_000; const NEW_DECK_DRAFT_SCOPE = "slides-new-deck"; @@ -94,10 +102,12 @@ async function waitForDeckServerRow(deckId: string): Promise { 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); @@ -107,11 +117,25 @@ export default function Index() { // Keep anchorRef.current in sync so PromptPopover can read it anchorRef.current = anchorElRef.current; - 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 ?? "none"); + setShowNewDeckPrompt(true); + }, + [defaultSystem?.id], + ); + + const setNewDeckPromptOpen = useCallback((open: boolean) => { + setShowNewDeckPrompt(open); + if (!open) setSelectedDesignSystemId(""); }, []); + useEffect(() => { + if (!showNewDeckPrompt || selectedDesignSystemId) return; + setSelectedDesignSystemId(defaultSystem?.id ?? "none"); + }, [defaultSystem?.id, 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 +163,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,7 +195,7 @@ export default function Index() { try { sessionStorage.setItem(PENDING_PROMPT_KEY, prompt); } catch {} - setShowNewDeckPrompt(false); + setNewDeckPromptOpen(false); setShowSignInDialog(true); return; } From 98e81ceac8b3f19a928fa7413f93a7db97069bdd Mon Sep 17 00:00:00 2001 From: Steve Sewell Date: Wed, 6 May 2026 16:34:36 -0400 Subject: [PATCH 11/15] feat(slides): show in-progress generation indicator on deck cards --- templates/slides/AGENTS.md | 4 +- .../slides/app/components/deck/DeckCard.tsx | 24 ++++++- .../design-system/DesignSystemSetup.tsx | 4 ++ .../app/components/editor/PromptDialog.tsx | 4 ++ templates/slides/app/pages/Index.tsx | 68 +++++++++++++++++-- 5 files changed, 93 insertions(+), 11 deletions(-) diff --git a/templates/slides/AGENTS.md b/templates/slides/AGENTS.md index adaf2fa93..38bfeeccc 100644 --- a/templates/slides/AGENTS.md +++ b/templates/slides/AGENTS.md @@ -253,9 +253,9 @@ If a metric or source would make the slide stronger but is not available, use qu | 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 '[]' [--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 ` | Replace all slides in an existing deck (atomic bulk replace) | +| `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) | 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..7506b8c11 100644 --- a/templates/slides/app/components/design-system/DesignSystemSetup.tsx +++ b/templates/slides/app/components/design-system/DesignSystemSetup.tsx @@ -311,6 +311,10 @@ 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/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} + (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) => { @@ -200,12 +204,19 @@ export default function Index() { 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(); @@ -225,6 +236,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.`, @@ -237,11 +262,12 @@ 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.", - "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 deck before adding slide 1.", + ". 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.", @@ -369,6 +395,11 @@ export default function Index() { onRename={handleRename} onDuplicate={handleDuplicate} isDuplicating={duplicating === deck.id} + designSystemTitle={ + deck.designSystemId + ? designSystemTitleById.get(deck.designSystemId) + : null + } /> ))} @@ -402,7 +433,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 From 543f5e671022660aea0a048b9c6506804716c54e Mon Sep 17 00:00:00 2001 From: Steve Sewell Date: Wed, 6 May 2026 16:35:34 -0400 Subject: [PATCH 12/15] fix(core): broadcast chat running state on normal start + stop transitions --- .changeset/chat-running-status.md | 5 +++++ packages/core/src/client/use-agent-chat.ts | 2 +- templates/slides/AGENTS.md | 8 +++++--- templates/slides/app/pages/Index.tsx | 4 ++-- 4 files changed, 13 insertions(+), 6 deletions(-) create mode 100644 .changeset/chat-running-status.md diff --git a/.changeset/chat-running-status.md b/.changeset/chat-running-status.md new file mode 100644 index 000000000..a713d9267 --- /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. diff --git a/packages/core/src/client/use-agent-chat.ts b/packages/core/src/client/use-agent-chat.ts index d93dcf5e4..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(): [ diff --git a/templates/slides/AGENTS.md b/templates/slides/AGENTS.md index 38bfeeccc..e1212f5d9 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,13 +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. -4. For requests above 6 slides, work in visible batches of at most 4 `add-slide` calls per model turn. Add the first batch immediately, then continue with the next batch after tool results until the requested slide count is reached. Do not spend a long turn designing the whole deck before slide 1 lands. +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:** diff --git a/templates/slides/app/pages/Index.tsx b/templates/slides/app/pages/Index.tsx index fb5b185ac..0ee793c33 100644 --- a/templates/slides/app/pages/Index.tsx +++ b/templates/slides/app/pages/Index.tsx @@ -124,7 +124,7 @@ export default function Index() { const openNewDeck = useCallback( (e: React.MouseEvent) => { anchorElRef.current = e.currentTarget; - setSelectedDesignSystemId(defaultSystem?.id ?? "none"); + setSelectedDesignSystemId(defaultSystem?.id ?? ""); setShowNewDeckPrompt(true); }, [defaultSystem?.id], @@ -137,7 +137,7 @@ export default function Index() { useEffect(() => { if (!showNewDeckPrompt || selectedDesignSystemId) return; - setSelectedDesignSystemId(defaultSystem?.id ?? "none"); + setSelectedDesignSystemId(defaultSystem?.id ?? ""); }, [defaultSystem?.id, selectedDesignSystemId, showNewDeckPrompt]); // Restore a prompt that was held back when the user wasn't signed in: From ef237171b02d3b7172f3f2a71aca71af3273820a Mon Sep 17 00:00:00 2001 From: Steve Sewell Date: Wed, 6 May 2026 16:36:35 -0400 Subject: [PATCH 13/15] feat(slides): include placeholder duplicating decks in list responses --- templates/slides/AGENTS.md | 16 ++++++++-------- .../app/components/layout/AgentWorkIndicator.tsx | 9 ++++++++- templates/slides/app/pages/Index.tsx | 13 +++++++++++-- templates/slides/server/handlers/decks.ts | 4 ++++ 4 files changed, 31 insertions(+), 11 deletions(-) diff --git a/templates/slides/AGENTS.md b/templates/slides/AGENTS.md index e1212f5d9..85d7a2b81 100644 --- a/templates/slides/AGENTS.md +++ b/templates/slides/AGENTS.md @@ -252,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 | +| 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) | +| `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/app/components/layout/AgentWorkIndicator.tsx b/templates/slides/app/components/layout/AgentWorkIndicator.tsx index 6a73c7ea5..b5508d7f6 100644 --- a/templates/slides/app/components/layout/AgentWorkIndicator.tsx +++ b/templates/slides/app/components/layout/AgentWorkIndicator.tsx @@ -29,7 +29,14 @@ export function AgentWorkIndicator() {