hermes-agent/apps/desktop/src/store/artifacts.ts
Brooklyn Nicholson 96999b116b refactor(desktop): put every preview on one rail tab list
The right rail held two things at once: a list of file tabs, and a
privileged "live preview" slot with a hardcoded `preview` tab id backed by
a separate session-keyed registry. The two were written under different
session-id rules and reconciled against each other, so an `open_preview`
from a session whose stored id hadn't landed yet was set and then
immediately cleared — the pane flashed and vanished. Artifacts arrived as
a third list with their own pane and renderers.

Now everything the rail can show is a `PreviewTarget` in `$previewTabs`,
and `openPreview` is the only way in. `$previewTarget` is a computed read
of the active tab, the session registry and its reconciler are gone, and
artifacts render in the real preview pane through the shared mode switcher
and source view instead of a parallel one. Artifact tabs stay memory-only
since the registry rebuilds from the transcript.
2026-07-27 17:55:27 -05:00

227 lines
6.9 KiB
TypeScript

import { atom } from 'nanostores'
import { artifactContentHash, type ArtifactDetection, type ArtifactKind, artifactSlug } from '@/lib/artifact-detect'
import { closeArtifactPreviewTabs, openPreview, type PreviewTarget } from './preview'
/**
* ARTIFACT REGISTRY — substantial generated content (HTML pages, large SVGs,
* long code) produced in the transcript, promoted out of the message flow into
* versioned content the right rail can preview. The registry is authoritative
* for artifact content; a rail tab only ever holds a reference to it, so a new
* version shows up in an already-open tab.
*
* 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.
*
* Memory-only: the transcript is the durable copy. Cards re-register as they
* render, so a reload rebuilds the registry (and its version history) for free
* instead of parking megabytes of generated HTML in localStorage.
*/
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[]
}
export type ArtifactRegistry = Record<string, ArtifactRecord[]>
const MAX_ARTIFACTS_PER_SESSION = 24
const MAX_VERSIONS_PER_ARTIFACT = 20
const MAX_SESSIONS = 40
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 = atom<ArtifactRegistry>({})
/** Per-artifact selected version index; absent = newest. */
export const $artifactVersionSelection = atom<Record<string, number>>({})
/** Lookup against a registry value, for components that already subscribe to
* the atom and need the record to change identity when it does. */
export function findArtifact(registry: ArtifactRegistry, artifactId: string): ArtifactRecord | null {
for (const records of Object.values(registry)) {
const found = records.find(record => record.id === artifactId)
if (found) {
return found
}
}
return null
}
export function getArtifact(artifactId: string): ArtifactRecord | null {
return findArtifact($artifactRegistry.get(), artifactId)
}
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 }
}
/** A rail tab for an artifact references the registry by id rather than
* carrying content, so an open tab follows the artifact as it gains versions. */
export function artifactPreviewTarget(record: ArtifactRecord): PreviewTarget {
return { kind: 'artifact', label: record.title, source: record.id, url: record.id }
}
/** Open an artifact in the right rail at `versionIndex` (default: newest).
* User-initiated only (card click) — never called from streaming, per the
* no-hijack rule. */
export function openArtifact(artifactId: string, versionIndex?: number) {
const record = getArtifact(artifactId)
if (!record) {
return
}
selectArtifactVersion(artifactId, versionIndex ?? record.versions.length - 1)
openPreview(artifactPreviewTarget(record))
}
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 clearArtifactRegistry() {
$artifactRegistry.set({})
$artifactVersionSelection.set({})
closeArtifactPreviewTabs()
}