'use client' import * as React from 'react' import Link from 'next/link' import { ArrowLeft, Download, FileText, Palette, Presentation, Sparkles } from 'lucide-react' import { useQuery } from '@tanstack/react-query' import type { Attachment } from '@startsimpli/api' import { DualPaneWorkspace } from '../workspace' import { ChatThread, ChatComposer } from '../chat' import type { ChatMessageData, ChatMessageAttachment } from '../chat/types' import { SlideDeckViewer } from '../slide-deck' import type { DeckBuilder } from './types' /** * Reopen display: if no message already carries attachments (the conversation * payload didn't echo them), hang the document's persisted reference attachments * off the earliest user message so they reappear in the chat after reopen. */ function hydratePriorAttachments( messages: ChatMessageData[], prior: Attachment[] | undefined ): ChatMessageData[] { if (!prior || prior.length === 0) return messages if (messages.some((m) => m.attachments && m.attachments.length > 0)) return messages const targetIdx = messages.findIndex((m) => m.role === 'user') if (targetIdx === -1) return messages const chips: ChatMessageAttachment[] = prior .filter((a) => a.purpose === 'reference') .map((a) => ({ id: a.id, fileUrl: a.fileUrl, filename: a.filename, contentType: a.contentType, })) if (chips.length === 0) return messages return messages.map((m, i) => (i === targetIdx ? { ...m, attachments: chips } : m)) } export interface DeckBuilderWorkspaceProps { /** Where the back arrow points. Omit to hide it (e.g. on a public /try page). */ backHref?: string /** * The orchestrator to render — injected, never imported here. The consuming * app wires a demo/no-backend engine or the backend workflow-run consumer; * both expose the identical `DeckBuilder` contract. Reference-attachment * support (uploader + reopen hydration) rides on `builder.attachments`. */ builder: DeckBuilder } /** * The chat | slides deck-builder page composer. Composed entirely from generic * @startsimpli/ui primitives (`DualPaneWorkspace` + chat + `SlideDeckViewer`) * wired to an injected deck-builder orchestrator. * * Pure + app-agnostic: the orchestrator is a prop (`builder`) so the same * surface can be driven by a demo engine or a backend workflow-run consumer, * and the reference-attachments client rides on the builder — the app renders * this as a thin `` wrapper. */ export function DeckBuilderWorkspace({ backHref, builder }: DeckBuilderWorkspaceProps) { const { deckTitle, phase, messages, slides, activeId, pptxUrl, pdfUrl, isWorking, documentId, deckStyle, attachments, setActiveId, sendMessage, regenerate, deleteSlide, moveSlide, insertSlide, editSlide, reorderSlide, } = builder // Only a backend-backed engine has a Document for reference uploads to land // on AND an attachments client wired up; a demo engine would strand every file // as a forever-queued chip, so it gets no uploader (attach affordance hidden). const canAttach = builder.supportsAttachments === true && !!attachments // A turn is in progress (agent thinking / slides generating). Prefer the // builder's own signal; fall back to slide status for engines that omit it. const busy = isWorking ?? slides.some((s) => s.status === 'generating') // Reference attachments are bundled into the shared ChatComposer: pass it the // document-scoped uploader and the composer self-manages upload state (chips, // downscale, retry, remove). Uploads are document-scoped — for a brand-new deck // with no document yet, the composer holds files client-side and flushes them // the moment the first turn creates the document and `documentId` arrives. The // backend agent gathers references off the Document, so we do NOT thread // attachment ids through the turn — landing the upload on `documentId` is enough. // Reopen: surface the document's persisted reference attachments. Fetched off // the injected attachments client (mirrors @startsimpli/hooks `useAttachments` // — the surface takes react-query as a peer, so keep the query inline rather // than couple this package to @startsimpli/hooks). Disabled until a real // documentId + client exist. Hydrated onto the earliest user message when the // conversation payload didn't already carry them, so they reappear on reopen. const { data: priorAttachments } = useQuery({ queryKey: ['attachments', 'list', documentId ?? ''], queryFn: () => attachments!.listAttachments(documentId!), enabled: !!attachments && !!documentId && documentId !== 'new', }) const messagesWithAttachments = React.useMemo( () => hydratePriorAttachments(messages, priorAttachments), [messages, priorAttachments] ) // Compact display of the deck's derived style: theme + any non-"auto" palette/ // font. "auto" is the placeholder default, so it's dropped to avoid noise. // Style values can be anything from a short label ("Modern Professional") to a // multi-sentence prose spec (when cloned from a reference image via vision), so // each bit is hard-truncated to a glanceable phrase and the chip is width- // capped + single-line — the full spec lives in the hover title. const shortBit = (v: string) => { const firstPhrase = v.split(/[.;,—]/)[0].trim() const s = (firstPhrase || v).trim() return s.length > 28 ? `${s.slice(0, 28).trimEnd()}…` : s } const rawBits = deckStyle ? [deckStyle.theme, deckStyle.palette, deckStyle.font] .filter((v): v is string => !!v && v !== 'auto') .filter((v, i, a) => a.indexOf(v) === i) : [] const styleLabel = rawBits.map(shortBit).join(' · ') const styleTitle = [deckStyle?.theme, deckStyle?.palette, deckStyle?.font, deckStyle?.mood] .filter((v): v is string => !!v && v !== 'auto') .join('\n\n') const styleChip = deckStyle && (rawBits.length > 0 || deckStyle.source === 'reference') && (
{deckStyle.source === 'reference' ? (
) const toolbar = (
{backHref && ( )}
{styleChip} {pptxUrl && ( PPTX )} {pdfUrl && ( PDF )}
) const chat = (
Your conversation will appear here.} />
) const deck = (