Merge pull request #72345 from NousResearch/hermes/hermes-5ee2515d

feat(desktop): artifacts — versioned cards, sandboxed live preview, right-rail viewer
This commit is contained in:
brooklyn! 2026-07-26 22:08:34 -05:00 committed by GitHub
commit b9ba7c78e4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1766 additions and 47 deletions

View file

@ -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()
}

View file

@ -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>
)
}

View file

@ -0,0 +1,107 @@
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)}
// 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}
/>
)
}

View file

@ -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>
)

View file

@ -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()
}
})
// ---------------------------------------------------------------------------

View file

@ -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">

View file

@ -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: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-[length:var(--conversation-text-font-size)] font-medium text-foreground',
streaming && 'shimmer text-foreground/55'
)}
>
{title}
</span>
<span className="block truncate text-[length:var(--conversation-tool-font-size)] text-muted-foreground">
{streaming
? copy.generating(lineCount)
: versionCount > 1
? `${kindLabel} · ${copy.versionBadge(versionCount)}`
: kindLabel}
</span>
</span>
{!streaming && (
<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>
)}
</button>
)
}

View file

@ -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</title></head>
<body>
<h1>Pomodoro</h1>
<p>A tiny focus timer that counts down twenty-five minutes.</p>
<script>let seconds = 25 * 60; setInterval(() => { seconds -= 1 }, 1000)</script>
</body>
</html>`
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(<MarkdownTextContent isRunning={false} text={fenced('html', HTML_DOC)} />)
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(<MarkdownTextContent isRunning={false} text={fenced('js', SMALL_SNIPPET)} />)
// 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(<MarkdownTextContent isRunning text={fenced('html', HTML_DOC)} />)
await screen.findByText('Pomodoro Timer')
expect(artifactsForSession('session-artifacts')).toHaveLength(0)
})
})

View file

@ -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
<td className={cn('px-2.5 py-1.5 align-top text-[0.8125rem] leading-snug', className)} {...props} />
),
img: MarkdownImage,
// ```mermaid / ```svg fences route to their lazy renderers; every other
// language falls back to the Shiki-highlighted code block.
SyntaxHighlighter: (props: SyntaxHighlighterProps) => (
<RichCodeBlock
code={props.code}
fallback={<SyntaxHighlighter {...props} defer={isStreaming} />}
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 <ArtifactCard code={props.code} detection={artifact} streaming={isStreaming} />
}
return (
<RichCodeBlock
code={props.code}
fallback={<SyntaxHighlighter {...props} defer={isStreaming} />}
language={props.language}
streaming={isStreaming}
/>
)
}
}) as StreamdownTextComponents,
[isStreaming]
[disableArtifacts, isStreaming]
)
if (text.length > MAX_MARKDOWN_CHARS) {

View file

@ -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<Record<string, unknown>>()),
openSessionTile: (...args: unknown[]) => openSessionTile(...args)
}))

View file

@ -198,6 +198,7 @@ const ReasoningTextPart: ReasoningMessagePartComponent = () => {
<MarkdownTextContent
containerClassName="text-xs leading-snug text-muted-foreground/85"
containerProps={{ 'data-slot': 'aui_reasoning-text' } as ComponentProps<'div'>}
disableArtifacts
isRunning={status.type === 'running' || messageRunning}
text={text.trimStart()}
/>

View file

@ -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': 'جلسة جديدة',

View file

@ -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',

View file

@ -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': '新しいセッション',

View file

@ -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<string, string>
searchAria: string

View file

@ -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': '新工作階段',

View file

@ -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': '新建会话',

View file

@ -0,0 +1,121 @@
import { describe, expect, it } from 'vitest'
import { artifactContentHash, artifactDownloadName, artifactSlug, detectArtifact } from './artifact-detect'
const HTML_DOC = `<!doctype html>
<html>
<head><title>Pomodoro Timer</title></head>
<body>
<h1>Pomodoro</h1>
<script>let t = 25 * 60; setInterval(() => { t -= 1 }, 1000)</script>
</body>
</html>`
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 = `<!doctype html><html><body><h1>Budget <em>Dashboard</em></h1>${'<div>x</div>'.repeat(30)}</body></html>`
expect(detectArtifact('html', doc)?.title).toBe('Budget Dashboard')
})
it('ignores a small html snippet', () => {
expect(detectArtifact('html', '<div class="chip">hello</div>')).toBeNull()
})
it('ignores small svg fences (inline embed owns them)', () => {
expect(detectArtifact('svg', '<svg viewBox="0 0 10 10"><rect width="10" height="10"/></svg>')).toBeNull()
})
it('promotes a large svg', () => {
const svg = `<svg viewBox="0 0 100 100"><title>Org Chart</title>${'<rect x="1" y="2" width="3" height="4"/>'.repeat(80)}</svg>`
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')
})
})

View file

@ -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 <title>, 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}`
}

View file

@ -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)
}

View file

@ -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>')
})
})

View file

@ -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()
}

View file

@ -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()

View file

@ -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 })

View file

@ -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)
}