import type { ComponentContext, ComponentRenderer } from "./registry"; import type { PersonaArtifactFileMeta, PersonaArtifactRecord } from "../types"; import { fileTypeLabel, basenameOf } from "../utils/artifact-file"; import { applyArtifactStatus, clearArtifactStatusTracking, resolveArtifactStatusLabel } from "../utils/artifact-status-label"; import { createLabelButton } from "../utils/buttons"; import { buildArtifactActionButton } from "../utils/artifact-custom-actions"; import type { PersonaArtifactActionContext } from "../types"; /** * Default artifact card renderer. * Builds the compact clickable card shown in the chat thread. */ function renderDefaultArtifactCard( props: Record, context: ComponentContext ): HTMLElement { const file = props.file && typeof props.file === "object" && !Array.isArray(props.file) ? (props.file as PersonaArtifactFileMeta) : undefined; const rawTitle = typeof props.title === "string" && props.title ? props.title : "Untitled artifact"; // File artifacts show the basename (title stays the full path on the wire). const title = file ? basenameOf(file.path) : rawTitle; const artifactId = typeof props.artifactId === "string" ? props.artifactId : ""; const status = props.status === "streaming" ? "streaming" : "complete"; const artifactType = typeof props.artifactType === "string" ? props.artifactType : "markdown"; const subtitle = file ? fileTypeLabel(file) : artifactType === "component" ? "Component" : "Document"; const root = document.createElement("div"); root.className = "persona-artifact-card persona-flex persona-w-full persona-max-w-full persona-items-center persona-gap-3 persona-px-4 persona-py-3"; root.tabIndex = 0; root.setAttribute("role", "button"); root.setAttribute("aria-label", `Open ${title} in artifact panel`); if (artifactId) { root.setAttribute("data-open-artifact", artifactId); } // Document icon const iconBox = document.createElement("div"); iconBox.className = "persona-flex persona-h-10 persona-w-10 persona-flex-shrink-0 persona-items-center persona-justify-center persona-rounded-lg"; iconBox.style.border = "1px solid var(--persona-border, #e5e7eb)"; iconBox.style.color = "var(--persona-muted, #9ca3af)"; iconBox.innerHTML = ``; // Title and subtitle const meta = document.createElement("div"); meta.className = "persona-min-w-0 persona-flex-1 persona-flex persona-flex-col persona-gap-0.5"; const titleEl = document.createElement("div"); titleEl.className = "persona-truncate persona-text-sm persona-font-medium"; titleEl.style.color = "var(--persona-text, #1f2937)"; titleEl.textContent = title; const subtitleEl = document.createElement("div"); subtitleEl.className = "persona-text-xs persona-flex persona-items-center persona-gap-1.5"; subtitleEl.style.color = "var(--persona-muted, #9ca3af)"; if (status === "streaming") { const artifactsCfg = context?.config?.features?.artifacts; // The card re-renders only at streaming-start and completion (deltas flow to // the pane / inline block, not here), so live counters don't tick on the // card; the string / label form still applies. Build a minimal record from // the card props so the shared resolver derives the same typeLabel + ctx. const record: PersonaArtifactRecord = { id: artifactId, artifactType: artifactType === "component" ? "component" : "markdown", title: rawTitle, status: "streaming", ...(typeof props.markdown === "string" ? { markdown: props.markdown } : {}), ...(file ? { file } : {}) }; const resolved = resolveArtifactStatusLabel(record, artifactsCfg, "card"); applyArtifactStatus(subtitleEl, resolved, artifactsCfg); } else { if (artifactId) clearArtifactStatusTracking(artifactId); subtitleEl.textContent = subtitle; } meta.append(titleEl, subtitleEl); root.append(iconBox, meta); // Custom actions + Download button render only on complete artifacts, same // as Download: content-dependent actions during streaming would act on // partial content, so they wait until the artifact is fully materialized. if (status === "complete") { // Custom card actions carry NO direct listeners (the card re-renders during // streaming and is morphed by idiomorph). Clicks are handled by event // delegation in ui.ts, keyed off data-artifact-custom-action, mirroring the // Download button's data-download-artifact delegation. const cardActions = context?.config?.features?.artifacts?.cardActions; if (cardActions && cardActions.length > 0) { const ctx: PersonaArtifactActionContext = { artifactId: artifactId || null, title, artifactType, markdown: typeof props.markdown === "string" ? props.markdown : undefined, file, }; for (const action of cardActions) { try { if (action.visible === undefined || action.visible(ctx)) { const btn = buildArtifactActionButton(action); btn.setAttribute("data-artifact-custom-action", action.id); btn.className = `${btn.className} persona-flex-shrink-0`; root.append(btn); } } catch { // A single bad action must not take down the whole card. } } } const dl = createLabelButton({ label: "Download", className: "persona-flex-shrink-0", }); dl.title = `Download ${title}`; dl.setAttribute("data-download-artifact", artifactId); root.append(dl); } return root; } /** * Built-in artifact reference card component. * Renders a compact clickable card in the chat thread that links to an artifact. * Uses `data-open-artifact` attribute for click delegation (handled in ui.ts). * * Supports a custom `renderCard` callback via `config.features.artifacts.renderCard` * that can override the default card rendering. */ export const PersonaArtifactCard: ComponentRenderer = (props, context) => { const customRenderer = context?.config?.features?.artifacts?.renderCard; if (customRenderer) { const title = typeof props.title === "string" && props.title ? props.title : "Untitled artifact"; const artifactId = typeof props.artifactId === "string" ? props.artifactId : ""; const status = props.status === "streaming" ? "streaming" : "complete"; const artifactType = typeof props.artifactType === "string" ? props.artifactType : "markdown"; const result = customRenderer({ artifact: { artifactId, title, artifactType, status }, config: context.config, defaultRenderer: () => renderDefaultArtifactCard(props, context), }); if (result) return result; } return renderDefaultArtifactCard(props, context); };