import { askUserQuestion } from "@agent-native/core/client/agent-chat"; import { callAction, useSession } from "@agent-native/core/client/hooks"; import { useT } from "@agent-native/core/client/i18n"; import { buildSignInReturnHref } from "@agent-native/core/client/ui"; import { useSetHeaderActions, useSetPageTitle, } from "@agent-native/toolkit/app-shell"; import { extractGoogleDocUrls } from "@shared/google-docs"; import { IconAlertTriangle, IconPlus, IconRefresh, IconStack2, IconUserCircle, } from "@tabler/icons-react"; import { useState, useRef, useCallback, useEffect, useMemo } from "react"; import { flushSync } from "react-dom"; import { useNavigate, useSearchParams } from "react-router"; import { toast } from "sonner"; import DeckCard from "@/components/deck/DeckCard"; import PromptPopover from "@/components/editor/PromptDialog"; import type { UploadedFile } from "@/components/editor/PromptDialog"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { useDecks } from "@/context/DeckContext"; import { useAgentGenerating } from "@/hooks/use-agent-generating"; import { useDesignSystems } from "@/hooks/use-design-systems"; import { createDeckAgentMessage } from "@/lib/agent-visible-message"; import { savePromptToComposerDraft } from "@/lib/composer-draft"; const NEW_DECK_DRAFT_SCOPE = "slides-new-deck"; const PENDING_PROMPT_KEY = "slides:pending-deck-prompt"; function savePromptForRetry( prompt: string, options: { persistAcrossSignIn?: boolean } = {}, ) { let signInHandoffSaved = !options.persistAcrossSignIn; if (options.persistAcrossSignIn) { try { sessionStorage.setItem(PENDING_PROMPT_KEY, prompt); signInHandoffSaved = true; } catch {} } const draftSaved = savePromptToComposerDraft(NEW_DECK_DRAFT_SCOPE, prompt); return signInHandoffSaved && draftSaved; } function clearPendingPromptForRetry() { try { sessionStorage.removeItem(PENDING_PROMPT_KEY); } catch {} } function mergeUploadedFilesForRetry( savedFiles: UploadedFile[], newFiles: UploadedFile[], ): UploadedFile[] { const seen = new Set(); return [...savedFiles, ...newFiles].filter((file) => { const key = file.path || file.url || file.filename; if (seen.has(key)) return false; seen.add(key); return true; }); } interface DesignSystemGenerationContextResult { title?: string; agentContext?: string; } async function loadDesignSystemGenerationContext( designSystemId?: string | null, ): Promise { if (!designSystemId) return ""; try { const result = (await callAction( "get-design-system", { id: designSystemId }, { method: "GET" }, )) as DesignSystemGenerationContextResult | undefined; if (result?.agentContext?.trim()) { return [ "", result.agentContext.trim(), "", "The selected design system context above was hydrated before this agent run. Follow it directly; do not replace it with generic colors, fonts, spacing, imagery, or slide components.", ].join("\n"); } } catch (error) { const message = error instanceof Error ? error.message : "unknown loading error"; return [ "", "## Selected Design System Context", `The selected design system id "${designSystemId}" could not be loaded before generation: ${message}`, "Before adding slides, call `get-design-system` for this id. If it still fails, stop and tell the user the selected design system is unavailable instead of improvising a generic style.", ].join("\n"); } return [ "", "## Selected Design System Context", `The selected design system id "${designSystemId}" returned no generation context.`, "Call `get-design-system` for this id before adding slides. If it still has no usable tokens/docs, stop and ask the user to finish design-system indexing instead of improvising a generic style.", ].join("\n"); } function describeUploadedFilesForAgent( files: UploadedFile[], deckId: string, ): string { if (files.length === 0) return ""; const fileList = files .map( (f) => `- ${f.originalName} (${f.type}, ${(f.size / 1024).toFixed(1)}KB) at path: ${f.path}${f.url ? `; embeddable URL: ${f.url}` : ""}`, ) .join("\n"); return [ "", `The user uploaded ${files.length} file(s). These paths are real uploaded files; process them with import actions before using their contents:`, fileList, "", "File handling rules:", `- PPTX files: call \`import-pptx --filePath "" --deckId ${deckId}\` before adding or editing slides.`, `- PDF and DOCX files: call \`import-file --filePath "" --format auto --deckId ${deckId}\` and use the returned extracted text as source material. The returned text is capped for reliability; re-run with maxChars only if more context is needed.`, "- Text-like files: use the uploaded-text-file blocks already included in the prompt; do not call import-file for them.", '- Image files with an embeddable URL can be inserted directly into slide HTML as `` or used as visual references.', "- Image files without a URL are visual/reference assets only; do not claim to have processed a PPTX/PDF/DOCX unless the relevant import action succeeds.", ].join("\n"); } export default function Index() { const t = useT(); const { decks, createDeck, ensureDeckPersisted, deleteDeck, updateDeck, loading, loadError, reloadDecks, } = useDecks(); const { designSystems, defaultSystem } = useDesignSystems(); const { session } = useSession(); const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); const [deckToDelete, setDeckToDelete] = useState(null); const [showNewDeckPrompt, setShowNewDeckPrompt] = useState(false); const [newDeckInitialPrompt, setNewDeckInitialPrompt] = useState<{ text: string; key: number; } | null>(null); const [newDeckRetryFiles, setNewDeckRetryFiles] = useState( [], ); const [signInPromptHadFiles, setSignInPromptHadFiles] = useState(false); const [selectedDesignSystemId, setSelectedDesignSystemId] = useState(""); const [showSignInDialog, setShowSignInDialog] = useState(false); const [duplicating, setDuplicating] = useState(null); const duplicatingRef = useRef(null); const { generating, submit: agentSubmit } = useAgentGenerating(); const anchorElRef = useRef(null); 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 deckFilter = searchParams.get("createdBy") === "me" ? "mine" : "all"; const visibleDecks = useMemo( () => deckFilter === "mine" ? decks.filter((deck) => deck.createdByMe) : decks, [deckFilter, decks], ); const setDeckFilter = useCallback( (value: string) => { const nextFilter = value === "mine" ? "mine" : "all"; setSearchParams( (prev) => { const next = new URLSearchParams(prev); if (nextFilter === "mine") { next.set("createdBy", "me"); } else { next.delete("createdBy"); } return next; }, { replace: true }, ); }, [setSearchParams], ); const openNewDeck = useCallback( (e: React.MouseEvent) => { anchorElRef.current = e.currentTarget; setSelectedDesignSystemId(defaultSystem?.id ?? ""); setShowNewDeckPrompt(true); }, [defaultSystem?.id], ); const setNewDeckPromptOpen = useCallback( (open: boolean, options: { clearInitialPrompt?: boolean } = {}) => { setShowNewDeckPrompt(open); if (!open) { setSelectedDesignSystemId(""); if (options.clearInitialPrompt !== false) { setNewDeckInitialPrompt(null); setNewDeckRetryFiles([]); } } }, [], ); const preservePromptForSignIn = useCallback( (prompt: string, options: { hadFiles?: boolean } = {}) => { if (!savePromptForRetry(prompt, { persistAcrossSignIn: true })) { setNewDeckInitialPrompt({ text: prompt, key: Date.now() }); } setNewDeckRetryFiles([]); setSignInPromptHadFiles(Boolean(options.hadFiles)); setNewDeckPromptOpen(false, { clearInitialPrompt: false }); setShowSignInDialog(true); }, [setNewDeckPromptOpen], ); const setSignInDialogOpen = useCallback((open: boolean) => { setShowSignInDialog(open); if (!open) { setSignInPromptHadFiles(false); } }, []); 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 // composer's localStorage draft and pop the new-deck dialog open so // they can hit submit without retyping. useEffect(() => { if (!session) return; let saved: string | null = null; try { saved = sessionStorage.getItem(PENDING_PROMPT_KEY); } catch {} if (!saved) return; if (savePromptToComposerDraft(NEW_DECK_DRAFT_SCOPE, saved)) { clearPendingPromptForRetry(); setNewDeckInitialPrompt(null); } else { clearPendingPromptForRetry(); setNewDeckInitialPrompt({ text: saved, key: Date.now() }); } setSelectedDesignSystemId(defaultSystem?.id ?? "none"); setShowNewDeckPrompt(true); }, [defaultSystem?.id, session]); const handleCreateDeckBlank = () => { const selectedDesignSystem = selectedDesignSystemId && selectedDesignSystemId !== "none" ? designSystems.find((ds) => ds.id === selectedDesignSystemId) : undefined; let deck: ReturnType | undefined; flushSync(() => { deck = createDeck(undefined, { designSystemId: selectedDesignSystem?.id ?? null, }); }); if (!deck) return; navigate(`/deck/${deck.id}`); }; const handleCreateDeckWithPrompt = async ( prompt: string, files: UploadedFile[], ) => { // Pre-flight auth check. The add-deck action returns 403 silently // when unauthenticated, leaving the user stuck on a deck page that // doesn't exist server-side and a small auth error in the chat // sidebar. Catch it here so the user sees a clear sign-in prompt // and the typed prompt isn't lost when they come back. if (!session) { preservePromptForSignIn(prompt, { hadFiles: files.length > 0 }); return; } const filesForGeneration = mergeUploadedFilesForRetry( newDeckRetryFiles, files, ); const selectedDesignSystem = selectedDesignSystemId && selectedDesignSystemId !== "none" ? designSystems.find((ds) => ds.id === selectedDesignSystemId) : undefined; let deck: ReturnType | undefined; flushSync(() => { deck = createDeck(undefined, { noDefaultSlides: true, designSystemId: selectedDesignSystem?.id ?? null, }); }); if (!deck) return; setNewDeckPromptOpen(false); // One quick, skippable decision so the agent doesn't guess the deck size. const deckLength = await askUserQuestion({ question: t("home.deckLengthQuestion"), header: t("home.deckLengthHeader"), options: [ { label: t("home.deckLengthShort"), value: "3–5 slides" }, { label: t("home.deckLengthMedium"), value: "6–10 slides", recommended: true, }, { label: t("home.deckLengthLong"), value: "11+ slides" }, { label: t("home.deckLengthSingleVisual"), value: "a single standalone visual slide", }, ], allowFreeText: false, }); const deckLengthContext = typeof deckLength === "string" && deckLength ? `Target length: aim for ${deckLength} unless the user's request clearly specifies a different count.` : ""; const trimmedPrompt = prompt.trim(); const hasImportedGoogleDocContext = trimmedPrompt.includes(" 0 ? [ "", "The request includes Google Docs URL(s):", ...googleDocUrls.map((url) => `- ${url}`), "Before adding slides, call `import-google-doc` for each URL and use the returned text as source material.", "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 hydratedDesignSystemContext = await loadDesignSystemGenerationContext( selectedDesignSystem?.id, ); const designSystemContext = selectedDesignSystem ? [ "", "Design system selection:", `- Use "${selectedDesignSystem.title}" (id: ${selectedDesignSystem.id}).`, "- The deck has already been linked to this design system.", "- Use the hydrated design system context below for colors, typography, spacing, imagery, and slide defaults.", hydratedDesignSystemContext, "- 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 create a presentation or standalone visual.`, "The visible user message above contains the user's request and/or pasted source material for the deck. Treat pasted memo content as source material even if the user did not explicitly say they are pasting it.", googleDocContext, fileContext, designSystemContext, "", deckLengthContext, "Start a `manage-progress` run so progress appears in the app header. Add the first slide as soon as it is ready, then continue one slide at a time so the editor visibly fills in.", "If the user asks for a standalone visual, diagram, hero, one-pager, poster, or a couple of visuals, create only the requested one/few polished visual slides. Do not pad the result into a full presentation.", "Add slides ONE AT A TIME using the `add-slide` action with --deckId=" + deck.id + ". Wait for each `add-slide` result before calling it again; do not batch or parallelize slide writes.", "If the user asked for a specific slide count, keep going sequentially until that count is reached unless a tool error blocks you.", "Every slide is rendered into a fixed native canvas (default 16:9 is 960x540 CSS pixels). Keep each slide within the density limits in AGENTS.md; split dense source material across more slides instead of packing it tightly.", "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, the resources tool, or search-files.", ].join("\n"); const persisted = await ensureDeckPersisted(deck.id); if (!persisted) { if (!savePromptForRetry(prompt)) { setNewDeckInitialPrompt({ text: prompt, key: Date.now() }); } setNewDeckRetryFiles(filesForGeneration); deleteDeck(deck.id); toast.error(t("home.generationStartFailed"), { description: t("home.generationStartFailedDescription"), }); setShowNewDeckPrompt(true); return; } clearPendingPromptForRetry(); setNewDeckInitialPrompt(null); setNewDeckRetryFiles([]); agentSubmit(createDeckAgentMessage(trimmedPrompt), context, { newTab: true, openSidebar: true, }); navigate(`/deck/${deck.id}?generating=1`); }; const handleConfirmDelete = () => { if (deckToDelete) { deleteDeck(deckToDelete); setDeckToDelete(null); } }; const handleRename = useCallback( (id: string, newTitle: string) => { updateDeck(id, { title: newTitle }); }, [updateDeck], ); const handleDuplicate = useCallback( async (id: string) => { if (duplicatingRef.current) return; duplicatingRef.current = id; setDuplicating(id); try { const { id: newId } = await callAction("duplicate-deck", { deckId: id, }); navigate(`/deck/${newId}`); } finally { duplicatingRef.current = null; setDuplicating(null); } }, [navigate], ); useSetPageTitle(t("home.decksTitle")); // Inject "New Deck" into the global header actions slot. useSetHeaderActions( useMemo( () => ( ), [openNewDeck, t], ), ); return (
{loading ? ( <>
{Array.from({ length: 8 }).map((_, i) => (
))}
) : loadError ? (

{t("home.loadFailed")}

{t("home.loadFailedDescription")}

) : decks.length === 0 ? ( ) : ( <>
value && setDeckFilter(value)} className="w-fit rounded-lg border border-border bg-card p-0.5" size="sm" > {t("home.all")} {t("home.mine")} {deckFilter === "mine" ? `${visibleDecks.length} of ${decks.length}` : decks.length}{" "} {t("home.deckCount", { count: deckFilter === "mine" ? visibleDecks.length : decks.length, })}
{/* New deck card */} {[...visibleDecks].reverse().map((deck) => ( setDeckToDelete(id)} onRename={handleRename} onDuplicate={handleDuplicate} isDuplicating={duplicating === deck.id} designSystemTitle={ deck.designSystemId ? designSystemTitleById.get(deck.designSystemId) : null } /> ))} {visibleDecks.length === 0 && (
{t("home.noMineDecks")}
)}
)} {/* Delete Confirmation Dialog */} !open && setDeckToDelete(null)} > {t("home.deleteDeckTitle")} {t("home.deleteDeckDescription")} {t("home.cancel")} {t("home.delete")} { if (session) return true; preservePromptForSignIn(prompt, { hadFiles: files.length > 0 }); return false; }} loading={generating} anchorRef={anchorRef} draftScope={NEW_DECK_DRAFT_SCOPE} initialText={newDeckInitialPrompt?.text} initialTextKey={newDeckInitialPrompt?.key} > {designSystems.length > 0 && (
)}
{/* Sign-in required to create a deck. Shown when an unauthenticated user submits a prompt — the typed prompt is preserved in sessionStorage and replayed into the composer after sign-in. */} {t("home.signInTitle")} {signInPromptHadFiles ? t("home.signInDescriptionWithFiles") : t("home.signInDescription")} {t("home.cancel")} { window.location.href = buildSignInReturnHref(); }} > {t("home.signIn")}
); } function EmptyState({ onCreateDeck, }: { onCreateDeck: (e: React.MouseEvent) => void; }) { const t = useT(); return (

{t("home.emptyTitle")}

{t("home.emptyDescription")}

); }