'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') && (