From 236b1b56cdd18da14bc1015233e117b03003e8f1 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 26 Jul 2026 19:29:25 -0700 Subject: [PATCH 1/2] =?UTF-8?q?feat(desktop):=20artifacts=20=E2=80=94=20ve?= =?UTF-8?q?rsioned=20cards,=20sandboxed=20live=20preview,=20right-rail=20v?= =?UTF-8?q?iewer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Substantial generated content (full HTML documents, large SVGs, long code) now promotes out of the transcript into versioned, openable artifacts: - lib/artifact-detect.ts: pure detection over fenced blocks — html docs, large standalone svg, and 48+ line / 3k+ char code fences become artifacts; prose/terminal/mermaid fences and small snippets never do. Titles derive from /<h1>, filename comments, or declarations. - store/artifacts.ts: per-session registry with content-hash dedupe and version history (same kind+title = one artifact the model iterates on), persisted to localStorage with per-session/per-artifact/byte caps. - ArtifactCard (transcript): compact openable card replaces the wall of code; streaming shows shimmer + line count; versions accumulate automatically on completion but the rail only opens on click (offer, don't hijack). Reasoning scratchpads never register. - ArtifactPane (right rail): artifact: tabs beside preview/file tabs with version stepper (v2 of 3 / Latest), PREVIEW/SOURCE toggle, copy, download (kind-aware extension), open-in-browser for HTML. - Rendering: HTML runs in a sandbox=allow-scripts iframe (opaque origin, no network to the app, no top-nav); SVG is DOMPurify-sanitized with the same profile as the inline embed; source view reuses the windowed Shiki renderer so 5k-line artifacts scroll smoothly. - Rail integration: artifact tabs participate in tab order, close-others/ close-to-right, ⌘W, pane visibility, and reveal; gateway switches close open artifact tabs; a persisted artifact: active-tab id reconciles to preview on boot (artifact tabs are ephemeral). - i18n: en/zh/zh-hant/ja/ar strings for card + pane. - session-ref-open.test.tsx: mock now spreads the real module — the artifact card imports session-view, which needs $sessionStates. Tests: artifact-detect (15), artifacts store (12), markdown-pipeline integration (3); full desktop suite 3128 passed, electron project 751 passed, tsc no new errors. --- apps/desktop/src/app/chat/close-tab.ts | 3 +- .../src/app/chat/right-rail/artifact-pane.tsx | 222 ++++++++++ .../chat/right-rail/artifact-renderers.tsx | 104 +++++ .../src/app/chat/right-rail/preview.tsx | 69 +++- apps/desktop/src/app/contrib/controller.tsx | 21 +- apps/desktop/src/app/contrib/panes.tsx | 4 +- .../components/assistant-ui/artifact-card.tsx | 145 +++++++ .../markdown-text.artifacts.test.tsx | 74 ++++ .../components/assistant-ui/markdown-text.tsx | 44 +- .../assistant-ui/session-ref-open.test.tsx | 3 +- .../assistant-ui/thread/message-parts.tsx | 1 + apps/desktop/src/i18n/ar.ts | 23 ++ apps/desktop/src/i18n/en.ts | 23 ++ apps/desktop/src/i18n/ja.ts | 23 ++ apps/desktop/src/i18n/types.ts | 23 ++ apps/desktop/src/i18n/zh-hant.ts | 23 ++ apps/desktop/src/i18n/zh.ts | 23 ++ apps/desktop/src/lib/artifact-detect.test.ts | 121 ++++++ apps/desktop/src/lib/artifact-detect.ts | 232 +++++++++++ apps/desktop/src/lib/download-text.ts | 16 + apps/desktop/src/store/artifacts.test.ts | 170 ++++++++ apps/desktop/src/store/artifacts.ts | 386 ++++++++++++++++++ apps/desktop/src/store/gateway-switch.ts | 5 + apps/desktop/src/store/layout.ts | 2 +- apps/desktop/src/store/preview.ts | 50 ++- 25 files changed, 1763 insertions(+), 47 deletions(-) create mode 100644 apps/desktop/src/app/chat/right-rail/artifact-pane.tsx create mode 100644 apps/desktop/src/app/chat/right-rail/artifact-renderers.tsx create mode 100644 apps/desktop/src/components/assistant-ui/artifact-card.tsx create mode 100644 apps/desktop/src/components/assistant-ui/markdown-text.artifacts.test.tsx create mode 100644 apps/desktop/src/lib/artifact-detect.test.ts create mode 100644 apps/desktop/src/lib/artifact-detect.ts create mode 100644 apps/desktop/src/lib/download-text.ts create mode 100644 apps/desktop/src/store/artifacts.test.ts create mode 100644 apps/desktop/src/store/artifacts.ts diff --git a/apps/desktop/src/app/chat/close-tab.ts b/apps/desktop/src/app/chat/close-tab.ts index 098e59108aea..b5e15dd30cf1 100644 --- a/apps/desktop/src/app/chat/close-tab.ts +++ b/apps/desktop/src/app/chat/close-tab.ts @@ -1,6 +1,7 @@ import { closeActiveTerminal } from '@/app/right-sidebar/terminal/terminals' import { closeWorkspaceTab } from '@/components/pane-shell/tree/store' import { isFocusWithin } from '@/lib/keybinds/combo' +import { $artifactTabs } from '@/store/artifacts' import { $filePreviewTabs, $previewTarget, closeActiveRightRailTab } from '@/store/preview' import { closeSessionTile, nextSessionTileForWorkspace } from '@/store/session-states' @@ -32,7 +33,7 @@ export function closeActiveTab(loadSessionIntoWorkspace?: (storedSessionId: stri // file tabs remain (the rail UI falls back to tabs[0]). Gating only on // `$filePreviewTarget` made ⌘W fall through to closeWorkspaceTab() and look // broken with a file tab still on screen. - if ($previewTarget.get() || $filePreviewTabs.get().length > 0) { + if ($previewTarget.get() || $filePreviewTabs.get().length > 0 || $artifactTabs.get().length > 0) { return closeActiveRightRailTab() } diff --git a/apps/desktop/src/app/chat/right-rail/artifact-pane.tsx b/apps/desktop/src/app/chat/right-rail/artifact-pane.tsx new file mode 100644 index 000000000000..c463ee84c41a --- /dev/null +++ b/apps/desktop/src/app/chat/right-rail/artifact-pane.tsx @@ -0,0 +1,222 @@ +import { useStore } from '@nanostores/react' +import { useEffect, useMemo, useState } from 'react' + +import { CopyButton } from '@/components/ui/copy-button' +import { Tip } from '@/components/ui/tooltip' +import { useI18n } from '@/i18n' +import { artifactDownloadName } from '@/lib/artifact-detect' +import { downloadTextFile } from '@/lib/download-text' +import { ChevronLeft, ChevronRight, Download, ExternalLink } from '@/lib/icons' +import { cn } from '@/lib/utils' +import { + $artifactRegistry, + $artifactVersionSelection, + type ArtifactRecord, + selectArtifactVersion +} from '@/store/artifacts' +import { notifyError } from '@/store/notifications' + +import { ArtifactLivePreview, ArtifactSourceView, composeArtifactHtml } from './artifact-renderers' +import { PreviewEmptyState } from './preview-file' + +type ArtifactViewMode = 'preview' | 'source' + +const MIME_BY_KIND = { code: 'text/plain', html: 'text/html', svg: 'image/svg+xml' } as const + +const HEADER_BUTTON_CLASS = + 'flex h-5 items-center gap-1 rounded-md px-1 text-[0.625rem] font-bold text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-40' + +/** Write the composed document to a real temp file through the existing + * buffer-save IPC, then hand it to the OS browser. A blob/data URL can't + * cross into the OS default browser, so a file on disk is the honest path. */ +async function openHtmlInBrowser(content: string): Promise<void> { + const bridge = window.hermesDesktop + + if (!bridge?.saveImageBuffer || !bridge.openExternal) { + throw new Error('Desktop bridge unavailable') + } + + const bytes = new TextEncoder().encode(composeArtifactHtml(content)) + const path = await bridge.saveImageBuffer(bytes, '.html') + + if (!path) { + throw new Error('Could not write artifact file') + } + + const fileUrl = `file://${path.startsWith('/') ? '' : '/'}${path.replace(/\\/g, '/')}` + + if (bridge.openPreviewInBrowser) { + await bridge.openPreviewInBrowser(fileUrl) + + return + } + + await bridge.openExternal(fileUrl) +} + +function VersionStepper({ + current, + onSelect, + total +}: { + current: number + onSelect: (index: number) => void + total: number +}) { + const { t } = useI18n() + const copy = t.artifactPane + + if (total < 2) { + return null + } + + return ( + <div className="flex items-center gap-0.5 text-[0.625rem] font-bold text-muted-foreground"> + <Tip label={copy.olderVersion}> + <button + aria-label={copy.olderVersion} + className={HEADER_BUTTON_CLASS} + disabled={current === 0} + onClick={() => onSelect(current - 1)} + type="button" + > + <ChevronLeft className="size-3" /> + </button> + </Tip> + <span className="tabular-nums">{copy.versionOf(current + 1, total)}</span> + <Tip label={copy.newerVersion}> + <button + aria-label={copy.newerVersion} + className={HEADER_BUTTON_CLASS} + disabled={current === total - 1} + onClick={() => onSelect(current + 1)} + type="button" + > + <ChevronRight className="size-3" /> + </button> + </Tip> + </div> + ) +} + +export function ArtifactPane({ artifactId }: { artifactId: string }) { + const { t } = useI18n() + const copy = t.artifactPane + const registry = useStore($artifactRegistry) + const versionSelection = useStore($artifactVersionSelection) + // View mode is per-pane, ephemeral: renderable artifacts open in preview. + const [userMode, setUserMode] = useState<ArtifactViewMode | null>(null) + + // Reset the explicit mode when the pane is reused for another artifact. + useEffect(() => { + setUserMode(null) + }, [artifactId]) + + const record = useMemo<ArtifactRecord | null>(() => { + for (const records of Object.values(registry)) { + const found = records.find(candidate => candidate.id === artifactId) + + if (found) { + return found + } + } + + return null + }, [artifactId, registry]) + + if (!record) { + return <PreviewEmptyState body={copy.missingBody} title={copy.missingTitle} /> + } + + const isRenderable = record.kind === 'html' || record.kind === 'svg' + const versionIndex = Math.min(versionSelection[artifactId] ?? record.versions.length - 1, record.versions.length - 1) + const version = record.versions[versionIndex]! + const isCurrentVersion = versionIndex >= record.versions.length - 1 + const mode: ArtifactViewMode = isRenderable ? (userMode ?? 'preview') : 'source' + const downloadName = artifactDownloadName(record.kind, record.language, record.title) + + const modeLabel: Record<ArtifactViewMode, string> = { + preview: copy.modePreview, + source: copy.modeSource + } + + return ( + <div className="flex h-full flex-col overflow-hidden bg-transparent"> + <div className="flex h-7 shrink-0 items-center gap-3 border-b border-border/40 px-3"> + <div className="flex min-w-0 flex-1 items-center gap-2"> + <VersionStepper + current={versionIndex} + onSelect={index => selectArtifactVersion(artifactId, index)} + total={record.versions.length} + /> + {!isCurrentVersion && ( + <button + className="text-[0.625rem] font-bold text-muted-foreground underline decoration-current/25 underline-offset-4 transition-colors hover:text-foreground" + onClick={() => selectArtifactVersion(artifactId, record.versions.length - 1)} + type="button" + > + {copy.latest} + </button> + )} + </div> + {isRenderable && + (['preview', 'source'] as const).map(candidate => ( + <button + className={cn( + 'text-[0.625rem] font-bold underline-offset-4 transition-colors', + candidate === mode + ? 'text-foreground underline decoration-current/30' + : 'text-muted-foreground hover:text-foreground' + )} + key={candidate} + onClick={() => setUserMode(candidate)} + type="button" + > + {modeLabel[candidate]} + </button> + ))} + <div className="flex items-center gap-0.5"> + <CopyButton + appearance="inline" + className="h-5 px-1 opacity-70 hover:opacity-100" + iconClassName="size-3" + label={copy.copyContent} + showLabel={false} + text={version.content} + /> + <Tip label={copy.download}> + <button + aria-label={copy.download} + className={HEADER_BUTTON_CLASS} + onClick={() => downloadTextFile(downloadName, version.content, MIME_BY_KIND[record.kind])} + type="button" + > + <Download className="size-3" /> + </button> + </Tip> + {record.kind === 'html' && window.hermesDesktop && ( + <Tip label={copy.openInBrowser}> + <button + aria-label={copy.openInBrowser} + className={HEADER_BUTTON_CLASS} + onClick={() => + void openHtmlInBrowser(version.content).catch(error => notifyError(error, copy.openInBrowserFailed)) + } + type="button" + > + <ExternalLink className="size-3" /> + </button> + </Tip> + )} + </div> + </div> + <div className="min-h-0 flex-1 overflow-hidden"> + {mode === 'preview' && isRenderable ? ( + <ArtifactLivePreview content={version.content} kind={record.kind} title={record.title} /> + ) : ( + <ArtifactSourceView language={record.kind === 'html' ? 'html' : record.language} text={version.content} /> + )} + </div> + </div> + ) +} diff --git a/apps/desktop/src/app/chat/right-rail/artifact-renderers.tsx b/apps/desktop/src/app/chat/right-rail/artifact-renderers.tsx new file mode 100644 index 000000000000..61b49809265a --- /dev/null +++ b/apps/desktop/src/app/chat/right-rail/artifact-renderers.tsx @@ -0,0 +1,104 @@ +import DOMPurify from 'dompurify' +import { useMemo } from 'react' +import ShikiHighlighter from 'react-shiki' + +import { chunkTextLines, useFixedRowWindow } from '@/components/chat/fixed-row-window' +import type { ArtifactKind } from '@/lib/artifact-detect' + +const SHIKI_THEME = { dark: 'github-dark-default', light: 'github-light-default' } as const +const SOURCE_CHUNK_LINES = 200 +const SOURCE_LINE_PX = 20 +const SOURCE_OVERSCAN_LINES = 400 + +/** Windowed, Shiki-highlighted source view for artifact content. Same fixed-row + * windowing as the file preview's SourceView so a 5k-line artifact scrolls + * smoothly, minus the gutter drag/selection machinery (artifact content has no + * on-disk path to reference lines against). */ +export function ArtifactSourceView({ language, text }: { language: string; text: string }) { + const chunks = useMemo(() => chunkTextLines(text, SOURCE_CHUNK_LINES), [text]) + const lastChunk = chunks.at(-1) + const totalLines = lastChunk ? lastChunk.start + lastChunk.lines.length : 0 + + const { afterRows, beforeRows, endChunk, onScroll, scrollerRef, startChunk } = useFixedRowWindow({ + overscanRows: SOURCE_OVERSCAN_LINES, + rowPx: SOURCE_LINE_PX, + rowsPerChunk: SOURCE_CHUNK_LINES, + totalRows: totalLines + }) + + const visibleChunks = chunks.slice(startChunk, endChunk + 1) + + return ( + <div className="h-full overflow-auto" onScroll={onScroll} ref={scrollerRef}> + <div className="min-w-max px-3 py-2 font-mono text-[0.7rem] leading-relaxed" data-selectable-text="true"> + {beforeRows > 0 && <div aria-hidden style={{ height: beforeRows * SOURCE_LINE_PX }} />} + {visibleChunks.map(chunk => ( + <div className="[&_pre]:m-0" key={chunk.start}> + <ShikiHighlighter + addDefaultStyles={false} + as="div" + defaultColor="light-dark()" + delay={80} + language={language || 'text'} + showLanguage={false} + theme={SHIKI_THEME} + > + {chunk.text} + </ShikiHighlighter> + </div> + ))} + {afterRows > 0 && <div aria-hidden style={{ height: afterRows * SOURCE_LINE_PX }} />} + </div> + </div> + ) +} + +/** Wrap an HTML fragment in a minimal document shell; full documents pass + * through untouched. Keeps generated fragments (no <html>/<body>) rendering + * with sane defaults instead of quirks-mode soup. */ +export function composeArtifactHtml(content: string): string { + if (/<html[\s>]|<!doctype\s+html/i.test(content)) { + return content + } + + return [ + '<!doctype html>', + '<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">', + '<style>body{margin:0;font-family:system-ui,sans-serif}</style></head><body>', + content, + '</body></html>' + ].join('\n') +} + +/** + * Sandboxed live renderer for html/svg artifact content. + * + * HTML runs in an `<iframe sandbox="allow-scripts">` — scripts execute in an + * opaque origin with no same-origin access, no top navigation, no popups, no + * form submission out of the frame. The parent app is unreachable. SVG is + * DOMPurify-sanitized with the same profile as the inline ```svg embed. + */ +export function ArtifactLivePreview({ content, kind, title }: { content: string; kind: ArtifactKind; title: string }) { + const svgClean = useMemo( + () => (kind === 'svg' ? DOMPurify.sanitize(content, { USE_PROFILES: { svg: true, svgFilters: true } }) : ''), + [content, kind] + ) + + if (kind === 'svg') { + return ( + <div className="grid h-full place-items-center overflow-auto bg-background p-4 [&_svg]:h-auto [&_svg]:max-h-full [&_svg]:w-auto [&_svg]:max-w-full"> + <div dangerouslySetInnerHTML={{ __html: svgClean }} /> + </div> + ) + } + + return ( + <iframe + className="block size-full border-0 bg-white" + sandbox="allow-scripts" + srcDoc={composeArtifactHtml(content)} + style={{ colorScheme: 'light' }} + title={title} + /> + ) +} diff --git a/apps/desktop/src/app/chat/right-rail/preview.tsx b/apps/desktop/src/app/chat/right-rail/preview.tsx index 31fc5f6d6a74..219a28aacfc8 100644 --- a/apps/desktop/src/app/chat/right-rail/preview.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview.tsx @@ -15,6 +15,7 @@ import { Tip } from '@/components/ui/tooltip' import { translateNow, useI18n } from '@/i18n' import { formatCombo } from '@/lib/keybinds/combo' import { cn } from '@/lib/utils' +import { $openArtifacts, artifactIdFromTabId } from '@/store/artifacts' import { $panesFlipped, $rightRailActiveTabId, @@ -34,6 +35,7 @@ import { } from '@/store/preview' import { $dirtyPreviewUrls } from '@/store/preview-edit' +import { ArtifactPane } from './artifact-pane' import { PreviewPane } from './preview-pane' export const PREVIEW_RAIL_MIN_WIDTH = '18rem' @@ -44,11 +46,9 @@ interface ChatPreviewRailProps { setTitlebarToolGroup?: SetTitlebarToolGroup } -interface RailTab { - id: RightRailTabId - label: string - target: PreviewTarget -} +type RailTab = + | { id: RightRailTabId; kind: 'preview'; label: string; target: PreviewTarget; tooltip: string } + | { artifactId: string; id: RightRailTabId; kind: 'artifact'; label: string; tooltip: string } function tabLabelFor(target: PreviewTarget): string { const value = target.label || target.path || target.source || target.url @@ -65,15 +65,43 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP const filePreviewTabs = useStore($filePreviewTabs) const previewTarget = useStore($previewTarget) const dirtyPreviewUrls = useStore($dirtyPreviewUrls) + const openArtifacts = useStore($openArtifacts) const tabs = useMemo<readonly RailTab[]>( () => [ ...(previewTarget - ? [{ id: RIGHT_RAIL_PREVIEW_TAB_ID, label: t.preview.tab, target: previewTarget } as RailTab] + ? [ + { + id: RIGHT_RAIL_PREVIEW_TAB_ID, + kind: 'preview', + label: t.preview.tab, + target: previewTarget, + tooltip: previewTarget.path || previewTarget.url || t.preview.tab + } as RailTab + ] : []), - ...filePreviewTabs.map(({ id, target }) => ({ id, label: tabLabelFor(target), target }) as RailTab) + ...filePreviewTabs.map( + ({ id, target }) => + ({ + id, + kind: 'preview', + label: tabLabelFor(target), + target, + tooltip: target.path || target.url || tabLabelFor(target) + }) as RailTab + ), + ...openArtifacts.map( + ({ record, tabId }) => + ({ + artifactId: record.id, + id: tabId, + kind: 'artifact', + label: record.title || t.artifactPane.tabFallback, + tooltip: record.title || t.artifactPane.tabFallback + }) as RailTab + ) ], - [filePreviewTabs, previewTarget, t.preview.tab] + [filePreviewTabs, openArtifacts, previewTarget, t.artifactPane.tabFallback, t.preview.tab] ) const activeTab = tabs.find(tab => tab.id === activeTabId) ?? tabs[0] @@ -117,13 +145,13 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP const active = tab.id === activeTab.id const hasOthers = tabs.length > 1 const hasTabsToRight = index < tabs.length - 1 - const dirty = Boolean(dirtyPreviewUrls[tab.target.url]) + const dirty = tab.kind === 'preview' && Boolean(dirtyPreviewUrls[tab.target.url]) return ( <ContextMenu key={tab.id}> <ContextMenuTrigger asChild> <PaneTab active={active} dirty={dirty} onClose={() => closeRightRailTab(tab.id)}> - <Tip label={tab.target.path || tab.target.url || tab.label}> + <Tip label={tab.tooltip}> <PaneTabLabel aria-selected={active} as="button" @@ -132,6 +160,9 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP role="tab" type="button" > + {tab.kind === 'artifact' && ( + <Codicon className="mr-1 shrink-0 text-[0.6875rem] opacity-70" name="sparkle" /> + )} {tab.label} </PaneTabLabel> </Tip> @@ -166,13 +197,17 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP </div> <div className="min-h-0 flex-1 overflow-hidden"> - <PreviewPane - embedded - onRestartServer={isPreview ? onRestartServer : undefined} - reloadRequest={previewReloadRequest} - setTitlebarToolGroup={setTitlebarToolGroup} - target={activeTab.target} - /> + {activeTab.kind === 'artifact' ? ( + <ArtifactPane artifactId={artifactIdFromTabId(activeTab.id) ?? activeTab.artifactId} /> + ) : ( + <PreviewPane + embedded + onRestartServer={isPreview ? onRestartServer : undefined} + reloadRequest={previewReloadRequest} + setTitlebarToolGroup={setTitlebarToolGroup} + target={activeTab.target} + /> + )} </div> </aside> ) diff --git a/apps/desktop/src/app/contrib/controller.tsx b/apps/desktop/src/app/contrib/controller.tsx index 3c245478bb09..8f486accb56d 100644 --- a/apps/desktop/src/app/contrib/controller.tsx +++ b/apps/desktop/src/app/contrib/controller.tsx @@ -39,6 +39,7 @@ import { sessionTitle as storedSessionTitle } from '@/lib/chat-runtime' import { LayoutDashboard } from '@/lib/icons' import { type KeybindContribution, KEYBINDS_AREA } from '@/lib/keybinds/actions' import { Codecs, persistentAtom } from '@/lib/persisted' +import { $artifactTabs } from '@/store/artifacts' import { $fileBrowserOpen, $panesFlipped, @@ -51,7 +52,7 @@ import { SIDEBAR_DEFAULT_WIDTH, SIDEBAR_MAX_WIDTH } from '@/store/layout' -import { $filePreviewTarget, $previewTarget, closeRightRail } from '@/store/preview' +import { $filePreviewTabs, $filePreviewTarget, $previewTarget, closeRightRail } from '@/store/preview' import { $reviewOpen, closeReview, REVIEW_PANE_ID } from '@/store/review' import { $currentCwd, $selectedStoredSessionId, $sessions, sessionMatchesStoredId } from '@/store/session' import { watchSessionPins } from '@/store/session-pin-sync' @@ -552,9 +553,10 @@ bindPaneCollapse( // Preview EXISTS only while something is previewed (old-shell semantics: // closing the last preview tab closes the pane; a new target opens + fronts // it). Same visibility binding as every other self-managed surface, driven -// by the live targets instead of a toggle. -const $previewVisible = computed([$previewTarget, $filePreviewTarget], (target, fileTarget) => - Boolean(target || fileTarget) +// by the live targets (and open artifact tabs) instead of a toggle. +const $previewVisible = computed( + [$previewTarget, $filePreviewTabs, $artifactTabs], + (target, fileTabs, artifactTabs) => Boolean(target) || fileTabs.length > 0 || artifactTabs.length > 0 ) bindPaneVisibility('preview', $previewVisible, closeRightRail) @@ -603,6 +605,17 @@ const revealPreview = () => { $previewTarget.listen(target => target && revealPreview()) $filePreviewTarget.listen(target => target && revealPreview()) +// Artifact reveal keys on tab OPENS (length grows), not list identity — closing +// one of two artifact tabs must not re-front the pane. +let lastArtifactTabCount = $artifactTabs.get().length +$artifactTabs.listen(tabs => { + const grew = tabs.length > lastArtifactTabCount + lastArtifactTabCount = tabs.length + + if (grew) { + revealPreview() + } +}) // --------------------------------------------------------------------------- diff --git a/apps/desktop/src/app/contrib/panes.tsx b/apps/desktop/src/app/contrib/panes.tsx index 484b1025eddd..21c18d358f17 100644 --- a/apps/desktop/src/app/contrib/panes.tsx +++ b/apps/desktop/src/app/contrib/panes.tsx @@ -27,6 +27,7 @@ import { registry } from '@/contrib/registry' import { getLogs } from '@/hermes' import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview' import { cn } from '@/lib/utils' +import { $openArtifacts } from '@/store/artifacts' import { $filePreviewTarget, $previewTarget, setCurrentSessionPreviewTarget } from '@/store/preview' import { $currentCwd } from '@/store/session' @@ -74,9 +75,10 @@ export const $restartPreviewServer = atom<((url: string, context?: string) => Pr export function PreviewRailPane() { const previewTarget = useStore($previewTarget) const fileTarget = useStore($filePreviewTarget) + const openArtifacts = useStore($openArtifacts) const restartPreviewServer = useStore($restartPreviewServer) - if (!previewTarget && !fileTarget) { + if (!previewTarget && !fileTarget && openArtifacts.length === 0) { return ( <div className="grid h-full place-items-center px-4 text-center"> <div className="flex flex-col items-center gap-1.5"> diff --git a/apps/desktop/src/components/assistant-ui/artifact-card.tsx b/apps/desktop/src/components/assistant-ui/artifact-card.tsx new file mode 100644 index 000000000000..7444c23c05f0 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/artifact-card.tsx @@ -0,0 +1,145 @@ +'use client' + +import { useStore } from '@nanostores/react' +import { useEffect, useMemo } from 'react' + +import { useSessionView } from '@/app/chat/session-view' +import { CodeCardIcon } from '@/components/chat/code-card' +import { useI18n } from '@/i18n' +import type { ArtifactDetection } from '@/lib/artifact-detect' +import { codiconForLanguage } from '@/lib/markdown-code' +import { cn } from '@/lib/utils' +import { + $artifactRegistry, + artifactsForSession, + openArtifactTab, + selectArtifactVersion, + upsertArtifact +} from '@/store/artifacts' + +interface ArtifactCardProps { + code: string + detection: ArtifactDetection + streaming?: boolean +} + +const KIND_ICON: Record<ArtifactDetection['kind'], string> = { + code: 'code', + html: 'browser', + svg: 'symbol-color' +} + +function detectionIcon(detection: ArtifactDetection): string { + return detection.kind === 'code' ? codiconForLanguage(detection.language) : KIND_ICON[detection.kind] +} + +/** + * Transcript stand-in for a fenced block that was promoted to an artifact. + * Replaces the wall of code with a compact, openable card: icon, title, kind, + * version badge. While the fence is still streaming + * it shows a shimmer + line count instead of the growing source. + * + * Registration is automatic on completion (so version history accumulates + * even if the user never opens the card) but opening the rail is strictly + * click-driven — a background stream never steals the pane. + */ +export function ArtifactCard({ code, detection, streaming = false }: ArtifactCardProps) { + const { t } = useI18n() + const copy = t.artifactCard + const view = useSessionView() + const runtimeId = useStore(view.$runtimeId) + const storedId = useStore(view.$storedId) + const registry = useStore($artifactRegistry) + const sessionId = storedId || runtimeId || '' + + const trimmed = code.trim() + + // Register/version the artifact once its fence has finished streaming. + // upsertArtifact dedupes on content hash, so re-renders and transcript + // replays are no-ops. + useEffect(() => { + if (!streaming && sessionId && trimmed) { + upsertArtifact(sessionId, detection, trimmed) + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- detection derives from code + }, [detection.kind, detection.language, detection.title, sessionId, streaming, trimmed]) + + const record = useMemo(() => { + void registry + + const slugMatch = artifactsForSession(sessionId).find( + candidate => candidate.kind === detection.kind && candidate.versions.some(v => v.content === trimmed) + ) + + return slugMatch ?? null + }, [detection.kind, registry, sessionId, trimmed]) + + const lineCount = useMemo(() => trimmed.split('\n').length, [trimmed]) + const kindLabel = copy.kind[detection.kind] + const versionCount = record?.versions.length ?? 0 + const title = (record?.title || detection.title || kindLabel).trim() || kindLabel + + const open = () => { + if (streaming || !sessionId || !trimmed) { + return + } + + // Ensure the registry row exists even if the completion effect hasn't + // fired yet (e.g. clicked in the same frame the stream sealed). + const result = upsertArtifact(sessionId, detection, trimmed) + + if (!result) { + return + } + + openArtifactTab(result.artifactId) + + // An older card opens at ITS version, not silently the newest — the user + // clicked this specific iteration. openArtifactTab resets to newest, so + // re-pin when this card's content is a historical version. + const versionIndex = result.record.versions.findIndex(version => version.content === trimmed) + + if (versionIndex !== -1 && versionIndex < result.record.versions.length - 1) { + selectArtifactVersion(result.artifactId, versionIndex) + } + } + + return ( + <button + className={cn( + 'group/artifact my-1.5 flex w-full max-w-md items-center gap-2.5 overflow-hidden rounded-[0.625rem] border border-border px-3 py-2.5 text-left transition-colors', + streaming ? 'cursor-default' : 'cursor-pointer hover:border-border hover:bg-accent/40' + )} + data-slot="aui_artifact-card" + disabled={streaming} + onClick={open} + type="button" + > + <span className="grid size-8 shrink-0 place-items-center rounded-md bg-muted/55 text-muted-foreground"> + <CodeCardIcon className="text-[1rem]" name={detectionIcon(detection)} /> + </span> + <span className="min-w-0 flex-1"> + <span + className={cn( + 'block truncate text-[0.8125rem] font-medium text-foreground', + streaming && 'shimmer text-foreground/55' + )} + > + {title} + </span> + <span className="block truncate text-[0.6875rem] text-muted-foreground"> + {streaming + ? copy.generating(lineCount) + : versionCount > 1 + ? `${kindLabel} · ${copy.versionBadge(versionCount)}` + : kindLabel} + </span> + </span> + {!streaming && ( + <span className="shrink-0 text-[0.6875rem] font-medium text-muted-foreground opacity-0 transition-opacity group-hover/artifact:opacity-100"> + {copy.open} + </span> + )} + </button> + ) +} diff --git a/apps/desktop/src/components/assistant-ui/markdown-text.artifacts.test.tsx b/apps/desktop/src/components/assistant-ui/markdown-text.artifacts.test.tsx new file mode 100644 index 000000000000..5aa9b66f11ac --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/markdown-text.artifacts.test.tsx @@ -0,0 +1,74 @@ +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { $artifactTabs, artifactsForSession, clearArtifactRegistry } from '@/store/artifacts' +import { $activeSessionId, $selectedStoredSessionId } from '@/store/session' + +import { MarkdownTextContent } from './markdown-text' + +const HTML_DOC = `<!doctype html> +<html> +<head><title>Pomodoro Timer + +

Pomodoro

+

A tiny focus timer that counts down twenty-five minutes.

+ + +` + +const SMALL_SNIPPET = 'const x = 1' + +function fenced(language: string, body: string): string { + return `Here you go:\n\n\`\`\`${language}\n${body}\n\`\`\`\n` +} + +// End-to-end for the artifact path: a substantial ```html fence in assistant +// markdown must come out of preprocessMarkdown -> Streamdown -> SyntaxHighlighter +// as an artifact card (registered in the store), while small fences keep the +// plain code-card path. +describe('MarkdownTextContent artifacts', () => { + beforeEach(() => { + $activeSessionId.set('session-artifacts') + $selectedStoredSessionId.set(null) + window.localStorage.clear() + clearArtifactRegistry() + }) + + afterEach(() => { + cleanup() + $activeSessionId.set(null) + $selectedStoredSessionId.set(null) + clearArtifactRegistry() + window.localStorage.clear() + }) + + it('renders a substantial html fence as an artifact card and registers it', async () => { + render() + + const card = await screen.findByText('Pomodoro Timer') + + expect(card.closest('button')?.dataset.slot).toBe('aui_artifact-card') + expect(artifactsForSession('session-artifacts')).toHaveLength(1) + expect(artifactsForSession('session-artifacts')[0]?.kind).toBe('html') + // Registration alone must not open the rail (offer, don't hijack). + expect($artifactTabs.get()).toHaveLength(0) + }) + + it('keeps a small fence as a plain code block', async () => { + const { container } = render() + + // The code card mounts synchronously; Shiki may split tokens into spans, + // so assert on the card slots rather than text content. + expect(container.querySelector('[data-slot="code-card"]')).not.toBeNull() + expect(container.querySelector('[data-slot="aui_artifact-card"]')).toBeNull() + expect(artifactsForSession('session-artifacts')).toHaveLength(0) + }) + + it('does not register while the message is still streaming', async () => { + render() + + await screen.findByText('Pomodoro Timer') + + expect(artifactsForSession('session-artifacts')).toHaveLength(0) + }) +}) diff --git a/apps/desktop/src/components/assistant-ui/markdown-text.tsx b/apps/desktop/src/components/assistant-ui/markdown-text.tsx index f641890ffd40..f11247fbfa3f 100644 --- a/apps/desktop/src/components/assistant-ui/markdown-text.tsx +++ b/apps/desktop/src/components/assistant-ui/markdown-text.tsx @@ -14,6 +14,7 @@ import { ExpandableBlock } from '@/components/chat/expandable-block' import { PreviewAttachment } from '@/components/chat/preview-attachment' import { chunkByLines, SyntaxHighlighter } from '@/components/chat/shiki-highlighter' import { ZoomableImage } from '@/components/chat/zoomable-image' +import { detectArtifact } from '@/lib/artifact-detect' import { normalizeExternalUrl, openExternalLink, PrettyLink } from '@/lib/external-link' import { createMemoizedMathPlugin } from '@/lib/katex-memo' import { parseMarkdownIntoBlocksCached } from '@/lib/markdown-blocks' @@ -33,6 +34,7 @@ import { previewTargetFromMarkdownHref } from '@/lib/preview-targets' import { sessionRefFromMarkdownHref } from '@/lib/session-refs' import { cn } from '@/lib/utils' +import { ArtifactCard } from './artifact-card' import { SessionRefLink } from './directive-text' import { detectEmbed, extractAlert, MarkdownAlert, RichCodeBlock, UrlEmbed } from './embeds' @@ -355,6 +357,9 @@ interface MarkdownTextSurfaceProps { containerClassName?: string containerProps?: ComponentProps<'div'> defer?: boolean + /** Disable artifact-card promotion for fenced blocks (reasoning text — a + * model's scratchpad draft must not register artifact versions). */ + disableArtifacts?: boolean } // Headings shrink to chat scale rather than the prose default (h1≈xl). Kept @@ -403,7 +408,12 @@ function HugeTextFallback({ containerClassName, text }: { containerClassName?: s ) } -function MarkdownTextSurface({ containerClassName, containerProps, defer }: MarkdownTextSurfaceProps) { +function MarkdownTextSurface({ + containerClassName, + containerProps, + defer, + disableArtifacts +}: MarkdownTextSurfaceProps) { const { status, text } = useMessagePartText() const isStreaming = status.type === 'running' @@ -508,18 +518,28 @@ function MarkdownTextSurface({ containerClassName, containerProps, defer }: Mark ), img: MarkdownImage, - // ```mermaid / ```svg fences route to their lazy renderers; every other - // language falls back to the Shiki-highlighted code block. - SyntaxHighlighter: (props: SyntaxHighlighterProps) => ( - } - language={props.language} - streaming={isStreaming} - /> - ) + // ```mermaid / ```svg fences route to their lazy renderers; substantial + // html/svg/code fences promote to an artifact card that opens in the + // right rail; every other language falls back to the Shiki-highlighted + // code block. + SyntaxHighlighter: (props: SyntaxHighlighterProps) => { + const artifact = disableArtifacts ? null : detectArtifact(props.language, props.code) + + if (artifact) { + return + } + + return ( + } + language={props.language} + streaming={isStreaming} + /> + ) + } }) as StreamdownTextComponents, - [isStreaming] + [disableArtifacts, isStreaming] ) if (text.length > MAX_MARKDOWN_CHARS) { diff --git a/apps/desktop/src/components/assistant-ui/session-ref-open.test.tsx b/apps/desktop/src/components/assistant-ui/session-ref-open.test.tsx index de3674f529ca..1dad2f730eb9 100644 --- a/apps/desktop/src/components/assistant-ui/session-ref-open.test.tsx +++ b/apps/desktop/src/components/assistant-ui/session-ref-open.test.tsx @@ -8,7 +8,8 @@ import { MarkdownTextContent } from './markdown-text' const openSessionTile = vi.fn() -vi.mock('@/store/session-states', () => ({ +vi.mock('@/store/session-states', async importOriginal => ({ + ...(await importOriginal>()), openSessionTile: (...args: unknown[]) => openSessionTile(...args) })) diff --git a/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx b/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx index ac0b42423e9f..d4f9967d0bd8 100644 --- a/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx @@ -198,6 +198,7 @@ const ReasoningTextPart: ReasoningMessagePartComponent = () => { } + disableArtifacts isRunning={status.type === 'running' || messageRunning} text={text.trimStart()} /> diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index fb0c4035a51f..cb4a8d74fc0e 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -1495,6 +1495,29 @@ export const ar = defineLocale({ copyUrl: 'نسخ الرابط', copyPath: 'نسخ المسار' }, + + artifactCard: { + kind: { code: 'كود', html: 'صفحة تفاعلية', svg: 'رسم' }, + generating: lines => `جارٍ الإنشاء… ${lines} سطرًا`, + versionBadge: count => `${count} إصدارات`, + open: 'فتح' + }, + + artifactPane: { + tabFallback: 'ناتج', + modePreview: 'معاينة', + modeSource: 'المصدر', + versionOf: (current, total) => `الإصدار ${current} من ${total}`, + olderVersion: 'إصدار أقدم', + newerVersion: 'إصدار أحدث', + latest: 'الأحدث', + copyContent: 'نسخ المحتوى', + download: 'تنزيل', + openInBrowser: 'فتح في المتصفح', + openInBrowserFailed: 'تعذّر الفتح في المتصفح', + missingTitle: 'الناتج غير متاح', + missingBody: 'لم يعد هذا الناتج موجودًا في السجل المحلي.' + }, sidebar: { nav: { 'new-session': 'جلسة جديدة', diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index d4005af11f7d..409faf98772a 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -1757,6 +1757,29 @@ export const en: Translations = { copyPath: 'Copy path' }, + artifactCard: { + kind: { code: 'Code', html: 'Interactive page', svg: 'Graphic' }, + generating: lines => `Generating… ${lines} lines`, + versionBadge: count => `${count} versions`, + open: 'Open' + }, + + artifactPane: { + tabFallback: 'Artifact', + modePreview: 'PREVIEW', + modeSource: 'SOURCE', + versionOf: (current, total) => `v${current} of ${total}`, + olderVersion: 'Older version', + newerVersion: 'Newer version', + latest: 'Latest', + copyContent: 'Copy content', + download: 'Download', + openInBrowser: 'Open in browser', + openInBrowserFailed: 'Could not open in browser', + missingTitle: 'Artifact unavailable', + missingBody: 'This artifact is no longer in the local registry.' + }, + sidebar: { nav: { 'new-session': 'New session', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index a1eeea21d1a9..198058eedd68 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -1621,6 +1621,29 @@ export const ja = defineLocale({ copyPath: 'パスをコピー' }, + artifactCard: { + kind: { code: 'コード', html: 'インタラクティブページ', svg: 'グラフィック' }, + generating: lines => `生成中… ${lines} 行`, + versionBadge: count => `${count} 個のバージョン`, + open: '開く' + }, + + artifactPane: { + tabFallback: 'アーティファクト', + modePreview: 'プレビュー', + modeSource: 'ソース', + versionOf: (current, total) => `${total} 中 v${current}`, + olderVersion: '前のバージョン', + newerVersion: '次のバージョン', + latest: '最新', + copyContent: 'コンテンツをコピー', + download: 'ダウンロード', + openInBrowser: 'ブラウザで開く', + openInBrowserFailed: 'ブラウザで開けませんでした', + missingTitle: 'アーティファクトを利用できません', + missingBody: 'このアーティファクトはローカルレジストリに存在しません。' + }, + sidebar: { nav: { 'new-session': '新しいセッション', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 217a66e9794a..e15db82546a1 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -1466,6 +1466,29 @@ export interface Translations { copyPath: string } + artifactCard: { + kind: Record<'code' | 'html' | 'svg', string> + generating: (lines: number) => string + versionBadge: (count: number) => string + open: string + } + + artifactPane: { + tabFallback: string + modePreview: string + modeSource: string + versionOf: (current: number, total: number) => string + olderVersion: string + newerVersion: string + latest: string + copyContent: string + download: string + openInBrowser: string + openInBrowserFailed: string + missingTitle: string + missingBody: string + } + sidebar: { nav: Record searchAria: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index dfe9efe69868..12e7a5c2c59f 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -1569,6 +1569,29 @@ export const zhHant = defineLocale({ copyPath: '複製路徑' }, + artifactCard: { + kind: { code: '程式碼', html: '互動頁面', svg: '圖形' }, + generating: lines => `產生中… ${lines} 行`, + versionBadge: count => `${count} 個版本`, + open: '開啟' + }, + + artifactPane: { + tabFallback: '產物', + modePreview: '預覽', + modeSource: '原始碼', + versionOf: (current, total) => `第 ${current}/${total} 版`, + olderVersion: '較舊版本', + newerVersion: '較新版本', + latest: '最新', + copyContent: '複製內容', + download: '下載', + openInBrowser: '在瀏覽器中開啟', + openInBrowserFailed: '無法在瀏覽器中開啟', + missingTitle: '產物無法使用', + missingBody: '此產物已不在本機註冊表中。' + }, + sidebar: { nav: { 'new-session': '新工作階段', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 518913dca952..71f033580aaf 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -1947,6 +1947,29 @@ export const zh: Translations = { copyPath: '复制路径' }, + artifactCard: { + kind: { code: '代码', html: '交互页面', svg: '图形' }, + generating: lines => `生成中… ${lines} 行`, + versionBadge: count => `${count} 个版本`, + open: '打开' + }, + + artifactPane: { + tabFallback: '产物', + modePreview: '预览', + modeSource: '源码', + versionOf: (current, total) => `第 ${current}/${total} 版`, + olderVersion: '较旧版本', + newerVersion: '较新版本', + latest: '最新', + copyContent: '复制内容', + download: '下载', + openInBrowser: '在浏览器中打开', + openInBrowserFailed: '无法在浏览器中打开', + missingTitle: '产物不可用', + missingBody: '此产物已不在本地注册表中。' + }, + sidebar: { nav: { 'new-session': '新建会话', diff --git a/apps/desktop/src/lib/artifact-detect.test.ts b/apps/desktop/src/lib/artifact-detect.test.ts new file mode 100644 index 000000000000..d4aba791299b --- /dev/null +++ b/apps/desktop/src/lib/artifact-detect.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest' + +import { artifactContentHash, artifactDownloadName, artifactSlug, detectArtifact } from './artifact-detect' + +const HTML_DOC = ` + +Pomodoro Timer + +

Pomodoro

+ + +` + +function longCode(lines: number): string { + return Array.from( + { length: lines }, + (_, i) => `export function helper${i}(value: number) { return value * ${i} }` + ).join('\n') +} + +describe('detectArtifact', () => { + it('promotes a full html document', () => { + const detection = detectArtifact('html', HTML_DOC) + + expect(detection).not.toBeNull() + expect(detection?.kind).toBe('html') + expect(detection?.title).toBe('Pomodoro Timer') + }) + + it('falls back to h1 when the document has no title tag', () => { + const doc = `

Budget Dashboard

${'
x
'.repeat(30)}` + + expect(detectArtifact('html', doc)?.title).toBe('Budget Dashboard') + }) + + it('ignores a small html snippet', () => { + expect(detectArtifact('html', '
hello
')).toBeNull() + }) + + it('ignores small svg fences (inline embed owns them)', () => { + expect(detectArtifact('svg', '')).toBeNull() + }) + + it('promotes a large svg', () => { + const svg = `Org Chart${''.repeat(80)}` + const detection = detectArtifact('svg', svg) + + expect(detection?.kind).toBe('svg') + expect(detection?.title).toBe('Org Chart') + }) + + it('keeps short code inline', () => { + expect(detectArtifact('python', 'print("hi")')).toBeNull() + }) + + it('promotes long code and derives a declaration title', () => { + const code = `export function buildDashboard(config: Config) {\n${longCode(60)}\n}` + const detection = detectArtifact('typescript', code) + + expect(detection?.kind).toBe('code') + expect(detection?.title).toBe('buildDashboard') + }) + + it('prefers a filename comment for the title', () => { + const code = `# server.py\n${longCode(60) + .replace(/export function/g, 'def') + .replace(/\{|\}/g, '')}` + + const detection = detectArtifact('python', code) + + expect(detection?.kind).toBe('code') + expect(detection?.title).toBe('server.py') + }) + + it('never promotes prose-ish or terminal fences', () => { + expect(detectArtifact('text', longCode(80))).toBeNull() + expect(detectArtifact('diff', longCode(80))).toBeNull() + expect(detectArtifact('markdown', longCode(80))).toBeNull() + expect(detectArtifact('mermaid', longCode(80))).toBeNull() + }) +}) + +describe('artifactSlug', () => { + it('is stable across regenerations of the same artifact', () => { + const a = artifactSlug({ kind: 'html', language: 'html', title: 'Pomodoro Timer' }) + const b = artifactSlug({ kind: 'html', language: 'html', title: 'Pomodoro Timer' }) + + expect(a).toBe(b) + expect(a).toContain('html') + }) + + it('distinguishes different titles', () => { + expect(artifactSlug({ kind: 'html', language: 'html', title: 'Timer' })).not.toBe( + artifactSlug({ kind: 'html', language: 'html', title: 'Dashboard' }) + ) + }) + + it('handles empty/symbol-only titles', () => { + expect(artifactSlug({ kind: 'code', language: 'ts', title: '!!!' })).toBe('code:ts:untitled') + }) +}) + +describe('artifactContentHash', () => { + it('is deterministic and content-sensitive', () => { + expect(artifactContentHash('abc')).toBe(artifactContentHash('abc')) + expect(artifactContentHash('abc')).not.toBe(artifactContentHash('abd')) + }) +}) + +describe('artifactDownloadName', () => { + it('keeps an existing extension', () => { + expect(artifactDownloadName('code', 'python', 'server.py')).toBe('server.py') + }) + + it('appends by kind and language', () => { + expect(artifactDownloadName('html', 'html', 'Pomodoro Timer')).toBe('Pomodoro-Timer.html') + expect(artifactDownloadName('svg', 'svg', 'Org Chart')).toBe('Org-Chart.svg') + expect(artifactDownloadName('code', 'typescript', 'buildDashboard')).toBe('buildDashboard.ts') + expect(artifactDownloadName('code', 'unknownlang', '')).toBe('artifact.txt') + }) +}) diff --git a/apps/desktop/src/lib/artifact-detect.ts b/apps/desktop/src/lib/artifact-detect.ts new file mode 100644 index 000000000000..464b92b6a9d4 --- /dev/null +++ b/apps/desktop/src/lib/artifact-detect.ts @@ -0,0 +1,232 @@ +import { isLikelyProseCodeBlock, sanitizeLanguageTag } from '@/lib/markdown-code' + +/** + * Artifact detection — decides when a fenced block in an assistant message is + * substantial, self-contained content that deserves an artifact card (opening + * in the right rail) instead of an inline code block. + * + * Pure and cheap: it runs per streaming delta on the growing fence body, so + * everything here is a bounded regex scan or a line count. No store access. + */ + +export type ArtifactKind = 'code' | 'html' | 'svg' + +export interface ArtifactDetection { + kind: ArtifactKind + /** Sanitized fence language ('' possible for html/svg detected by shape). */ + language: string + /** Human title derived from the content (html , svg <title>, a named + * declaration for code). Falls back to a kind/language label. */ + title: string +} + +// A fence only becomes an artifact once it is unambiguously a document (html), +// a large standalone graphic (svg), or long enough that inlining it would +// drown the conversation (code). Small snippets stay inline code cards. +const HTML_DOC_RE = /<!doctype\s+html|<html[\s>]|<head[\s>]|<body[\s>]/i +const HTML_TAG_RE = /<[a-z][a-z0-9-]*(\s[^>]*)?>/i +const HTML_DOC_MIN_CHARS = 160 +const HTML_FRAGMENT_MIN_CHARS = 1200 +const SVG_MIN_CHARS = 2000 +const CODE_MIN_LINES = 48 +const CODE_MIN_CHARS = 3000 + +const HTML_LANGUAGES = new Set(['html', 'htm', 'xhtml']) + +// Languages whose fences are never artifacts: prose-ish, terminal output, and +// the fences already owned by richer renderers (mermaid diagrams, small svg). +const NON_ARTIFACT_LANGUAGES = new Set([ + '', + 'console', + 'diff', + 'log', + 'logs', + 'markdown', + 'md', + 'mermaid', + 'output', + 'patch', + 'plain', + 'plaintext', + 'shell-session', + 'stdout', + 'text', + 'txt' +]) + +function countLines(text: string): number { + let lines = 1 + let index = text.indexOf('\n') + + while (index !== -1) { + lines += 1 + index = text.indexOf('\n', index + 1) + } + + return lines +} + +function stripTags(value: string): string { + return value + .replace(/<[^>]*>/g, ' ') + .replace(/\s+/g, ' ') + .trim() +} + +function titleFromTag(content: string, tag: 'h1' | 'title'): string { + const match = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}>`, 'i').exec(content) + + return match ? stripTags(match[1] || '').slice(0, 80) : '' +} + +const CODE_DECLARATION_RE = + /(?:^|\n)\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|class|struct|interface|enum|trait|impl|def|fn)\s+([A-Za-z_$][\w$]*)/ + +// `// app.py`, `# server.ts`, `<!-- index.html -->`, `/* main.rs */` on the +// first meaningful line — the de-facto LLM convention for naming a file. +const FILENAME_COMMENT_RE = /^\s*(?:\/\/|#|--|<!--|\/\*)\s*([\w./-]+\.[a-z0-9]{1,8})\b/i + +function codeTitle(language: string, content: string): string { + const head = content.slice(0, 2000) + const fileName = FILENAME_COMMENT_RE.exec(head)?.[1] + + if (fileName) { + return fileName + } + + const declaration = CODE_DECLARATION_RE.exec(head)?.[1] + + if (declaration) { + return declaration + } + + return language +} + +export function detectArtifact(language: string | undefined, code: string | undefined): ArtifactDetection | null { + const trimmed = (code ?? '').trim() + + if (!trimmed) { + return null + } + + const clean = sanitizeLanguageTag(language || '') + + if (HTML_LANGUAGES.has(clean)) { + const isDocument = HTML_DOC_RE.test(trimmed) + + if ( + (isDocument && trimmed.length >= HTML_DOC_MIN_CHARS) || + (!isDocument && trimmed.length >= HTML_FRAGMENT_MIN_CHARS && HTML_TAG_RE.test(trimmed)) + ) { + return { + kind: 'html', + language: clean, + title: titleFromTag(trimmed, 'title') || titleFromTag(trimmed, 'h1') || 'HTML' + } + } + + return null + } + + if (clean === 'svg') { + // Small svg fences keep their inline embed (svg-embed.tsx); only a large + // standalone graphic graduates into an artifact tab. + if (trimmed.length >= SVG_MIN_CHARS && /<svg[\s>]/i.test(trimmed)) { + return { kind: 'svg', language: clean, title: titleFromTag(trimmed, 'title') || 'SVG' } + } + + return null + } + + if (NON_ARTIFACT_LANGUAGES.has(clean)) { + return null + } + + if (trimmed.length < CODE_MIN_CHARS && countLines(trimmed) < CODE_MIN_LINES) { + return null + } + + if (isLikelyProseCodeBlock(clean, trimmed)) { + return null + } + + return { kind: 'code', language: clean, title: codeTitle(clean, trimmed) } +} + +/** Stable identity slug for versioning: the same (kind, title, language) in a + * session is treated as one artifact the model iterates on. */ +export function artifactSlug(detection: Pick<ArtifactDetection, 'kind' | 'language' | 'title'>): string { + const title = detection.title + .toLowerCase() + .replace(/[^\p{L}\p{N}]+/gu, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 48) + + return `${detection.kind}:${detection.language}:${title || 'untitled'}` +} + +/** Tiny non-cryptographic content hash (FNV-1a) for version dedupe. */ +export function artifactContentHash(content: string): string { + let hash = 0x811c9dc5 + + for (let i = 0; i < content.length; i += 1) { + hash ^= content.charCodeAt(i) + hash = Math.imul(hash, 0x01000193) + } + + return (hash >>> 0).toString(36) +} + +const DOWNLOAD_EXTENSION_BY_LANGUAGE: Record<string, string> = { + bash: '.sh', + c: '.c', + cpp: '.cpp', + csharp: '.cs', + css: '.css', + go: '.go', + htm: '.html', + html: '.html', + java: '.java', + javascript: '.js', + js: '.js', + json: '.json', + jsx: '.jsx', + kotlin: '.kt', + php: '.php', + py: '.py', + python: '.py', + rb: '.rb', + rs: '.rs', + ruby: '.rb', + rust: '.rs', + sh: '.sh', + sql: '.sql', + svg: '.svg', + swift: '.swift', + toml: '.toml', + ts: '.ts', + tsx: '.tsx', + typescript: '.ts', + xhtml: '.html', + xml: '.xml', + yaml: '.yaml', + yml: '.yaml' +} + +export function artifactDownloadName(kind: ArtifactKind, language: string, title: string): string { + const base = + title + .replace(/[^\p{L}\p{N}._ -]+/gu, '') + .trim() + .replace(/\s+/g, '-') + .slice(0, 60) || 'artifact' + + if (/\.[a-z0-9]{1,8}$/i.test(base)) { + return base + } + + const ext = kind === 'html' ? '.html' : kind === 'svg' ? '.svg' : DOWNLOAD_EXTENSION_BY_LANGUAGE[language] || '.txt' + + return `${base}${ext}` +} diff --git a/apps/desktop/src/lib/download-text.ts b/apps/desktop/src/lib/download-text.ts new file mode 100644 index 000000000000..3e9091fcd640 --- /dev/null +++ b/apps/desktop/src/lib/download-text.ts @@ -0,0 +1,16 @@ +/** Save generated text content to a file via a blob download. Electron's + * default will-download behavior shows the OS save dialog, so this works + * without a dedicated IPC handler (same pattern as use-image-download's + * browser fallback). */ +export function downloadTextFile(name: string, content: string, mimeType = 'text/plain') { + const blobUrl = URL.createObjectURL(new Blob([content], { type: `${mimeType};charset=utf-8` })) + const link = document.createElement('a') + + link.href = blobUrl + link.download = name + link.rel = 'noopener noreferrer' + document.body.appendChild(link) + link.click() + link.remove() + window.setTimeout(() => URL.revokeObjectURL(blobUrl), 30_000) +} diff --git a/apps/desktop/src/store/artifacts.test.ts b/apps/desktop/src/store/artifacts.test.ts new file mode 100644 index 000000000000..aacbeb042a50 --- /dev/null +++ b/apps/desktop/src/store/artifacts.test.ts @@ -0,0 +1,170 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import type { ArtifactDetection } from '@/lib/artifact-detect' + +import { + $artifactRegistry, + $artifactTabs, + $artifactVersionSelection, + artifactsForSession, + artifactTabId, + clearArtifactRegistry, + closeArtifactTab, + getArtifact, + openArtifactTab, + selectArtifactVersion, + upsertArtifact +} from './artifacts' +import { $rightRailActiveTabId, PREVIEW_PANE_ID, RIGHT_RAIL_PREVIEW_TAB_ID } from './layout' +import { $paneOpen } from './panes' +import { $activeSessionId, $selectedStoredSessionId } from './session' + +const HTML_DETECTION: ArtifactDetection = { kind: 'html', language: 'html', title: 'Pomodoro Timer' } + +describe('artifacts store', () => { + beforeEach(() => { + $activeSessionId.set('session-1') + $selectedStoredSessionId.set(null) + window.localStorage.clear() + clearArtifactRegistry() + $rightRailActiveTabId.set(RIGHT_RAIL_PREVIEW_TAB_ID) + }) + + afterEach(() => { + $activeSessionId.set(null) + $selectedStoredSessionId.set(null) + clearArtifactRegistry() + window.localStorage.clear() + }) + + it('registers a new artifact with one version', () => { + const result = upsertArtifact('session-1', HTML_DETECTION, '<html>v1</html>') + + expect(result?.versionAdded).toBe(true) + expect(artifactsForSession('session-1')).toHaveLength(1) + expect(getArtifact(result!.artifactId)?.versions).toHaveLength(1) + }) + + it('dedupes identical content by hash (streaming replays are no-ops)', () => { + const first = upsertArtifact('session-1', HTML_DETECTION, '<html>v1</html>') + const replay = upsertArtifact('session-1', HTML_DETECTION, '<html>v1</html>') + + expect(replay?.versionAdded).toBe(false) + expect(replay?.artifactId).toBe(first?.artifactId) + expect(getArtifact(first!.artifactId)?.versions).toHaveLength(1) + }) + + it('appends a version when the same artifact regenerates with new content', () => { + const first = upsertArtifact('session-1', HTML_DETECTION, '<html>v1</html>') + const second = upsertArtifact('session-1', HTML_DETECTION, '<html>v2</html>') + + expect(second?.versionAdded).toBe(true) + expect(second?.artifactId).toBe(first?.artifactId) + + const record = getArtifact(first!.artifactId) + + expect(record?.versions).toHaveLength(2) + expect(record?.versions.at(-1)?.content).toBe('<html>v2</html>') + expect(artifactsForSession('session-1')).toHaveLength(1) + }) + + it('keeps different titles as separate artifacts', () => { + upsertArtifact('session-1', HTML_DETECTION, '<html>timer</html>') + upsertArtifact('session-1', { ...HTML_DETECTION, title: 'Budget Dashboard' }, '<html>budget</html>') + + expect(artifactsForSession('session-1')).toHaveLength(2) + }) + + it('scopes artifacts per session', () => { + upsertArtifact('session-1', HTML_DETECTION, '<html>a</html>') + upsertArtifact('session-2', HTML_DETECTION, '<html>b</html>') + + expect(artifactsForSession('session-1')).toHaveLength(1) + expect(artifactsForSession('session-2')).toHaveLength(1) + }) + + it('persists the registry to localStorage', () => { + upsertArtifact('session-1', HTML_DETECTION, '<html>persisted</html>') + + expect(window.localStorage.getItem('hermes.desktop.artifacts.v1')).toContain('persisted') + }) + + it('rejects empty sessions and empty content', () => { + expect(upsertArtifact('', HTML_DETECTION, '<html>x</html>')).toBeNull() + expect(upsertArtifact('session-1', HTML_DETECTION, ' ')).toBeNull() + }) + + it('opens and closes artifact tabs, driving the rail selection', () => { + const result = upsertArtifact('session-1', HTML_DETECTION, '<html>v1</html>')! + const tabId = artifactTabId(result.artifactId) + + openArtifactTab(result.artifactId) + + expect($artifactTabs.get()).toEqual([tabId]) + expect($rightRailActiveTabId.get()).toBe(tabId) + expect($paneOpen(PREVIEW_PANE_ID).get()).toBe(true) + + closeArtifactTab(tabId) + + expect($artifactTabs.get()).toEqual([]) + expect($rightRailActiveTabId.get()).toBe(RIGHT_RAIL_PREVIEW_TAB_ID) + }) + + it('does not duplicate a tab when the same artifact opens twice', () => { + const result = upsertArtifact('session-1', HTML_DETECTION, '<html>v1</html>')! + + openArtifactTab(result.artifactId) + openArtifactTab(result.artifactId) + + expect($artifactTabs.get()).toHaveLength(1) + }) + + it('tracks version selection and snaps back to latest', () => { + const result = upsertArtifact('session-1', HTML_DETECTION, '<html>v1</html>')! + + upsertArtifact('session-1', HTML_DETECTION, '<html>v2</html>') + upsertArtifact('session-1', HTML_DETECTION, '<html>v3</html>') + + selectArtifactVersion(result.artifactId, 0) + + expect($artifactVersionSelection.get()[result.artifactId]).toBe(0) + + // Selecting the newest version clears the pin (absent = newest). + selectArtifactVersion(result.artifactId, 2) + + expect(result.artifactId in $artifactVersionSelection.get()).toBe(false) + + // Out-of-range clamps. + selectArtifactVersion(result.artifactId, -5) + + expect($artifactVersionSelection.get()[result.artifactId]).toBe(0) + }) + + it('reopening an artifact lands on the newest version', () => { + const result = upsertArtifact('session-1', HTML_DETECTION, '<html>v1</html>')! + + upsertArtifact('session-1', HTML_DETECTION, '<html>v2</html>') + selectArtifactVersion(result.artifactId, 0) + openArtifactTab(result.artifactId) + + expect(result.artifactId in $artifactVersionSelection.get()).toBe(false) + }) + + it('survives a registry reload round-trip', () => { + upsertArtifact('session-1', HTML_DETECTION, '<html>v1</html>') + upsertArtifact('session-1', HTML_DETECTION, '<html>v2</html>') + + const raw = window.localStorage.getItem('hermes.desktop.artifacts.v1')! + const parsed = JSON.parse(raw) as Record<string, unknown[]> + + expect(parsed['session-1']).toHaveLength(1) + + // Simulate a fresh boot: hydrate a clean registry from the persisted JSON. + $artifactRegistry.set(JSON.parse(raw)) + + const record = artifactsForSession('session-1')[0] + + expect(record?.versions).toHaveLength(2) + expect(record?.versions.at(-1)?.content).toBe('<html>v2</html>') + }) +}) diff --git a/apps/desktop/src/store/artifacts.ts b/apps/desktop/src/store/artifacts.ts new file mode 100644 index 000000000000..b1d7025f1a00 --- /dev/null +++ b/apps/desktop/src/store/artifacts.ts @@ -0,0 +1,386 @@ +import { atom, computed } from 'nanostores' + +import { artifactContentHash, type ArtifactDetection, type ArtifactKind, artifactSlug } from '@/lib/artifact-detect' +import { persistentAtom } from '@/lib/persisted' + +import { $rightRailActiveTabId, PREVIEW_PANE_ID, RIGHT_RAIL_PREVIEW_TAB_ID, selectRightRailTab } from './layout' +import { setPaneOpen } from './panes' +import { $activeSessionId, $selectedStoredSessionId } from './session' + +/** + * ARTIFACT REGISTRY — substantial generated content (HTML pages, large SVGs, + * long code) produced in the transcript, promoted out of the message flow into + * versioned, openable artifacts. The renderer owns this state: artifacts are a + * presentation of message content the backend already persists, so the store + * is a cache keyed by session with bounded history. + * + * Identity: one artifact = one (session, slug) pair, where the slug derives + * from kind + language + title. When the model regenerates "the dashboard" + * three times in a session, that is ONE artifact with three versions, exactly + * like a document the user keeps refining — not three cards. + */ + +export interface ArtifactVersion { + content: string + createdAt: number + hash: string +} + +export interface ArtifactRecord { + createdAt: number + id: string + kind: ArtifactKind + language: string + sessionId: string + slug: string + title: string + updatedAt: number + /** Oldest → newest. The last entry is the current version. */ + versions: ArtifactVersion[] +} + +type ArtifactRegistry = Record<string, ArtifactRecord[]> + +const STORAGE_KEY = 'hermes.desktop.artifacts.v1' +const MAX_ARTIFACTS_PER_SESSION = 24 +const MAX_VERSIONS_PER_ARTIFACT = 20 +const MAX_SESSIONS = 40 +// localStorage is ~5MB; artifacts carry full content, so cap the persisted +// bytes per artifact aggressively. Oversized artifacts survive in memory for +// the app's lifetime but persist only their newest version(s) that fit. +const MAX_PERSISTED_CHARS_PER_ARTIFACT = 120_000 + +export type ArtifactTabId = `artifact:${string}` + +export function artifactTabId(artifactId: string): ArtifactTabId { + return `artifact:${artifactId}` +} + +export function artifactIdFromTabId(tabId: string): string | null { + return tabId.startsWith('artifact:') ? tabId.slice('artifact:'.length) : null +} + +function isArtifactVersion(value: unknown): value is ArtifactVersion { + if (!value || typeof value !== 'object') { + return false + } + + const r = value as Record<string, unknown> + + return typeof r.content === 'string' && typeof r.createdAt === 'number' && typeof r.hash === 'string' +} + +function isArtifactRecord(value: unknown): value is ArtifactRecord { + if (!value || typeof value !== 'object') { + return false + } + + const r = value as Record<string, unknown> + + return ( + typeof r.createdAt === 'number' && + typeof r.id === 'string' && + (r.kind === 'code' || r.kind === 'html' || r.kind === 'svg') && + typeof r.language === 'string' && + typeof r.sessionId === 'string' && + typeof r.slug === 'string' && + typeof r.title === 'string' && + typeof r.updatedAt === 'number' && + Array.isArray(r.versions) && + r.versions.length > 0 && + r.versions.every(isArtifactVersion) + ) +} + +function sanitizeRegistry(value: unknown): ArtifactRegistry { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {} + } + + const out: ArtifactRegistry = {} + + for (const [sessionId, records] of Object.entries(value as Record<string, unknown>)) { + if (!Array.isArray(records)) { + continue + } + + const valid = records.filter(isArtifactRecord) + + if (valid.length > 0) { + out[sessionId] = valid + } + } + + return out +} + +function persistedVersions(record: ArtifactRecord): ArtifactVersion[] { + const kept: ArtifactVersion[] = [] + let budget = MAX_PERSISTED_CHARS_PER_ARTIFACT + + // Newest first; always keep at least the current version even if oversized. + for (let i = record.versions.length - 1; i >= 0; i -= 1) { + const version = record.versions[i]! + + if (kept.length > 0 && version.content.length > budget) { + break + } + + budget -= version.content.length + kept.unshift(version) + } + + return kept +} + +function pruneRegistry(registry: ArtifactRegistry): ArtifactRegistry { + const entries = Object.entries(registry) + .map(([sessionId, records]) => { + const trimmed = [...records] + .sort((a, b) => b.updatedAt - a.updatedAt) + .slice(0, MAX_ARTIFACTS_PER_SESSION) + .sort((a, b) => a.createdAt - b.createdAt) + + return [sessionId, trimmed] as const + }) + .filter(([, records]) => records.length > 0) + .sort(([, a], [, b]) => { + const latest = (records: readonly ArtifactRecord[]) => Math.max(...records.map(record => record.updatedAt)) + + return latest(b) - latest(a) + }) + .slice(0, MAX_SESSIONS) + + return Object.fromEntries(entries) +} + +export const $artifactRegistry = persistentAtom<ArtifactRegistry>( + STORAGE_KEY, + {}, + { + decode: raw => sanitizeRegistry(JSON.parse(raw) as unknown), + encode: registry => + JSON.stringify( + Object.fromEntries( + Object.entries(pruneRegistry(registry)).map(([sessionId, records]) => [ + sessionId, + records.map(record => ({ ...record, versions: persistedVersions(record) })) + ]) + ) + ) + } +) + +/** Artifact tabs open in the right rail (ids into the registry). */ +export const $artifactTabs = atom<ArtifactTabId[]>([]) + +/** Per-tab selected version index; absent = newest. Ephemeral by design: a + * reopened artifact always lands on its current version. */ +export const $artifactVersionSelection = atom<Record<string, number>>({}) + +function currentArtifactSessionId(): string { + return $selectedStoredSessionId.get() || $activeSessionId.get() || '' +} + +export function getArtifact(artifactId: string): ArtifactRecord | null { + for (const records of Object.values($artifactRegistry.get())) { + const found = records.find(record => record.id === artifactId) + + if (found) { + return found + } + } + + return null +} + +export const $openArtifacts = computed([$artifactRegistry, $artifactTabs], (registry, tabs) => { + const byId = new Map<string, ArtifactRecord>() + + for (const records of Object.values(registry)) { + for (const record of records) { + byId.set(record.id, record) + } + } + + return tabs + .map(tabId => { + const id = artifactIdFromTabId(tabId) + const record = id ? byId.get(id) : undefined + + return record ? { record, tabId } : null + }) + .filter((entry): entry is { record: ArtifactRecord; tabId: ArtifactTabId } => entry !== null) +}) + +export function artifactsForSession(sessionId: string | null | undefined): ArtifactRecord[] { + const id = sessionId?.trim() + + if (!id) { + return [] + } + + return $artifactRegistry.get()[id] ?? [] +} + +interface UpsertResult { + artifactId: string + record: ArtifactRecord + /** True when this call appended a NEW version (vs. deduped/no-op). */ + versionAdded: boolean +} + +/** + * Register (or version) an artifact for a session. Same slug + same content + * hash is a no-op (streaming remounts and transcript re-renders call this + * repeatedly); same slug + new content appends a version. + */ +export function upsertArtifact( + sessionId: string | null | undefined, + detection: ArtifactDetection, + content: string +): UpsertResult | null { + const id = sessionId?.trim() + const trimmed = content.trim() + + if (!id || !trimmed) { + return null + } + + const slug = artifactSlug(detection) + const hash = artifactContentHash(trimmed) + const registry = $artifactRegistry.get() + const records = registry[id] ?? [] + const existing = records.find(record => record.slug === slug) + const now = Date.now() + + if (existing) { + const known = existing.versions.some(version => version.hash === hash) + + if (known) { + return { artifactId: existing.id, record: existing, versionAdded: false } + } + + const versions = [...existing.versions, { content: trimmed, createdAt: now, hash }].slice( + -MAX_VERSIONS_PER_ARTIFACT + ) + + const next: ArtifactRecord = { + ...existing, + // A regenerated artifact may carry a sharper title (html <title> arrives + // late in the stream); prefer the newest non-generic one. + title: detection.title || existing.title, + updatedAt: now, + versions + } + + $artifactRegistry.set( + pruneRegistry({ + ...registry, + [id]: records.map(record => (record.id === existing.id ? next : record)) + }) + ) + + return { artifactId: existing.id, record: next, versionAdded: true } + } + + const record: ArtifactRecord = { + createdAt: now, + id: `${id}:${slug}`, + kind: detection.kind, + language: detection.language, + sessionId: id, + slug, + title: detection.title, + updatedAt: now, + versions: [{ content: trimmed, createdAt: now, hash }] + } + + $artifactRegistry.set(pruneRegistry({ ...registry, [id]: [...records, record] })) + + return { artifactId: record.id, record, versionAdded: true } +} + +export function upsertCurrentSessionArtifact(detection: ArtifactDetection, content: string): UpsertResult | null { + return upsertArtifact(currentArtifactSessionId(), detection, content) +} + +/** Open an artifact tab in the right rail and select it. User-initiated only + * (card click) — never called from streaming, per the no-hijack rule. */ +export function openArtifactTab(artifactId: string) { + const tabId = artifactTabId(artifactId) + const current = $artifactTabs.get() + + if (!current.includes(tabId)) { + $artifactTabs.set([...current, tabId]) + } + + // Land on the newest version whenever (re)opened. + const selection = $artifactVersionSelection.get() + + if (artifactId in selection) { + const { [artifactId]: _dropped, ...rest } = selection + $artifactVersionSelection.set(rest) + } + + setPaneOpen(PREVIEW_PANE_ID, true) + selectRightRailTab(tabId) +} + +export function closeArtifactTab(tabId: ArtifactTabId): boolean { + const current = $artifactTabs.get() + const index = current.indexOf(tabId) + + if (index === -1) { + return false + } + + const next = current.filter(id => id !== tabId) + + $artifactTabs.set(next) + + const artifactId = artifactIdFromTabId(tabId) + + if (artifactId) { + const { [artifactId]: _dropped, ...rest } = $artifactVersionSelection.get() + $artifactVersionSelection.set(rest) + } + + if ($rightRailActiveTabId.get() === tabId) { + selectRightRailTab(next[Math.min(index, next.length - 1)] ?? RIGHT_RAIL_PREVIEW_TAB_ID) + } + + return true +} + +export function selectArtifactVersion(artifactId: string, versionIndex: number) { + const record = getArtifact(artifactId) + + if (!record) { + return + } + + const clamped = Math.max(0, Math.min(record.versions.length - 1, versionIndex)) + const selection = $artifactVersionSelection.get() + + if (clamped === record.versions.length - 1) { + if (artifactId in selection) { + const { [artifactId]: _dropped, ...rest } = selection + $artifactVersionSelection.set(rest) + } + + return + } + + $artifactVersionSelection.set({ ...selection, [artifactId]: clamped }) +} + +export function closeAllArtifactTabs() { + $artifactTabs.set([]) + $artifactVersionSelection.set({}) +} + +export function clearArtifactRegistry() { + $artifactRegistry.set({}) + closeAllArtifactTabs() +} diff --git a/apps/desktop/src/store/gateway-switch.ts b/apps/desktop/src/store/gateway-switch.ts index 507ade879c00..a551e2e1ed0e 100644 --- a/apps/desktop/src/store/gateway-switch.ts +++ b/apps/desktop/src/store/gateway-switch.ts @@ -3,6 +3,7 @@ import { atom } from 'nanostores' import { resetLiveRuntimeTracking } from '@/app/contrib/hooks/use-background-sync' import { resetSidebarBatchCapability } from '@/hermes' import { invalidateProfileScopedQueries } from '@/lib/query-client' +import { closeAllArtifactTabs } from '@/store/artifacts' import { resetSessionsLimit } from '@/store/layout' import { $unreadFinishedSessionIds, @@ -61,6 +62,10 @@ export function wipeSessionListsForGatewaySwitch(): void { setMessages([]) setFreshDraftReady(true) + // Artifact tabs reference sessions on the previous backend; the registry + // itself survives (it's local presentation state) but open tabs must not. + closeAllArtifactTabs() + // Narrowed: account/marketplace/onboarding caches are global, not gateway- // scoped, so a mode swap must not refetch them. invalidateProfileScopedQueries() diff --git a/apps/desktop/src/store/layout.ts b/apps/desktop/src/store/layout.ts index f329e965265b..6e08c88a2e57 100644 --- a/apps/desktop/src/store/layout.ts +++ b/apps/desktop/src/store/layout.ts @@ -40,7 +40,7 @@ export const FILE_BROWSER_PANE_ID = 'file-browser' export const PREVIEW_PANE_ID = 'preview' export const RIGHT_RAIL_PREVIEW_TAB_ID = 'preview' -export type RightRailTabId = typeof RIGHT_RAIL_PREVIEW_TAB_ID | `file:${string}` +export type RightRailTabId = typeof RIGHT_RAIL_PREVIEW_TAB_ID | `artifact:${string}` | `file:${string}` ensurePaneRegistered(CHAT_SIDEBAR_PANE_ID, { open: true }) ensurePaneRegistered(FILE_BROWSER_PANE_ID, { open: false }) diff --git a/apps/desktop/src/store/preview.ts b/apps/desktop/src/store/preview.ts index 678bcd1f3f7d..2454698b6229 100644 --- a/apps/desktop/src/store/preview.ts +++ b/apps/desktop/src/store/preview.ts @@ -3,6 +3,7 @@ import { atom, computed } from 'nanostores' import { persistentAtom } from '@/lib/persisted' import { normalize } from '@/lib/text' +import { $artifactTabs, type ArtifactTabId, closeAllArtifactTabs, closeArtifactTab } from './artifacts' import { $rightRailActiveTabId, PREVIEW_PANE_ID, @@ -79,10 +80,12 @@ export const $filePreviewTabs = persistentAtom<FilePreviewTab[]>(TABS_STORAGE_KE }) // Drop a restored active file-tab that didn't survive validation so the rail -// never points at a tab that isn't there. +// never points at a tab that isn't there. Artifact tabs are ephemeral (never +// restored), so a persisted `artifact:` active id is always stale too. if ( - $rightRailActiveTabId.get().startsWith('file:') && - !$filePreviewTabs.get().some(tab => tab.id === $rightRailActiveTabId.get()) + ($rightRailActiveTabId.get().startsWith('file:') && + !$filePreviewTabs.get().some(tab => tab.id === $rightRailActiveTabId.get())) || + $rightRailActiveTabId.get().startsWith('artifact:') ) { selectRightRailTab(RIGHT_RAIL_PREVIEW_TAB_ID) } @@ -424,10 +427,10 @@ export function dismissPreviewTarget() { $previewTarget.set(null) if ($rightRailActiveTabId.get() === RIGHT_RAIL_PREVIEW_TAB_ID) { - selectRightRailTab($filePreviewTabs.get()[0]?.id ?? RIGHT_RAIL_PREVIEW_TAB_ID) + selectRightRailTab($filePreviewTabs.get()[0]?.id ?? $artifactTabs.get()[0] ?? RIGHT_RAIL_PREVIEW_TAB_ID) } - setPaneOpen(PREVIEW_PANE_ID, $filePreviewTabs.get().length > 0) + setPaneOpen(PREVIEW_PANE_ID, $filePreviewTabs.get().length > 0 || $artifactTabs.get().length > 0) } function closeFilePreviewTab(tabId: RightRailTabId) { @@ -447,10 +450,12 @@ function closeFilePreviewTab(tabId: RightRailTabId) { $filePreviewTabs.set(next) if ($rightRailActiveTabId.get() === tabId) { - selectRightRailTab(next[Math.min(index, next.length - 1)]?.id ?? RIGHT_RAIL_PREVIEW_TAB_ID) + selectRightRailTab( + next[Math.min(index, next.length - 1)]?.id ?? $artifactTabs.get()[0] ?? RIGHT_RAIL_PREVIEW_TAB_ID + ) } - if (next.length === 0 && !$previewTarget.get()) { + if (next.length === 0 && !$previewTarget.get() && $artifactTabs.get().length === 0) { setPaneOpen(PREVIEW_PANE_ID, false) } } @@ -464,6 +469,16 @@ export function closeRightRailTab(tabId: RightRailTabId) { return } + if (tabId.startsWith('artifact:')) { + closeArtifactTab(tabId as ArtifactTabId) + + if (!$previewTarget.get() && $filePreviewTabs.get().length === 0 && $artifactTabs.get().length === 0) { + setPaneOpen(PREVIEW_PANE_ID, false) + } + + return + } + closeFilePreviewTab(tabId) } @@ -474,7 +489,7 @@ export function closeActiveRightRailTab(): boolean { let tabId = $rightRailActiveTabId.get() if (tabId === RIGHT_RAIL_PREVIEW_TAB_ID && !$previewTarget.get()) { - const fallback = $filePreviewTabs.get()[0]?.id + const fallback = $filePreviewTabs.get()[0]?.id ?? $artifactTabs.get()[0] if (!fallback) { return false @@ -493,6 +508,16 @@ export function closeActiveRightRailTab(): boolean { return true } + if (tabId.startsWith('artifact:')) { + if (!$artifactTabs.get().includes(tabId as ArtifactTabId)) { + return false + } + + closeRightRailTab(tabId) + + return true + } + if (!$filePreviewTabs.get().some(tab => tab.id === tabId)) { return false } @@ -503,7 +528,7 @@ export function closeActiveRightRailTab(): boolean { } // The rail's visible tab order: the live preview tab (when present) first, then -// the file tabs in their stored order. Mirrors `ChatPreviewRail`'s `tabs` memo +// the file tabs, then artifact tabs. Mirrors `ChatPreviewRail`'s `tabs` memo // so "close others / to the right" act on what the user actually sees. function rightRailTabOrder(): RightRailTabId[] { const ids: RightRailTabId[] = [] @@ -516,6 +541,10 @@ function rightRailTabOrder(): RightRailTabId[] { ids.push(tab.id) } + for (const tabId of $artifactTabs.get()) { + ids.push(tabId) + } + return ids } @@ -544,13 +573,14 @@ export function closeRightRailTabsToRight(tabId: RightRailTabId) { } } -/** Dismisses the active preview + every file tab so the rail pane unmounts. */ +/** Dismisses the active preview + every file and artifact tab so the rail pane unmounts. */ export function closeRightRail() { if ($previewTarget.get()) { dismissPreviewTarget() } $filePreviewTabs.set([]) + closeAllArtifactTabs() setPaneOpen(PREVIEW_PANE_ID, false) } From a94ec27f970b5f30859ecec6b1c9c60619fe7ad0 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com> Date: Sun, 26 Jul 2026 22:00:29 -0500 Subject: [PATCH 2/2] polish(desktop): tokenize artifact card font sizes, drop redundant hover class - artifact card title/meta/open-hint now use --conversation-text-font-size / --conversation-tool-font-size like CodeCard and the rest of the transcript, instead of hardcoded rem literals - drop no-op hover:border-border (border is border-border at rest) - comment the deliberate raw bg-white + colorScheme:light on the sandboxed iframe so it doesn't read as an untokenized literal --- .../src/app/chat/right-rail/artifact-renderers.tsx | 3 +++ .../desktop/src/components/assistant-ui/artifact-card.tsx | 8 ++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/app/chat/right-rail/artifact-renderers.tsx b/apps/desktop/src/app/chat/right-rail/artifact-renderers.tsx index 61b49809265a..00ed9a58a58b 100644 --- a/apps/desktop/src/app/chat/right-rail/artifact-renderers.tsx +++ b/apps/desktop/src/app/chat/right-rail/artifact-renderers.tsx @@ -97,6 +97,9 @@ export function ArtifactLivePreview({ content, kind, title }: { content: string; className="block size-full border-0 bg-white" sandbox="allow-scripts" srcDoc={composeArtifactHtml(content)} + // Deliberately raw white + forced light scheme: the frame hosts foreign + // generated HTML that assumes a light canvas, so it renders deterministically + // light in both app themes instead of inheriting theme tokens it can't see. style={{ colorScheme: 'light' }} title={title} /> diff --git a/apps/desktop/src/components/assistant-ui/artifact-card.tsx b/apps/desktop/src/components/assistant-ui/artifact-card.tsx index 7444c23c05f0..56e53e031296 100644 --- a/apps/desktop/src/components/assistant-ui/artifact-card.tsx +++ b/apps/desktop/src/components/assistant-ui/artifact-card.tsx @@ -108,7 +108,7 @@ export function ArtifactCard({ code, detection, streaming = false }: ArtifactCar <button className={cn( 'group/artifact my-1.5 flex w-full max-w-md items-center gap-2.5 overflow-hidden rounded-[0.625rem] border border-border px-3 py-2.5 text-left transition-colors', - streaming ? 'cursor-default' : 'cursor-pointer hover:border-border hover:bg-accent/40' + streaming ? 'cursor-default' : 'cursor-pointer hover:bg-accent/40' )} data-slot="aui_artifact-card" disabled={streaming} @@ -121,13 +121,13 @@ export function ArtifactCard({ code, detection, streaming = false }: ArtifactCar <span className="min-w-0 flex-1"> <span className={cn( - 'block truncate text-[0.8125rem] font-medium text-foreground', + 'block truncate text-[length:var(--conversation-text-font-size)] font-medium text-foreground', streaming && 'shimmer text-foreground/55' )} > {title} </span> - <span className="block truncate text-[0.6875rem] text-muted-foreground"> + <span className="block truncate text-[length:var(--conversation-tool-font-size)] text-muted-foreground"> {streaming ? copy.generating(lineCount) : versionCount > 1 @@ -136,7 +136,7 @@ export function ArtifactCard({ code, detection, streaming = false }: ArtifactCar </span> </span> {!streaming && ( - <span className="shrink-0 text-[0.6875rem] font-medium text-muted-foreground opacity-0 transition-opacity group-hover/artifact:opacity-100"> + <span className="shrink-0 text-[length:var(--conversation-tool-font-size)] font-medium text-muted-foreground opacity-0 transition-opacity group-hover/artifact:opacity-100"> {copy.open} </span> )}