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.
This commit is contained in:
Brooklyn Nicholson 2026-07-27 17:55:13 -05:00
parent b429194478
commit 96999b116b
33 changed files with 1000 additions and 1569 deletions

View file

@ -1,14 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $rightRailActiveTabId, RIGHT_RAIL_PREVIEW_TAB_ID } from '@/store/layout'
import {
$filePreviewTabs,
$previewTarget,
clearSessionPreviewRegistry,
type PreviewTarget,
setCurrentSessionPreviewTarget
} from '@/store/preview'
import { $activeSessionId, $selectedStoredSessionId } from '@/store/session'
import { $rightRailActiveTabId } from '@/store/layout'
import { $previewTabs, closeRightRail, openPreview, type PreviewTarget } from '@/store/preview'
import { closeActiveTab } from './close-tab'
@ -26,40 +19,34 @@ function fileTarget(path: string): PreviewTarget {
describe('closeActiveTab', () => {
beforeEach(() => {
vi.stubGlobal('document', { activeElement: null })
$activeSessionId.set('session-1')
$selectedStoredSessionId.set(null)
closeRightRail()
window.localStorage.clear()
clearSessionPreviewRegistry()
})
afterEach(() => {
vi.unstubAllGlobals()
$activeSessionId.set(null)
$selectedStoredSessionId.set(null)
clearSessionPreviewRegistry()
closeRightRail()
window.localStorage.clear()
})
it('closes the active file preview tab (⌘W happy path)', () => {
setCurrentSessionPreviewTarget(fileTarget('/work/notes.md'), 'manual')
openPreview(fileTarget('/work/notes.md'), 'manual')
expect($filePreviewTabs.get()).toHaveLength(1)
expect($previewTabs.get()).toHaveLength(1)
expect($rightRailActiveTabId.get()).toBe('file:file:///work/notes.md')
expect(closeActiveTab()).toBe(true)
expect($filePreviewTabs.get()).toHaveLength(0)
expect($previewTabs.get()).toHaveLength(0)
})
it('closes the visible file tab when active selection is a ghost preview', () => {
// Active tab id stuck on live-preview after that target was cleared, while
// file tabs remain (UI falls back to tabs[0] until React syncs). ⌘W must
// close the visible file tab instead of no-op'ing via closeWorkspaceTab().
setCurrentSessionPreviewTarget(fileTarget('/work/notes.md'), 'manual')
$previewTarget.set(null)
$rightRailActiveTabId.set(RIGHT_RAIL_PREVIEW_TAB_ID)
it('closes the visible tab when the active selection points at a tab that is gone', () => {
// The rail falls back to tabs[0] until React syncs the selection, so ⌘W has
// to act on what is actually on screen rather than no-op'ing.
openPreview(fileTarget('/work/notes.md'), 'manual')
$rightRailActiveTabId.set('file:file:///work/stale.md')
expect($filePreviewTabs.get()).toHaveLength(1)
expect($previewTabs.get()).toHaveLength(1)
expect(closeActiveTab()).toBe(true)
expect($filePreviewTabs.get()).toHaveLength(0)
expect($previewTabs.get()).toHaveLength(0)
})
})

View file

@ -1,8 +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 { $previewTabs, closeActiveRightRailTab } from '@/store/preview'
import { closeSessionTile, nextSessionTileForWorkspace } from '@/store/session-states'
/**
@ -28,12 +27,10 @@ export function closeActiveTab(loadSessionIntoWorkspace?: (storedSessionId: stri
return true
}
// Prefer tab *presence* over the derived active file target. After the live
// preview is cleared, `$rightRailActiveTabId` can stay on `preview` while
// 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 || $artifactTabs.get().length > 0) {
// Gate on tab *presence*, not on the selection: a stale `$rightRailActiveTabId`
// would otherwise make ⌘W fall through to closeWorkspaceTab() and look broken
// with a tab still on screen. The store resolves which tab that is.
if ($previewTabs.get().length > 0) {
return closeActiveRightRailTab()
}

View file

@ -11,7 +11,7 @@ import { cn } from '@/lib/utils'
import { PREVIEW_PANE_ID } from '@/store/layout'
import { notifyError } from '@/store/notifications'
import { $paneOpen } from '@/store/panes'
import { $previewTarget, dismissPreviewTarget, setCurrentSessionPreviewTarget } from '@/store/preview'
import { $previewTabSources, closePreviewForSource, openPreview } from '@/store/preview'
import { type PreviewArtifact } from '@/store/preview-status'
interface PreviewStatusRowProps {
@ -22,10 +22,10 @@ interface PreviewStatusRowProps {
/** One detected artifact, single line, always visible: filename + open + close. */
export const PreviewStatusRow = memo(function PreviewStatusRow({ item, onDismiss }: PreviewStatusRowProps) {
const { t } = useI18n()
const activePreview = useStore($previewTarget)
const openSources = useStore($previewTabSources)
const previewPaneOpen = useStore($paneOpen(PREVIEW_PANE_ID))
const [opening, setOpening] = useState(false)
const isOpen = activePreview?.source === item.target && previewPaneOpen
const isOpen = openSources.includes(item.target) && previewPaneOpen
const resolveTarget = async () => {
const target = await normalizeOrLocalPreviewTarget(item.target, item.cwd || undefined)
@ -43,7 +43,7 @@ export const PreviewStatusRow = memo(function PreviewStatusRow({ item, onDismiss
}
if (isOpen) {
dismissPreviewTarget()
closePreviewForSource(item.target)
return
}
@ -51,7 +51,7 @@ export const PreviewStatusRow = memo(function PreviewStatusRow({ item, onDismiss
setOpening(true)
try {
setCurrentSessionPreviewTarget(await resolveTarget(), 'tool-result', item.target)
openPreview(await resolveTarget(), 'tool-result')
} catch (error) {
notifyError(error, t.preview.unavailable)
} finally {

View file

@ -1,222 +0,0 @@
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

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

@ -0,0 +1,112 @@
import { act, cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { $artifactRegistry, $artifactVersionSelection, artifactPreviewTarget, upsertArtifact } from '@/store/artifacts'
import { ArtifactPreview } from './preview-artifact'
function register(title: string, kind: 'code' | 'html' | 'svg', content: string) {
const result = upsertArtifact('session-1', { kind, language: kind === 'code' ? 'python' : kind, title }, content)
if (!result) {
throw new Error('artifact did not register')
}
return result
}
async function renderArtifact(artifactId: string) {
const record = $artifactRegistry.get()['session-1']!.find(item => item.id === artifactId)!
await act(async () => {
render(<ArtifactPreview target={artifactPreviewTarget(record)} />)
})
}
describe('ArtifactPreview', () => {
afterEach(() => {
cleanup()
$artifactRegistry.set({})
$artifactVersionSelection.set({})
})
it('renders html in a scripts-only sandboxed frame the parent app is unreachable from', async () => {
const { artifactId } = register('Dashboard', 'html', '<h1>Hi</h1>')
await renderArtifact(artifactId)
const frame = screen.getByTitle('Dashboard') as HTMLIFrameElement
expect(frame.getAttribute('sandbox')).toBe('allow-scripts')
expect(frame.srcdoc).toContain('<h1>Hi</h1>')
// No allow-same-origin: scripts inside cannot reach the renderer's origin.
expect(frame.getAttribute('sandbox')).not.toContain('same-origin')
})
it('strips scripts out of svg before it renders inline', async () => {
const { artifactId } = register(
'Logo',
'svg',
'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>'
)
await renderArtifact(artifactId)
expect(document.querySelector('svg')).not.toBeNull()
expect(document.querySelector('svg script')).toBeNull()
})
it('offers only the source view for code, which has nothing to render', async () => {
const { artifactId } = register('Solver', 'code', 'print("hi")')
await renderArtifact(artifactId)
expect(screen.queryByRole('button', { name: /rendered/i })).toBeNull()
})
it('shows the version stepper once an artifact has history, and follows the selection', async () => {
register('Dashboard', 'html', '<h1>v1</h1>')
const { artifactId } = register('Dashboard', 'html', '<h1>v2</h1>')
await renderArtifact(artifactId)
expect(screen.getByText('v2 of 2')).toBeTruthy()
expect((screen.getByTitle('Dashboard') as HTMLIFrameElement).srcdoc).toContain('v2')
await act(async () => {
$artifactVersionSelection.set({ [artifactId]: 0 })
})
expect(screen.getByText('v1 of 2')).toBeTruthy()
expect((screen.getByTitle('Dashboard') as HTMLIFrameElement).srcdoc).toContain('v1')
})
it('hides the stepper for a single-version artifact', async () => {
const { artifactId } = register('Dashboard', 'html', '<h1>only</h1>')
await renderArtifact(artifactId)
expect(screen.queryByText('v1 of 1')).toBeNull()
})
it('picks up a new version in an already-open tab', async () => {
const { artifactId } = register('Dashboard', 'html', '<h1>v1</h1>')
await renderArtifact(artifactId)
await act(async () => {
register('Dashboard', 'html', '<h1>v2</h1>')
})
expect((screen.getByTitle('Dashboard') as HTMLIFrameElement).srcdoc).toContain('v2')
})
it('falls back to an empty state when the registry no longer has the artifact', async () => {
const { artifactId } = register('Dashboard', 'html', '<h1>gone</h1>')
const record = $artifactRegistry.get()['session-1']!.find(item => item.id === artifactId)!
const target = artifactPreviewTarget(record)
$artifactRegistry.set({})
await act(async () => {
render(<ArtifactPreview target={target} />)
})
expect(screen.queryByTitle('Dashboard')).toBeNull()
})
})

View file

@ -0,0 +1,263 @@
import { useStore } from '@nanostores/react'
import DOMPurify from 'dompurify'
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, type ArtifactKind } from '@/lib/artifact-detect'
import { downloadTextFile } from '@/lib/download-text'
import { ChevronLeft, ChevronRight, Download, ExternalLink } from '@/lib/icons'
import { $artifactRegistry, $artifactVersionSelection, findArtifact, selectArtifactVersion } from '@/store/artifacts'
import { notifyError } from '@/store/notifications'
import type { PreviewTarget } from '@/store/preview'
import { PreviewEmptyState, PreviewModeSwitcher, type PreviewViewMode, SourceView } from './preview-file'
const MIME_BY_KIND = { code: 'text/plain', html: 'text/html', svg: 'image/svg+xml' } as const
// Shiki has no `svg` grammar; code artifacts keep their detected fence language.
const SOURCE_LANGUAGE_BY_KIND: Record<ArtifactKind, string | undefined> = {
code: undefined,
html: 'html',
svg: 'xml'
}
const HEADER_BUTTON_CLASS =
'flex items-center gap-1 rounded-md px-1.5 text-[0.625rem] font-bold text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-40'
/** 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. */
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')
}
/** 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)
}
/**
* Live view for renderable 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.
*/
function ArtifactLiveView({ 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}
/>
)
}
function VersionStepper({
current,
onSelect,
total
}: {
current: number
onSelect: (index: number) => void
total: number
}) {
const { t } = useI18n()
const copy = t.artifactPreview
if (total < 2) {
return null
}
return (
<>
<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="text-[0.625rem] font-bold tabular-nums text-muted-foreground">
{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>
{current < total - 1 && (
<button
className="text-[0.625rem] font-bold text-muted-foreground underline decoration-current/25 underline-offset-4 transition-colors hover:text-foreground"
onClick={() => onSelect(total - 1)}
type="button"
>
{copy.latest}
</button>
)}
</>
)
}
/**
* Renders an `artifact` preview target inside the real preview pane: the
* shared mode switcher up top (RENDERED / SOURCE, plus the version stepper and
* content actions in its trailing slot) over either the live view or the same
* windowed source view a file preview uses.
*
* The target only carries the artifact id content is read live from the
* registry, so an open tab picks up new versions as the model iterates.
*/
export function ArtifactPreview({ target }: { target: PreviewTarget }) {
const { t } = useI18n()
const copy = t.artifactPreview
const artifactId = target.url
const registry = useStore($artifactRegistry)
const versionSelection = useStore($artifactVersionSelection)
const [userMode, setUserMode] = useState<PreviewViewMode | null>(null)
// Reset the explicit mode when the pane is reused for another artifact.
useEffect(() => {
setUserMode(null)
}, [artifactId])
const record = useMemo(() => findArtifact(registry, artifactId), [artifactId, registry])
if (!record) {
return <PreviewEmptyState body={copy.missingBody} title={copy.missingTitle} />
}
const renderable = record.kind === 'html' || record.kind === 'svg'
const modes: PreviewViewMode[] = renderable ? ['rendered', 'source'] : ['source']
const mode = userMode && modes.includes(userMode) ? userMode : modes[0]!
const versionIndex = Math.min(versionSelection[artifactId] ?? record.versions.length - 1, record.versions.length - 1)
const version = record.versions[versionIndex]!
return (
<div className="flex h-full flex-col overflow-hidden bg-transparent">
<PreviewModeSwitcher
active={mode}
modes={modes}
onSelect={setUserMode}
trailing={
<>
<VersionStepper
current={versionIndex}
onSelect={index => selectArtifactVersion(artifactId, index)}
total={record.versions.length}
/>
<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(
artifactDownloadName(record.kind, record.language, record.title),
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 className="min-h-0 flex-1 overflow-hidden">
{mode === 'rendered' && renderable ? (
<ArtifactLiveView content={version.content} kind={record.kind} title={record.title} />
) : (
<SourceView language={SOURCE_LANGUAGE_BY_KIND[record.kind] ?? record.language} text={version.content} />
)}
</div>
</div>
)
}

View file

@ -338,7 +338,7 @@ function MarkdownPreview({ text }: { text: string }) {
)
}
function PreviewModeSwitcher({
export function PreviewModeSwitcher({
active,
modes,
onSelect,
@ -439,7 +439,10 @@ function startLineDrag(event: ReactDragEvent<HTMLElement>, filePath: string, { e
event.dataTransfer.effectAllowed = 'copy'
}
function SourceView({ filePath, language, text }: { filePath: string; language: string; text: string }) {
/** Windowed, Shiki-highlighted source. The gutter's line selection produces a
* `path:line` composer ref, so it is inert without a `filePath` (artifact
* content has no path to reference lines against). */
export function SourceView({ filePath, language, text }: { filePath?: string; language: string; text: string }) {
const { t } = useI18n()
const chunks = useMemo(() => chunkTextLines(text, SOURCE_CHUNK_LINES), [text])
const lastChunk = chunks.at(-1)
@ -457,6 +460,10 @@ function SourceView({ filePath, language, text }: { filePath: string; language:
const inSelection = (line: number) => selection != null && line >= selection.start && line <= selection.end
const handleLineClick = (event: ReactMouseEvent, line: number) => {
if (!filePath) {
return
}
if (event.shiftKey && selection) {
setSelection({ end: Math.max(selection.end, line), start: Math.min(selection.start, line) })
@ -473,6 +480,10 @@ function SourceView({ filePath, language, text }: { filePath: string; language:
}
const handleDragStart = (event: ReactDragEvent<HTMLElement>, line: number) => {
if (!filePath) {
return
}
startLineDrag(event, filePath, inSelection(line) && selection ? selection : { end: line, start: line })
}
@ -481,7 +492,7 @@ function SourceView({ filePath, language, text }: { filePath: string; language:
// the composer. Capture-phase + stopPropagation so it beats the terminal's
// global ⌘L handler (which would otherwise grab the native text selection).
useEffect(() => {
if (!selection) {
if (!selection || !filePath) {
return
}
@ -524,16 +535,17 @@ function SourceView({ filePath, language, text }: { filePath: string; language:
return (
<div
className={cn(
'h-5 w-9 cursor-pointer pr-2 leading-5 tabular-nums transition-colors',
'h-5 w-9 pr-2 leading-5 tabular-nums transition-colors',
filePath && 'cursor-pointer',
selected
? 'bg-amber-200/45 text-amber-900 dark:bg-amber-300/20 dark:text-amber-100'
: 'hover:text-foreground'
: filePath && 'hover:text-foreground'
)}
draggable
draggable={Boolean(filePath)}
key={line}
onClick={event => handleLineClick(event, line)}
onDragStart={event => handleDragStart(event, line)}
title={t.preview.sourceLineTitle}
title={filePath ? t.preview.sourceLineTitle : undefined}
>
{line}
</div>
@ -561,7 +573,7 @@ function SourceView({ filePath, language, text }: { filePath: string; language:
)
}
type PreviewViewMode = 'diff' | 'rendered' | 'source'
export type PreviewViewMode = 'diff' | 'rendered' | 'source'
export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; target: PreviewTarget }) {
const { t } = useI18n()

View file

@ -12,6 +12,7 @@ import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import { $previewServerRestart, failPreviewServerRestart, type PreviewTarget } from '@/store/preview'
import { ArtifactPreview } from './preview-artifact'
import {
clampConsoleHeight,
compactUrl,
@ -146,7 +147,13 @@ export function PreviewPane({
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState<PreviewLoadErrorState | null>(null)
const [localReloadKey, setLocalReloadKey] = useState(0)
const isWebPreview = target.kind === 'url' || (target.previewKind === 'html' && target.renderMode !== 'source')
// Artifacts have no URL to load — they render from the registry, never in a
// webview.
const isWebPreview =
target.kind !== 'artifact' &&
(target.kind === 'url' || (target.previewKind === 'html' && target.renderMode !== 'source'))
const currentLabel = compactUrl(currentUrl)
const previewLabel =
@ -643,7 +650,12 @@ export function PreviewPane({
)}
ref={hostRef}
/>
{!isWebPreview && <LocalFilePreview reloadKey={localReloadKey} target={target} />}
{!isWebPreview &&
(target.kind === 'artifact' ? (
<ArtifactPreview target={target} />
) : (
<LocalFilePreview reloadKey={localReloadKey} target={target} />
))}
{loadError && (
<PreviewLoadError
consoleHeight={consoleOpen ? consoleHeight : 0}

View file

@ -15,18 +15,10 @@ 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, selectRightRailTab } from '@/store/layout'
import {
$panesFlipped,
$rightRailActiveTabId,
RIGHT_RAIL_PREVIEW_TAB_ID,
type RightRailTabId,
selectRightRailTab
} from '@/store/layout'
import {
$filePreviewTabs,
$previewReloadRequest,
$previewTarget,
$previewTabs,
closeOtherRightRailTabs,
closeRightRail,
closeRightRailTab,
@ -35,7 +27,6 @@ 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'
@ -46,11 +37,12 @@ interface ChatPreviewRailProps {
setTitlebarToolGroup?: SetTitlebarToolGroup
}
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 {
// Artifacts are titled, not located — their label is the whole name.
if (target.kind === 'artifact') {
return target.label || translateNow('preview.tab')
}
const value = target.label || target.path || target.source || target.url
const tail = value.split(/[\\/]/).filter(Boolean).at(-1)
@ -62,46 +54,17 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
const previewReloadRequest = useStore($previewReloadRequest)
const activeTabId = useStore($rightRailActiveTabId)
const panesFlipped = useStore($panesFlipped)
const filePreviewTabs = useStore($filePreviewTabs)
const previewTarget = useStore($previewTarget)
const previewTabs = useStore($previewTabs)
const dirtyPreviewUrls = useStore($dirtyPreviewUrls)
const openArtifacts = useStore($openArtifacts)
const tabs = useMemo<readonly RailTab[]>(
() => [
...(previewTarget
? [
{
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,
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, openArtifacts, previewTarget, t.artifactPane.tabFallback, t.preview.tab]
const tabs = useMemo(
() =>
previewTabs.map(({ id, target }) => {
const label = tabLabelFor(target)
return { id, label, target, tooltip: target.kind === 'artifact' ? label : target.path || target.url || label }
}),
[previewTabs]
)
const activeTab = tabs.find(tab => tab.id === activeTabId) ?? tabs[0]
@ -116,7 +79,7 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
return null
}
const isPreview = activeTab.id === RIGHT_RAIL_PREVIEW_TAB_ID
const isPreview = activeTab.target.kind === 'url'
return (
<aside
@ -140,7 +103,7 @@ 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 = tab.kind === 'preview' && Boolean(dirtyPreviewUrls[tab.target.url])
const dirty = Boolean(dirtyPreviewUrls[tab.target.url])
return (
<ContextMenu key={tab.id}>
@ -155,7 +118,7 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
role="tab"
type="button"
>
{tab.kind === 'artifact' && (
{tab.target.kind === 'artifact' && (
<Codicon className="mr-1 shrink-0 text-[0.6875rem] opacity-70" name="sparkle" />
)}
{tab.label}
@ -192,17 +155,13 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
</div>
<div className="min-h-0 flex-1 overflow-hidden">
{activeTab.kind === 'artifact' ? (
<ArtifactPane artifactId={artifactIdFromTabId(activeTab.id) ?? activeTab.artifactId} />
) : (
<PreviewPane
embedded
onRestartServer={isPreview ? onRestartServer : undefined}
reloadRequest={previewReloadRequest}
setTitlebarToolGroup={setTitlebarToolGroup}
target={activeTab.target}
/>
)}
<PreviewPane
embedded
onRestartServer={isPreview ? onRestartServer : undefined}
reloadRequest={previewReloadRequest}
setTitlebarToolGroup={setTitlebarToolGroup}
target={activeTab.target}
/>
</div>
</aside>
)

View file

@ -39,7 +39,6 @@ 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,
@ -52,7 +51,7 @@ import {
SIDEBAR_DEFAULT_WIDTH,
SIDEBAR_MAX_WIDTH
} from '@/store/layout'
import { $filePreviewTabs, $filePreviewTarget, $previewTarget, closeRightRail } from '@/store/preview'
import { $previewOpenRequest, $previewTabs, 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'
@ -553,11 +552,8 @@ 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 (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
)
// by the open tabs instead of a toggle.
const $previewVisible = computed($previewTabs, tabs => tabs.length > 0)
bindPaneVisibility('preview', $previewVisible, closeRightRail)
@ -603,19 +599,10 @@ const revealPreview = () => {
revealTreePane('preview')
}
$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()
}
})
// Keyed on open REQUESTS, not on the tab list: re-opening a tab that already
// exists must still un-hide and front the pane, and closing one of two tabs
// must not.
$previewOpenRequest.listen(() => revealPreview())
// ---------------------------------------------------------------------------

View file

@ -27,8 +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 { $previewTarget, openPreview } from '@/store/preview'
import { $currentCwd } from '@/store/session'
// ---------------------------------------------------------------------------
@ -74,11 +73,9 @@ 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 && openArtifacts.length === 0) {
if (!previewTarget) {
return (
<div className="grid h-full place-items-center px-4 text-center">
<div className="flex flex-col items-center gap-1.5">
@ -111,7 +108,7 @@ function previewFile(path: string) {
void normalizeOrLocalPreviewTarget(path, $currentCwd.get() || undefined)
.then(target => {
if (target) {
setCurrentSessionPreviewTarget(target, 'file-browser', path)
openPreview(target, 'file-browser')
}
})
.catch(() => undefined)

View file

@ -32,7 +32,7 @@ import { latestSessionTodos } from '@/lib/todos'
import { $billingSettingsRequest } from '@/store/billing-block'
import { setCronFocusJobId } from '@/store/cron'
import { $pinnedSessionIds, pinSession, restoreWorktree, unpinSession } from '@/store/layout'
import { $filePreviewTarget, $previewTarget } from '@/store/preview'
import { $previewTarget } from '@/store/preview'
import { $activeGatewayProfile, $freshSessionRequest, $profileScope, refreshActiveProfile } from '@/store/profile'
import { $startWorkSessionRequest, followActiveSessionCwd } from '@/store/projects'
import {
@ -398,13 +398,9 @@ export function ContribWiring({ children }: { children: ReactNode }) {
// follows) + the preview server restart handler, layered over the base
// gateway event stream exactly like DesktopController.
const { handleDesktopGatewayEvent, restartPreviewServer } = usePreviewRouting({
activeSessionIdRef,
baseHandleGatewayEvent: handleGatewayEvent,
currentCwd,
currentView,
requestGateway,
routedSessionId,
selectedStoredSessionId
requestGateway
})
// Composer @-mention context suggestions (files/dirs under the cwd).
@ -724,11 +720,10 @@ export function ContribWiring({ children }: { children: ReactNode }) {
// deep links, native-notification nav, preview-shortcut enablement,
// remembered-session restore, and cross-window session-list sync.
const previewTarget = useStore($previewTarget)
const filePreviewTarget = useStore($filePreviewTarget)
useDesktopIntegrations({
chatOpen,
hasPreview: Boolean(filePreviewTarget || previewTarget),
hasPreview: Boolean(previewTarget),
locationPathname: location.pathname,
navigate,
refreshSessions,

View file

@ -12,7 +12,7 @@ import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import { cn } from '@/lib/utils'
import { $panesFlipped } from '@/store/layout'
import { notifyError } from '@/store/notifications'
import { setCurrentSessionPreviewTarget } from '@/store/preview'
import { openPreview } from '@/store/preview'
import { $currentCwd } from '@/store/session'
import { SidebarPanelLabel } from '../shell/sidebar-label'
@ -66,7 +66,7 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder }: RightSide
throw new Error(r.couldNotPreview(path))
}
setCurrentSessionPreviewTarget(preview, 'file-browser', path)
openPreview(preview, 'file-browser')
} catch (error) {
notifyError(error, r.previewUnavailable)
}

View file

@ -21,7 +21,7 @@ import { cn } from '@/lib/utils'
import { $renamingPath, copyFilePath, revealFile, toRelativePath } from '@/store/file-actions'
import { $sidebarWorkspaceNodeOpen, revealFileInTree, toggleWorkspaceNodeCollapsed } from '@/store/layout'
import { notifyError } from '@/store/notifications'
import { setCurrentSessionPreviewTarget } from '@/store/preview'
import { openPreview } from '@/store/preview'
import {
$reviewFiles,
$reviewLoading,
@ -277,7 +277,7 @@ function ReviewFileRow({ node, depth }: { node: ReviewTreeNode; depth: number })
const preview = await normalizeOrLocalPreviewTarget(dragPath)
if (preview) {
setCurrentSessionPreviewTarget(preview, 'file-browser', dragPath)
openPreview(preview, 'file-browser')
}
} catch (error) {
notifyError(error, t.rightSidebar.previewUnavailable)

View file

@ -8,7 +8,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { CSSProperties } from 'react'
import { triggerHaptic } from '@/lib/haptics'
import { $filePreviewTarget, $previewTarget } from '@/store/preview'
import { $previewTarget } from '@/store/preview'
import { useTheme } from '@/themes/context'
import { $terminalInjection } from '../store'
@ -62,7 +62,7 @@ type TerminalStatus = 'closed' | 'open' | 'starting'
// file's name instead of the shell, so the composer ref reads as a file quote
// rather than a bogus "zsh:N lines".
function previewSelectionLabel(): string {
const target = $filePreviewTarget.get() ?? $previewTarget.get()
const target = $previewTarget.get()
const source = target?.path || target?.url || ''
return source.split(/[\\/]/).filter(Boolean).pop() || target?.label?.trim() || ''

View file

@ -0,0 +1,177 @@
import { act, cleanup, render, waitFor } from '@testing-library/react'
import { useEffect } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { assistantTextPart, type ChatMessage } from '@/lib/chat-messages'
import { $previewTabs, $previewTarget, closeRightRail, type PreviewTarget } from '@/store/preview'
import { $activeSessionId, $currentCwd, $messages, $selectedStoredSessionId } from '@/store/session'
import type { RpcEvent } from '@/types/hermes'
import { usePreviewRouting } from './use-preview-routing'
const RUNTIME_SESSION_ID = '20260727_140707_edec2d'
function assistantMessage(id: string, text: string): ChatMessage {
return { id, parts: [assistantTextPart(text)], role: 'assistant' }
}
function fileTarget(path: string): PreviewTarget {
return { kind: 'file', label: path, path, previewKind: 'html', source: path, url: `file://${path}` }
}
let handleEvent: (event: RpcEvent) => void = () => undefined
function Harness() {
const routing = usePreviewRouting({
baseHandleGatewayEvent: vi.fn(),
currentCwd: '/work',
requestGateway: vi.fn()
})
useEffect(() => {
handleEvent = routing.handleDesktopGatewayEvent
}, [routing.handleDesktopGatewayEvent])
return null
}
async function emitPreviewOpen(url = '/tmp/artifact-test.html') {
await act(async () => {
handleEvent({
payload: { label: 'hi bestie', url },
session_id: RUNTIME_SESSION_ID,
type: 'preview.open'
} as unknown as RpcEvent)
})
}
describe('open_preview', () => {
beforeEach(() => {
// A live session always has a runtime id; only the STORED id lags.
$activeSessionId.set(RUNTIME_SESSION_ID)
$currentCwd.set('/work')
$messages.set([])
closeRightRail()
window.localStorage.clear()
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: { normalizePreviewTarget: vi.fn(async (target: string) => fileTarget(target)) }
})
})
afterEach(() => {
cleanup()
$messages.set([])
closeRightRail()
$activeSessionId.set(null)
$selectedStoredSessionId.set(null)
window.localStorage.clear()
vi.restoreAllMocks()
})
// The rail used to hold a session-keyed singleton alongside its tabs, written
// under one session-id rule and reconciled under another. A live session with
// no stored id yet resolved to '' on the write side, so the target was set and
// then immediately reconciled back to null — the pane flashed and vanished.
it('opens a tab for a session that has no stored id yet', async () => {
$selectedStoredSessionId.set(null)
render(<Harness />)
await emitPreviewOpen()
await waitFor(() => expect($previewTarget.get()?.path).toBe('/tmp/artifact-test.html'))
expect($previewTabs.get()).toHaveLength(1)
})
it('keeps the tab when the stored session id arrives afterwards', async () => {
$selectedStoredSessionId.set(null)
render(<Harness />)
await emitPreviewOpen()
await waitFor(() => expect($previewTarget.get()?.path).toBe('/tmp/artifact-test.html'))
await act(async () => {
$selectedStoredSessionId.set('stored-1')
})
expect($previewTarget.get()?.path).toBe('/tmp/artifact-test.html')
})
it('ignores an open from a session that is not the one on screen', async () => {
render(<Harness />)
await act(async () => {
handleEvent({
payload: { url: '/tmp/other.html' },
session_id: 'some-other-session',
type: 'preview.open'
} as unknown as RpcEvent)
})
expect($previewTabs.get()).toHaveLength(0)
})
it('opens a second target as its own tab rather than replacing the first', async () => {
render(<Harness />)
await emitPreviewOpen('/tmp/one.html')
await waitFor(() => expect($previewTabs.get()).toHaveLength(1))
await emitPreviewOpen('/tmp/two.html')
await waitFor(() => expect($previewTabs.get()).toHaveLength(2))
expect($previewTarget.get()?.path).toBe('/tmp/two.html')
})
it('re-fronts the existing tab when the same target opens twice', async () => {
render(<Harness />)
await emitPreviewOpen('/tmp/one.html')
await emitPreviewOpen('/tmp/one.html')
await waitFor(() => expect($previewTabs.get()).toHaveLength(1))
})
it('renders a tool-opened html file rather than showing its source', async () => {
render(<Harness />)
await emitPreviewOpen()
await waitFor(() => expect($previewTarget.get()?.renderMode).toBe('preview'))
})
// Offer, don't hijack: only an explicit open_preview call opens the rail.
it('does not infer a preview from assistant prose', async () => {
render(<Harness />)
await act(async () => {
$messages.set([
assistantMessage('a1', 'Preview: http://localhost:5173/'),
assistantMessage('a2', 'Open /work/demo.html')
])
})
expect($previewTabs.get()).toHaveLength(0)
expect(window.hermesDesktop.normalizePreviewTarget).not.toHaveBeenCalled()
})
it('does not open a preview off the back of a tool result', async () => {
render(<Harness />)
await act(async () => {
handleEvent({
payload: { inline_diff: 'a/preview-demo.html -> b/preview-demo.html\n' },
session_id: RUNTIME_SESSION_ID,
type: 'tool.complete'
} as unknown as RpcEvent)
handleEvent({
payload: { path: './dist/index.html' },
session_id: RUNTIME_SESSION_ID,
type: 'tool.complete'
} as unknown as RpcEvent)
})
expect($previewTabs.get()).toHaveLength(0)
})
})

View file

@ -1,184 +0,0 @@
import { act, cleanup, render, waitFor } from '@testing-library/react'
import { useEffect, useRef } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { assistantTextPart, type ChatMessage } from '@/lib/chat-messages'
import {
$previewTarget,
clearSessionPreviewRegistry,
type PreviewTarget,
registerSessionPreview
} from '@/store/preview'
import { $currentCwd, $messages } from '@/store/session'
import type { RpcEvent } from '@/types/hermes'
import { usePreviewRouting } from './use-preview-routing'
function assistantMessage(id: string, text: string): ChatMessage {
return {
id,
parts: [assistantTextPart(text)],
role: 'assistant'
}
}
function previewTarget(source: string): PreviewTarget {
const isUrl = /^https?:\/\//i.test(source)
return {
kind: isUrl ? 'url' : 'file',
label: source,
path: isUrl ? undefined : source,
previewKind: isUrl ? undefined : 'html',
source,
url: isUrl ? source : `file://${source}`
}
}
let handleEvent: (event: RpcEvent) => void = () => undefined
function PreviewRoutingHarness({ onEvent }: { onEvent: (handler: (event: RpcEvent) => void) => void }) {
const activeSessionIdRef = useRef<string | null>('session-1')
const routing = usePreviewRouting({
activeSessionIdRef,
baseHandleGatewayEvent: vi.fn(),
currentCwd: '/work',
currentView: 'chat',
requestGateway: vi.fn(),
routedSessionId: 'session-1',
selectedStoredSessionId: null
})
useEffect(() => {
onEvent(routing.handleDesktopGatewayEvent)
}, [onEvent, routing.handleDesktopGatewayEvent])
return null
}
describe('usePreviewRouting', () => {
beforeEach(() => {
$currentCwd.set('/work')
$messages.set([])
$previewTarget.set(null)
clearSessionPreviewRegistry()
handleEvent = () => undefined
window.localStorage.clear()
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: {
normalizePreviewTarget: vi.fn(async (target: string) => previewTarget(target))
}
})
})
afterEach(() => {
cleanup()
$messages.set([])
$previewTarget.set(null)
vi.restoreAllMocks()
clearSessionPreviewRegistry()
window.localStorage.clear()
})
it('opens the active session preview from the registry', async () => {
const target = previewTarget('/work/demo.html')
registerSessionPreview('session-1', target, 'tool-result')
render(
<PreviewRoutingHarness
onEvent={handler => {
handleEvent = handler
}}
/>
)
await waitFor(() => {
expect($previewTarget.get()).toEqual({ ...target, renderMode: 'preview' })
})
})
it('does not infer previews from assistant prose', async () => {
render(
<PreviewRoutingHarness
onEvent={handler => {
handleEvent = handler
}}
/>
)
act(() => {
$messages.set([
assistantMessage('a1', 'Preview: http://localhost:5173/'),
assistantMessage('a2', 'Open /work/demo.html')
])
})
expect($previewTarget.get()).toBeNull()
expect(window.hermesDesktop.normalizePreviewTarget).not.toHaveBeenCalled()
})
it('opens the preview pane on a preview.open event for the active session', async () => {
render(
<PreviewRoutingHarness
onEvent={handler => {
handleEvent = handler
}}
/>
)
act(() =>
handleEvent({
payload: { url: 'https://www.cnn.com', label: 'CNN' },
session_id: 'session-1',
type: 'preview.open'
})
)
await waitFor(() => {
expect($previewTarget.get()).toMatchObject({ kind: 'url', label: 'CNN', url: 'https://www.cnn.com' })
})
})
it('ignores a preview.open event for a background session', async () => {
render(
<PreviewRoutingHarness
onEvent={handler => {
handleEvent = handler
}}
/>
)
act(() =>
handleEvent({ payload: { url: 'https://www.cnn.com' }, session_id: 'other-session', type: 'preview.open' })
)
// Give any (wrongly) scheduled async open a tick to resolve before asserting.
await Promise.resolve()
expect($previewTarget.get()).toBeNull()
})
it('does not auto-open a preview from tool results', async () => {
render(
<PreviewRoutingHarness
onEvent={handler => {
handleEvent = handler
}}
/>
)
act(() =>
handleEvent({
payload: { inline_diff: '\u001b[38;2;218;165;32ma/preview-demo.html -> b/preview-demo.html\u001b[0m\n' },
session_id: 'session-1',
type: 'tool.complete'
})
)
act(() => handleEvent({ payload: { path: './dist/index.html' }, session_id: 'session-1', type: 'tool.complete' }))
expect($previewTarget.get()).toBeNull()
expect(window.localStorage.getItem('hermes.desktop.sessionPreviews.v1')).toBeNull()
})
})

View file

@ -1,77 +1,39 @@
import { useStore } from '@nanostores/react'
import { type MutableRefObject, useCallback, useEffect } from 'react'
import { useCallback } from 'react'
import { gatewayEventCompletedFileDiff } from '@/lib/gateway-events'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import {
$previewTarget,
$sessionPreviewRegistry,
$previewTabs,
beginPreviewServerRestart,
completePreviewServerRestart,
getSessionPreviewRecord,
openPreview,
progressPreviewServerRestart,
requestPreviewReload,
setCurrentSessionPreviewTarget,
setPreviewTarget
requestPreviewReload
} from '@/store/preview'
import { $currentCwd } from '@/store/session'
import { $focusedRuntimeId } from '@/store/session-states'
import type { RpcEvent } from '@/types/hermes'
type EventHandler = (event: RpcEvent) => void
interface PreviewRoutingOptions {
activeSessionIdRef: MutableRefObject<string | null>
baseHandleGatewayEvent: EventHandler
currentCwd: string
currentView: string
requestGateway: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
routedSessionId: string | null
selectedStoredSessionId: string | null
}
function asRecord(payload: unknown): Record<string, unknown> {
return payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
}
function activePreviewSessionId(
activeSessionIdRef: MutableRefObject<string | null>,
routedSessionId: string | null,
selectedStoredSessionId: string | null
): string {
return selectedStoredSessionId || routedSessionId || activeSessionIdRef.current || ''
}
export function usePreviewRouting({
activeSessionIdRef,
baseHandleGatewayEvent,
currentCwd,
currentView,
requestGateway,
routedSessionId,
selectedStoredSessionId
requestGateway
}: PreviewRoutingOptions) {
const previewRegistry = useStore($sessionPreviewRegistry)
const previewSessionId = activePreviewSessionId(activeSessionIdRef, routedSessionId, selectedStoredSessionId)
// Restore a *user-opened* preview when its session becomes active. Tool
// results no longer auto-register/open a preview — the inline preview card in
// the tool row is the only entry point, so HTML artifacts never pop the rail
// open on their own.
useEffect(() => {
if (currentView !== 'chat' || !previewSessionId) {
setPreviewTarget(null)
return
}
const record = getSessionPreviewRecord(previewSessionId)
setPreviewTarget(record?.normalized ?? null)
}, [currentView, previewRegistry, previewSessionId])
const restartPreviewServer = useCallback(
async (url: string, context?: string) => {
const sessionId = activeSessionIdRef.current
const sessionId = $focusedRuntimeId.get()
if (!sessionId) {
throw new Error('No active session for background restart')
@ -96,7 +58,7 @@ export function usePreviewRouting({
return taskId
},
[activeSessionIdRef, currentCwd, requestGateway]
[currentCwd, requestGateway]
)
const handleDesktopGatewayEvent = useCallback<EventHandler>(
@ -105,21 +67,20 @@ export function usePreviewRouting({
if (event.type === 'preview.open') {
// Agent-driven open in response to an explicit user request ("show
// cnn.com in the preview pane"). Honor it only for the active session —
// a background turn must not yank the pane open (see desktop AGENTS.md:
// offer, don't hijack). Routes through the same normalizer as the file
// cnn.com in the preview pane"). Honor it only for the session the user
// is actually looking at — a background turn must not yank the pane open
// (see desktop AGENTS.md: offer, don't hijack). That session is the
// focused one, which is a TILE's runtime whenever a tile is fronted, not
// the primary chat's. Routes through the same normalizer as the file
// browser so URLs, localhost, and file paths all resolve correctly.
const { url, label } = asRecord(event.payload)
const target = typeof url === 'string' ? url.trim() : ''
if (target && (!event.session_id || event.session_id === activeSessionIdRef.current)) {
if (target && (!event.session_id || event.session_id === $focusedRuntimeId.get())) {
void normalizeOrLocalPreviewTarget(target, $currentCwd.get() || currentCwd || undefined).then(resolved => {
if (resolved) {
const trimmedLabel = typeof label === 'string' ? label.trim() : ''
setCurrentSessionPreviewTarget(
trimmedLabel ? { ...resolved, label: trimmedLabel } : resolved,
'tool-result'
)
openPreview(trimmedLabel ? { ...resolved, label: trimmedLabel } : resolved, 'tool-result')
}
})
}
@ -141,18 +102,18 @@ export function usePreviewRouting({
}
}
if (event.session_id && event.session_id !== activeSessionIdRef.current) {
if (event.session_id && event.session_id !== $focusedRuntimeId.get()) {
return
}
// Only refresh an already-open live preview when a file changes; never
// open one unprompted. (Preview links are surfaced from the tool row into
// the status stack — see tool-fallback.tsx.)
if ($previewTarget.get()?.kind === 'url' && gatewayEventCompletedFileDiff(event)) {
if ($previewTabs.get().some(tab => tab.target.kind === 'url') && gatewayEventCompletedFileDiff(event)) {
requestPreviewReload()
}
},
[activeSessionIdRef, baseHandleGatewayEvent, currentCwd]
[baseHandleGatewayEvent, currentCwd]
)
return { handleDesktopGatewayEvent, restartPreviewServer }

View file

@ -9,13 +9,7 @@ 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'
import { $artifactRegistry, artifactsForSession, openArtifact, upsertArtifact } from '@/store/artifacts'
interface ArtifactCardProps {
code: string
@ -92,16 +86,11 @@ export function ArtifactCard({ code, detection, streaming = false }: ArtifactCar
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.
// clicked this specific iteration.
const versionIndex = result.record.versions.findIndex(version => version.content === trimmed)
if (versionIndex !== -1 && versionIndex < result.record.versions.length - 1) {
selectArtifactVersion(result.artifactId, versionIndex)
}
openArtifact(result.artifactId, versionIndex === -1 ? undefined : versionIndex)
}
return (

View file

@ -1,7 +1,8 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { $artifactTabs, artifactsForSession, clearArtifactRegistry } from '@/store/artifacts'
import { artifactsForSession, clearArtifactRegistry } from '@/store/artifacts'
import { $previewTabs } from '@/store/preview'
import { $activeSessionId, $selectedStoredSessionId } from '@/store/session'
import { MarkdownTextContent } from './markdown-text'
@ -51,7 +52,7 @@ describe('MarkdownTextContent artifacts', () => {
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)
expect($previewTabs.get()).toHaveLength(0)
})
it('keeps a small fence as a plain code block', async () => {

View file

@ -1503,10 +1503,7 @@ export const ar = defineLocale({
open: 'فتح'
},
artifactPane: {
tabFallback: 'ناتج',
modePreview: 'معاينة',
modeSource: 'المصدر',
artifactPreview: {
versionOf: (current, total) => `الإصدار ${current} من ${total}`,
olderVersion: 'إصدار أقدم',
newerVersion: 'إصدار أحدث',

View file

@ -1764,10 +1764,7 @@ export const en: Translations = {
open: 'Open'
},
artifactPane: {
tabFallback: 'Artifact',
modePreview: 'PREVIEW',
modeSource: 'SOURCE',
artifactPreview: {
versionOf: (current, total) => `v${current} of ${total}`,
olderVersion: 'Older version',
newerVersion: 'Newer version',

View file

@ -1628,10 +1628,7 @@ export const ja = defineLocale({
open: '開く'
},
artifactPane: {
tabFallback: 'アーティファクト',
modePreview: 'プレビュー',
modeSource: 'ソース',
artifactPreview: {
versionOf: (current, total) => `${total} 中 v${current}`,
olderVersion: '前のバージョン',
newerVersion: '次のバージョン',

View file

@ -1473,10 +1473,7 @@ export interface Translations {
open: string
}
artifactPane: {
tabFallback: string
modePreview: string
modeSource: string
artifactPreview: {
versionOf: (current: number, total: number) => string
olderVersion: string
newerVersion: string

View file

@ -1576,10 +1576,7 @@ export const zhHant = defineLocale({
open: '開啟'
},
artifactPane: {
tabFallback: '產物',
modePreview: '預覽',
modeSource: '原始碼',
artifactPreview: {
versionOf: (current, total) => `${current}/${total}`,
olderVersion: '較舊版本',
newerVersion: '較新版本',

View file

@ -1954,10 +1954,7 @@ export const zh: Translations = {
open: '打开'
},
artifactPane: {
tabFallback: '产物',
modePreview: '预览',
modeSource: '源码',
artifactPreview: {
versionOf: (current, total) => `${current}/${total}`,
olderVersion: '较旧版本',
newerVersion: '较新版本',

View file

@ -3,20 +3,17 @@ 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,
openArtifact,
selectArtifactVersion,
upsertArtifact
} from './artifacts'
import { $rightRailActiveTabId, PREVIEW_PANE_ID, RIGHT_RAIL_PREVIEW_TAB_ID } from './layout'
import { $rightRailActiveTabId, PREVIEW_PANE_ID } from './layout'
import { $paneOpen } from './panes'
import { $previewTabs, closeRightRail, closeRightRailTab } from './preview'
import { $activeSessionId, $selectedStoredSessionId } from './session'
const HTML_DETECTION: ArtifactDetection = { kind: 'html', language: 'html', title: 'Pomodoro Timer' }
@ -27,7 +24,7 @@ describe('artifacts store', () => {
$selectedStoredSessionId.set(null)
window.localStorage.clear()
clearArtifactRegistry()
$rightRailActiveTabId.set(RIGHT_RAIL_PREVIEW_TAB_ID)
closeRightRail()
})
afterEach(() => {
@ -83,40 +80,43 @@ describe('artifacts store', () => {
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', () => {
it('opens an artifact as a real rail tab that references the registry', () => {
const result = upsertArtifact('session-1', HTML_DETECTION, '<html>v1</html>')!
const tabId = artifactTabId(result.artifactId)
openArtifactTab(result.artifactId)
openArtifact(result.artifactId)
expect($artifactTabs.get()).toEqual([tabId])
expect($rightRailActiveTabId.get()).toBe(tabId)
const tab = $previewTabs.get()[0]!
expect(tab.target).toMatchObject({ kind: 'artifact', label: 'Pomodoro Timer', url: result.artifactId })
expect($rightRailActiveTabId.get()).toBe(tab.id)
expect($paneOpen(PREVIEW_PANE_ID).get()).toBe(true)
closeArtifactTab(tabId)
closeRightRailTab(tab.id)
expect($artifactTabs.get()).toEqual([])
expect($rightRailActiveTabId.get()).toBe(RIGHT_RAIL_PREVIEW_TAB_ID)
expect($previewTabs.get()).toEqual([])
expect($rightRailActiveTabId.get()).toBeNull()
})
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)
openArtifact(result.artifactId)
openArtifact(result.artifactId)
expect($artifactTabs.get()).toHaveLength(1)
expect($previewTabs.get()).toHaveLength(1)
})
it('keeps artifact tabs out of the persisted tab list', () => {
const result = upsertArtifact('session-1', HTML_DETECTION, '<html>v1</html>')!
openArtifact(result.artifactId)
expect(window.localStorage.getItem('hermes.desktop.previewTabs.v2')).toBe('[]')
})
it('tracks version selection and snaps back to latest', () => {
@ -140,31 +140,27 @@ describe('artifacts store', () => {
expect($artifactVersionSelection.get()[result.artifactId]).toBe(0)
})
it('reopening an artifact lands on the newest version', () => {
it('opens at the newest version by default and at a pinned one on request', () => {
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)
openArtifact(result.artifactId, 0)
expect($artifactVersionSelection.get()[result.artifactId]).toBe(0)
openArtifact(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>')
it('clearing the registry closes the tabs pointing into it', () => {
const result = upsertArtifact('session-1', HTML_DETECTION, '<html>v1</html>')!
const raw = window.localStorage.getItem('hermes.desktop.artifacts.v1')!
const parsed = JSON.parse(raw) as Record<string, unknown[]>
openArtifact(result.artifactId)
clearArtifactRegistry()
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>')
expect($previewTabs.get()).toEqual([])
expect(artifactsForSession('session-1')).toEqual([])
})
})

View file

@ -1,23 +1,24 @@
import { atom, computed } from 'nanostores'
import { atom } 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'
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, 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.
* 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 {
@ -39,99 +40,11 @@ export interface ArtifactRecord {
versions: ArtifactVersion[]
}
type ArtifactRegistry = Record<string, ArtifactRecord[]>
export 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)
@ -154,36 +67,15 @@ function pruneRegistry(registry: ArtifactRegistry): ArtifactRegistry {
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) }))
])
)
)
}
)
export const $artifactRegistry = atom<ArtifactRegistry>({})
/** 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. */
/** Per-artifact selected version index; absent = newest. */
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())) {
/** 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) {
@ -194,24 +86,9 @@ export function getArtifact(artifactId: string): ArtifactRecord | null {
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 getArtifact(artifactId: string): ArtifactRecord | null {
return findArtifact($artifactRegistry.get(), artifactId)
}
export function artifactsForSession(sessionId: string | null | undefined): ArtifactRecord[] {
const id = sessionId?.trim()
@ -301,56 +178,24 @@ export function upsertArtifact(
return { artifactId: record.id, record, versionAdded: true }
}
export function upsertCurrentSessionArtifact(detection: ArtifactDetection, content: string): UpsertResult | null {
return upsertArtifact(currentArtifactSessionId(), detection, content)
/** 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 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()
/** 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 (!current.includes(tabId)) {
$artifactTabs.set([...current, tabId])
if (!record) {
return
}
// 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
selectArtifactVersion(artifactId, versionIndex ?? record.versions.length - 1)
openPreview(artifactPreviewTarget(record))
}
export function selectArtifactVersion(artifactId: string, versionIndex: number) {
@ -375,12 +220,8 @@ export function selectArtifactVersion(artifactId: string, versionIndex: number)
$artifactVersionSelection.set({ ...selection, [artifactId]: clamped })
}
export function closeAllArtifactTabs() {
$artifactTabs.set([])
$artifactVersionSelection.set({})
}
export function clearArtifactRegistry() {
$artifactRegistry.set({})
closeAllArtifactTabs()
$artifactVersionSelection.set({})
closeArtifactPreviewTabs()
}

View file

@ -3,7 +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 { clearArtifactRegistry } from '@/store/artifacts'
import { resetSessionsLimit } from '@/store/layout'
import {
$unreadFinishedSessionIds,
@ -62,9 +62,9 @@ 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()
// Artifacts are keyed by sessions on the previous backend, so both the
// registry and any rail tab pointing into it go with them.
clearArtifactRegistry()
// Narrowed: account/marketplace/onboarding caches are global, not gateway-
// scoped, so a mode swap must not refetch them.

View file

@ -38,9 +38,10 @@ const RIGHT_RAIL_ACTIVE_TAB_STORAGE_KEY = 'hermes.desktop.rightRailActiveTab'
export const CHAT_SIDEBAR_PANE_ID = 'chat-sidebar'
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 | `artifact:${string}` | `file:${string}`
/** Every rail tab is a preview of something, namespaced by what backs it: a
* path on disk, a live URL, or an id into the in-memory artifact registry. */
export type RightRailTabId = `artifact:${string}` | `file:${string}` | `url:${string}`
ensurePaneRegistered(CHAT_SIDEBAR_PANE_ID, { open: true })
ensurePaneRegistered(FILE_BROWSER_PANE_ID, { open: false })
@ -56,13 +57,12 @@ export const $fileBrowserOpen: ReadableAtom<boolean> = computed(
states => states[FILE_BROWSER_PANE_ID]?.open ?? false
)
// Persisted so a relaunch reopens the same rail tab. A restored file-tab id with
// no matching tab is reconciled back to the preview tab in the preview store.
export const $rightRailActiveTabId = persistentAtom<RightRailTabId>(
RIGHT_RAIL_ACTIVE_TAB_STORAGE_KEY,
RIGHT_RAIL_PREVIEW_TAB_ID,
{ decode: raw => raw as RightRailTabId, encode: tabId => tabId }
)
// Persisted so a relaunch reopens the same rail tab. Null when the rail has no
// tabs; a restored id with no matching tab is reconciled in the preview store.
export const $rightRailActiveTabId = persistentAtom<RightRailTabId | null>(RIGHT_RAIL_ACTIVE_TAB_STORAGE_KEY, null, {
decode: raw => (raw ? (raw as RightRailTabId) : null),
encode: tabId => tabId ?? ''
})
export const $sidebarWidth: ReadableAtom<number> = computed($paneStates, states => {
const override = states[CHAT_SIDEBAR_PANE_ID]?.widthOverride
@ -308,7 +308,7 @@ export function togglePanesFlipped() {
$panesFlipped.set(!$panesFlipped.get())
}
export function selectRightRailTab(id: RightRailTabId) {
export function selectRightRailTab(id: RightRailTabId | null) {
$rightRailActiveTabId.set(id)
}

View file

@ -1,54 +1,45 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { $rightRailActiveTabId, PREVIEW_PANE_ID, RIGHT_RAIL_PREVIEW_TAB_ID } from './layout'
import { $rightRailActiveTabId, PREVIEW_PANE_ID } from './layout'
import { $paneOpen } from './panes'
import {
$filePreviewTabs,
$filePreviewTarget,
$previewServerRestart,
$previewServerRestartStatus,
$previewTabs,
$previewTarget,
$sessionPreviewRegistry,
beginPreviewServerRestart,
clearSessionPreviewRegistry,
closeActiveRightRailTab,
dismissPreviewTarget,
getSessionPreviewRecord,
closePreviewForSource,
closeRightRail,
closeRightRailTab,
openPreview,
previewTabId,
type PreviewTarget,
progressPreviewServerRestart,
setCurrentSessionPreviewTarget
progressPreviewServerRestart
} from './preview'
import { $activeSessionId, $selectedStoredSessionId } from './session'
function previewTarget(source: string): PreviewTarget {
return {
kind: 'file',
label: source,
path: source,
previewKind: 'html',
source,
url: `file://${source}`
}
function fileTarget(source: string): PreviewTarget {
return { kind: 'file', label: source, path: source, previewKind: 'html', source, url: `file://${source}` }
}
function withRenderMode(target: PreviewTarget, renderMode: PreviewTarget['renderMode']): PreviewTarget {
return { ...target, renderMode }
function urlTarget(source: string): PreviewTarget {
return { kind: 'url', label: source, source, url: source }
}
function artifactTarget(id: string): PreviewTarget {
return { kind: 'artifact', label: id, source: id, url: id }
}
describe('preview store', () => {
beforeEach(() => {
$previewServerRestart.set(null)
$activeSessionId.set('session-1')
$selectedStoredSessionId.set(null)
closeRightRail()
window.localStorage.clear()
clearSessionPreviewRegistry()
})
afterEach(() => {
$previewServerRestart.set(null)
$activeSessionId.set(null)
$selectedStoredSessionId.set(null)
clearSessionPreviewRegistry()
closeRightRail()
window.localStorage.clear()
})
@ -64,75 +55,82 @@ describe('preview store', () => {
expect(statuses).toEqual(['idle', 'running'])
})
it('persists registered previews and dismissal per session', () => {
const target = previewTarget('/work/demo.html')
it('opens the pane and fronts the new tab', () => {
openPreview(fileTarget('/work/demo.html'), 'tool-result')
setCurrentSessionPreviewTarget(target, 'tool-result')
expect($previewTarget.get()).toEqual(withRenderMode(target, 'preview'))
expect($paneOpen(PREVIEW_PANE_ID).get()).toBe(true)
expect(getSessionPreviewRecord('session-1')?.normalized).toEqual(withRenderMode(target, 'preview'))
expect(window.localStorage.getItem('hermes.desktop.sessionPreviews.v1')).toContain('/work/demo.html')
expect($rightRailActiveTabId.get()).toBe('file:file:///work/demo.html')
expect($previewTarget.get()?.path).toBe('/work/demo.html')
})
dismissPreviewTarget()
it('gives every kind of target its own tab, side by side', () => {
openPreview(fileTarget('/work/demo.html'), 'file-browser')
openPreview(urlTarget('http://localhost:5174'), 'tool-result')
openPreview(artifactTarget('session-1:dashboard'))
expect($previewTabs.get().map(tab => tab.target.kind)).toEqual(['file', 'url', 'artifact'])
})
it('re-fronts an existing tab instead of duplicating it, refreshing its target', () => {
openPreview({ ...fileTarget('/work/demo.html'), label: 'old' }, 'file-browser')
openPreview({ ...fileTarget('/work/demo.html'), label: 'new' }, 'file-browser')
expect($previewTabs.get()).toHaveLength(1)
expect($previewTarget.get()?.label).toBe('new')
})
// Browsing to an HTML file means "let me read it"; a tool or link handing you
// one means "run it". Same road, different render mode on the target.
it('renders browsed html as source and handed-over html live', () => {
openPreview(fileTarget('/work/browsed.html'), 'file-browser')
expect($previewTarget.get()?.renderMode).toBe('source')
openPreview(fileTarget('/work/handed.html'), 'tool-result')
expect($previewTarget.get()?.renderMode).toBe('preview')
})
it('falls back to a neighbouring tab when the active one closes, and shuts the pane on the last', () => {
openPreview(fileTarget('/work/one.html'), 'file-browser')
openPreview(fileTarget('/work/two.html'), 'file-browser')
closeRightRailTab(previewTabId(fileTarget('/work/two.html')))
expect($previewTarget.get()?.path).toBe('/work/one.html')
expect($paneOpen(PREVIEW_PANE_ID).get()).toBe(true)
expect(closeActiveRightRailTab()).toBe(true)
expect($previewTarget.get()).toBeNull()
expect($rightRailActiveTabId.get()).toBeNull()
expect($paneOpen(PREVIEW_PANE_ID).get()).toBe(false)
expect(getSessionPreviewRecord('session-1')).toBeNull()
expect($sessionPreviewRegistry.get()['session-1']?.[0]?.dismissedAt).toEqual(expect.any(Number))
setCurrentSessionPreviewTarget(target, 'tool-result')
expect(getSessionPreviewRecord('session-1')?.dismissedAt).toBeUndefined()
})
it('replaces the session preview instead of keeping a back stack', () => {
const first = previewTarget('/work/first.html')
const second = previewTarget('/work/second.html')
setCurrentSessionPreviewTarget(first, 'tool-result')
setCurrentSessionPreviewTarget(second, 'tool-result')
expect($sessionPreviewRegistry.get()['session-1']).toHaveLength(1)
expect(getSessionPreviewRecord('session-1')?.normalized).toEqual(withRenderMode(second, 'preview'))
dismissPreviewTarget()
expect($previewTarget.get()).toBeNull()
expect(getSessionPreviewRecord('session-1')).toBeNull()
expect($sessionPreviewRegistry.get()['session-1']?.map(record => record.normalized.url)).toEqual([
'file:///work/second.html'
])
it('reports nothing to close when the rail is empty, so the shortcut falls through', () => {
expect(closeActiveRightRailTab()).toBe(false)
})
it('keeps file inspection separate from live preview', () => {
const target = previewTarget('/work/demo.html')
const preview = previewTarget('/work/live.html')
it('closes by the raw source the composer rows were handed', () => {
openPreview(urlTarget('http://localhost:5174'), 'tool-result')
setCurrentSessionPreviewTarget(preview, 'tool-result')
setCurrentSessionPreviewTarget(target, 'manual')
expect($filePreviewTarget.get()).toEqual(withRenderMode(target, 'source'))
expect($previewTarget.get()).toEqual(withRenderMode(preview, 'preview'))
expect(getSessionPreviewRecord('session-1')?.normalized).toEqual(withRenderMode(preview, 'preview'))
closeActiveRightRailTab()
expect($filePreviewTarget.get()).toBeNull()
expect($previewTarget.get()).toEqual(withRenderMode(preview, 'preview'))
expect(closePreviewForSource('http://localhost:5174')).toBe(true)
expect($previewTabs.get()).toHaveLength(0)
expect(closePreviewForSource('http://localhost:5174')).toBe(false)
})
it('keeps file tabs when a live preview opens', () => {
const file = previewTarget('/work/file.html')
const live = previewTarget('/work/live.html')
it('persists file and url tabs but never artifacts, whose content is memory-only', () => {
openPreview(fileTarget('/work/demo.html'), 'file-browser')
openPreview(urlTarget('http://localhost:5174'), 'tool-result')
openPreview(artifactTarget('session-1:dashboard'))
setCurrentSessionPreviewTarget(file, 'manual')
setCurrentSessionPreviewTarget(live, 'tool-result')
const stored = window.localStorage.getItem('hermes.desktop.previewTabs.v2') ?? ''
expect($filePreviewTabs.get().map(tab => tab.target)).toEqual([withRenderMode(file, 'source')])
expect($filePreviewTarget.get()).toBeNull()
expect($rightRailActiveTabId.get()).toBe(RIGHT_RAIL_PREVIEW_TAB_ID)
expect($previewTarget.get()).toEqual(withRenderMode(live, 'preview'))
expect(stored).toContain('/work/demo.html')
expect(stored).toContain('localhost:5174')
expect(stored).not.toContain('dashboard')
})
it('strips inline image bytes rather than pushing megabytes into storage', () => {
openPreview({ ...fileTarget('/work/shot.png'), dataUrl: 'data:image/png;base64,AAAA', previewKind: 'image' })
expect(window.localStorage.getItem('hermes.desktop.previewTabs.v2') ?? '').not.toContain('base64')
})
})

View file

@ -3,16 +3,21 @@ 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,
RIGHT_RAIL_PREVIEW_TAB_ID,
type RightRailTabId,
selectRightRailTab
} from './layout'
import { $rightRailActiveTabId, PREVIEW_PANE_ID, type RightRailTabId, selectRightRailTab } from './layout'
import { setPaneOpen } from './panes'
import { $activeSessionId, $selectedStoredSessionId } from './session'
/**
* PREVIEW RAIL one list of tabs, one way in.
*
* Everything the rail can show is a `PreviewTarget` in `$previewTabs`: a file
* on disk, a live URL, or a generated artifact. There is no privileged "live
* preview" slot alongside the tabs; `openPreview` is the only entry point, so
* a tool result, a file-browser click, and an artifact card all travel the
* same road and behave identically once open.
*
* Tabs are global and outlive the session that created them, like tabs
* anywhere else they close when you close them.
*/
export interface PreviewTarget {
binary?: boolean
@ -20,9 +25,12 @@ export interface PreviewTarget {
/** Inline image bytes (a `data:` URL) when the renderer already holds them
* e.g. a pasted/dropped screenshot whose only on-disk copy is a transient
* path the preview can't reliably re-read. Rendered directly and NOT
* persisted to the session-preview registry (it would bloat localStorage). */
* persisted (it would bloat localStorage). */
dataUrl?: string
kind: 'file' | 'url'
/** `artifact` targets have nothing behind them on disk or on the network
* `url` is an id into the artifact registry, which owns the content. They
* are what lets the rail preview generated HTML the workspace never saw. */
kind: 'artifact' | 'file' | 'url'
label: string
large?: boolean
language?: string
@ -41,132 +49,109 @@ export interface PreviewServerRestart {
url: string
}
/** Where an open came from. Only affects how an HTML file is first rendered:
* browsing files is "peek at the source", a tool/link handing you something is
* "run it". Not a separate code path just a property of the target. */
export type PreviewRecordSource = 'explicit-link' | 'file-browser' | 'manual' | 'tool-result'
export interface SessionPreviewRecord {
autoOpen?: boolean
createdAt: number
dismissedAt?: number
id: string
normalized: PreviewTarget
sessionId: string
source: PreviewRecordSource
target: string
}
type SessionPreviewRegistry = Record<string, SessionPreviewRecord[]>
export interface FilePreviewTab {
id: `file:${string}`
export interface PreviewTab {
id: RightRailTabId
target: PreviewTarget
}
const REGISTRY_STORAGE_KEY = 'hermes.desktop.sessionPreviews.v1'
const TABS_STORAGE_KEY = 'hermes.desktop.filePreviewTabs.v1'
const MAX_RECORDS_PER_SESSION = 1
const MAX_SESSIONS = 120
const TABS_STORAGE_KEY = 'hermes.desktop.previewTabs.v2'
/** Superseded by the tab list above; cleared so it can't leak forever. */
const LEGACY_SESSION_REGISTRY_KEY = 'hermes.desktop.sessionPreviews.v1'
export const $previewTarget = atom<PreviewTarget | null>(null)
// Persisted so open file-preview tabs survive a relaunch; content is re-read
// from each target's path/url on demand. Invalid rows are dropped on load and
// inline image bytes (megabytes) are stripped on save, mirroring the registry.
export const $filePreviewTabs = persistentAtom<FilePreviewTab[]>(TABS_STORAGE_KEY, [], {
decode: raw => {
const parsed = JSON.parse(raw) as unknown
return Array.isArray(parsed) ? parsed.filter(isFilePreviewTab) : []
},
encode: tabs => JSON.stringify(tabs, (key, value) => (key === 'dataUrl' ? undefined : value))
})
// Drop a restored active file-tab that didn't survive validation so the rail
// 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('artifact:')
) {
selectRightRailTab(RIGHT_RAIL_PREVIEW_TAB_ID)
}
// Inverse: persisted/default active id is still the live-preview tab, but that
// target isn't open and file tabs are. Point at the first file tab so ⌘W and
// the strip agree before React's fallback sync runs.
if ($rightRailActiveTabId.get() === RIGHT_RAIL_PREVIEW_TAB_ID && $filePreviewTabs.get().length > 0) {
selectRightRailTab($filePreviewTabs.get()[0]!.id)
}
export const $filePreviewTarget = computed([$filePreviewTabs, $rightRailActiveTabId], (tabs, activeTabId) => {
if (!activeTabId.startsWith('file:')) {
return null
}
return tabs.find(tab => tab.id === activeTabId)?.target ?? null
})
export const $previewReloadRequest = atom(0)
export const $previewServerRestart = atom<PreviewServerRestart | null>(null)
export const $previewServerRestartStatus = computed($previewServerRestart, restart => restart?.status ?? 'idle')
export const $sessionPreviewRegistry = atom<SessionPreviewRegistry>(loadSessionPreviewRegistry())
$sessionPreviewRegistry.subscribe(persistSessionPreviewRegistry)
function isSamePreviewTarget(a: PreviewTarget | null, b: PreviewTarget | null): boolean {
if (a === b) {
return true
}
if (!a || !b) {
function isPreviewTarget(value: unknown): value is PreviewTarget {
if (!value || typeof value !== 'object') {
return false
}
const r = value as Record<string, unknown>
return (
a.kind === b.kind &&
a.label === b.label &&
a.renderMode === b.renderMode &&
a.source === b.source &&
a.url === b.url
(r.kind === 'artifact' || r.kind === 'file' || r.kind === 'url') &&
typeof r.label === 'string' &&
typeof r.source === 'string' &&
typeof r.url === 'string'
)
}
function showLivePreviewTab() {
setPaneOpen(PREVIEW_PANE_ID, true)
selectRightRailTab(RIGHT_RAIL_PREVIEW_TAB_ID)
}
export function setPreviewTarget(target: PreviewTarget | null) {
if (isSamePreviewTarget($previewTarget.get(), target)) {
if (target) {
showLivePreviewTab()
}
return
// Artifact tabs are never written (their registry is memory-only), so a
// restored artifact row is stale storage — drop it rather than reviving a tab
// with nothing behind it.
function isPreviewTab(value: unknown): value is PreviewTab {
if (!value || typeof value !== 'object') {
return false
}
$previewTarget.set(target)
const r = value as Record<string, unknown>
if (target) {
showLivePreviewTab()
return typeof r.id === 'string' && (r.id.startsWith('file:') || r.id.startsWith('url:')) && isPreviewTarget(r.target)
}
export const $previewTabs = persistentAtom<PreviewTab[]>(TABS_STORAGE_KEY, [], {
decode: raw => {
const parsed = JSON.parse(raw) as unknown
return Array.isArray(parsed) ? parsed.filter(isPreviewTab) : []
},
// Inline image bytes (megabytes) are stripped, and artifact tabs are skipped
// entirely — the registry behind them doesn't survive a reload either.
encode: tabs =>
JSON.stringify(
tabs.filter(tab => tab.target.kind !== 'artifact'),
(key, value) => (key === 'dataUrl' ? undefined : value)
)
})
if (typeof window !== 'undefined') {
try {
window.localStorage.removeItem(LEGACY_SESSION_REGISTRY_KEY)
} catch {
// Storage access can throw in locked-down contexts; nothing depends on it.
}
}
export function filePreviewTabId(target: PreviewTarget): `file:${string}` {
return `file:${target.url}`
/** The tab the rail actually shows. A stale or missing selection falls back to
* the first tab, so the strip, `⌘W`, and the pane never disagree about which
* tab is on screen. */
function resolveActiveTab(tabs: PreviewTab[], activeTabId: RightRailTabId | null): PreviewTab | null {
return tabs.find(tab => tab.id === activeTabId) ?? tabs[0] ?? null
}
function openFilePreviewTarget(target: PreviewTarget) {
const id = filePreviewTabId(target)
const current = $filePreviewTabs.get()
const index = current.findIndex(tab => tab.id === id)
const tab: FilePreviewTab = { id, target }
$filePreviewTabs.set(index === -1 ? [...current, tab] : current.map((item, i) => (i === index ? tab : item)))
setPaneOpen(PREVIEW_PANE_ID, true)
selectRightRailTab(id)
function activePreviewTab(): PreviewTab | null {
return resolveActiveTab($previewTabs.get(), $rightRailActiveTabId.get())
}
// Manual/file-browser opens are "peeking at a file" → source view in the file
// pane. Tool/explicit-link opens are runnable artifacts → live preview pane.
// A restored active id whose tab didn't survive validation would leave the rail
// pointing at nothing.
selectRightRailTab(activePreviewTab()?.id ?? null)
/** The target the rail is currently showing, or null when it has no tabs. */
export const $previewTarget = computed(
[$previewTabs, $rightRailActiveTabId],
(tabs, activeTabId) => resolveActiveTab(tabs, activeTabId)?.target ?? null
)
/** Raw `source` strings of every open tab, for the composer rows that toggle a
* preview open and closed by the target they were handed. */
export const $previewTabSources = computed($previewTabs, tabs => tabs.map(tab => tab.target.source))
export const $previewReloadRequest = atom(0)
/** Bumped by every `openPreview` call so the layout can reveal the pane even
* when the tab already existed (re-opening a hidden pane must still show it). */
export const $previewOpenRequest = atom(0)
export const $previewServerRestart = atom<PreviewServerRestart | null>(null)
export const $previewServerRestartStatus = computed($previewServerRestart, restart => restart?.status ?? 'idle')
export function previewTabId(target: PreviewTarget): RightRailTabId {
return `${target.kind}:${target.url}`
}
// Browsing files is "peek at the source"; a tool or an explicit link handing
// you an HTML file means "run it".
function isFilePreviewSource(source: PreviewRecordSource): boolean {
return source === 'file-browser' || source === 'manual'
}
@ -179,266 +164,24 @@ function previewTargetForSource(target: PreviewTarget, source: PreviewRecordSour
return { ...target, renderMode: isFilePreviewSource(source) ? 'source' : 'preview' }
}
function tryOpenFilePreview(target: PreviewTarget, source: PreviewRecordSource): boolean {
if (target.kind !== 'file' || !isFilePreviewSource(source)) {
return false
}
/** Open (or re-front) the rail tab for `target`. Re-opening an existing tab
* refreshes its target so a stale label/path can't outlive the thing it
* points at. The only way anything reaches the preview rail. */
export function openPreview(target: PreviewTarget, source: PreviewRecordSource = 'manual') {
const resolved = previewTargetForSource(target, source)
const id = previewTabId(resolved)
const current = $previewTabs.get()
const index = current.findIndex(tab => tab.id === id)
const tab: PreviewTab = { id, target: resolved }
openFilePreviewTarget(previewTargetForSource(target, source))
return true
$previewTabs.set(index === -1 ? [...current, tab] : current.map((item, i) => (i === index ? tab : item)))
setPaneOpen(PREVIEW_PANE_ID, true)
selectRightRailTab(id)
$previewOpenRequest.set($previewOpenRequest.get() + 1)
}
function isPreviewTarget(value: unknown): value is PreviewTarget {
if (!value || typeof value !== 'object') {
return false
}
const r = value as Record<string, unknown>
return (
(r.kind === 'file' || r.kind === 'url') &&
typeof r.label === 'string' &&
typeof r.source === 'string' &&
typeof r.url === 'string'
)
}
function isFilePreviewTab(value: unknown): value is FilePreviewTab {
if (!value || typeof value !== 'object') {
return false
}
const r = value as Record<string, unknown>
return typeof r.id === 'string' && r.id.startsWith('file:') && isPreviewTarget(r.target)
}
function isPreviewRecord(value: unknown): value is SessionPreviewRecord {
if (!value || typeof value !== 'object') {
return false
}
const r = value as Record<string, unknown>
return (
typeof r.createdAt === 'number' &&
typeof r.id === 'string' &&
isPreviewTarget(r.normalized) &&
typeof r.sessionId === 'string' &&
['explicit-link', 'file-browser', 'manual', 'tool-result'].includes(String(r.source)) &&
typeof r.target === 'string' &&
(r.dismissedAt === undefined || typeof r.dismissedAt === 'number')
)
}
function loadSessionPreviewRegistry(): SessionPreviewRegistry {
if (typeof window === 'undefined') {
return {}
}
try {
const raw = window.localStorage.getItem(REGISTRY_STORAGE_KEY)
if (!raw) {
return {}
}
const parsed = JSON.parse(raw) as unknown
if (!parsed || typeof parsed !== 'object') {
return {}
}
const out: SessionPreviewRegistry = {}
for (const [sessionId, records] of Object.entries(parsed as Record<string, unknown>)) {
if (!Array.isArray(records)) {
continue
}
const valid = records.filter(isPreviewRecord).slice(0, MAX_RECORDS_PER_SESSION)
if (valid.length > 0) {
out[sessionId] = valid
}
}
return pruneRegistry(out)
} catch {
return {}
}
}
function persistSessionPreviewRegistry(registry: SessionPreviewRegistry) {
if (typeof window === 'undefined') {
return
}
try {
// Drop the inline image bytes before persisting — a screenshot data URL is
// megabytes and would blow the localStorage quota. On reload the record
// falls back to reading its `path`/`url`.
const lean = JSON.stringify(pruneRegistry(registry), (key, value) => (key === 'dataUrl' ? undefined : value))
window.localStorage.setItem(REGISTRY_STORAGE_KEY, lean)
} catch {
// Session previews are a desktop convenience; storage failures are nonfatal.
}
}
function pruneRegistry(registry: SessionPreviewRegistry): SessionPreviewRegistry {
const entries = Object.entries(registry)
.map(
([sessionId, records]) =>
[sessionId, [...records].sort((a, b) => b.createdAt - a.createdAt).slice(0, MAX_RECORDS_PER_SESSION)] as const
)
.filter(([, records]) => records.length > 0)
.sort(([, a], [, b]) => (b[0]?.createdAt ?? 0) - (a[0]?.createdAt ?? 0))
.slice(0, MAX_SESSIONS)
return Object.fromEntries(entries)
}
function currentPreviewSessionId(): string {
return $selectedStoredSessionId.get() || $activeSessionId.get() || ''
}
function recordId(sessionId: string, target: PreviewTarget): string {
return `${sessionId}:${target.url}`
}
export function registerSessionPreview(
sessionId: string | null | undefined,
target: PreviewTarget,
source: PreviewRecordSource,
rawTarget = target.source
): SessionPreviewRecord | null {
const id = sessionId?.trim()
if (!id) {
return null
}
const current = $sessionPreviewRegistry.get()
const now = Date.now()
const records = current[id] ?? []
const existing = records.find(record => record.normalized.url === target.url)
const normalized = previewTargetForSource(target, source)
const nextRecord: SessionPreviewRecord = {
autoOpen: true,
createdAt: now,
id: existing?.id || recordId(id, target),
normalized,
sessionId: id,
source,
target: rawTarget || target.source
}
$sessionPreviewRegistry.set(
pruneRegistry({
...current,
[id]: [nextRecord]
})
)
return nextRecord
}
export function setSessionPreviewTarget(
sessionId: string | null | undefined,
target: PreviewTarget,
source: PreviewRecordSource,
rawTarget = target.source
): SessionPreviewRecord | null {
if (tryOpenFilePreview(target, source)) {
return null
}
const record = registerSessionPreview(sessionId, target, source, rawTarget)
setPreviewTarget(record?.normalized ?? previewTargetForSource(target, source))
return record
}
export function setCurrentSessionPreviewTarget(
target: PreviewTarget,
source: PreviewRecordSource,
rawTarget = target.source
): SessionPreviewRecord | null {
return setSessionPreviewTarget(currentPreviewSessionId(), target, source, rawTarget)
}
export function getSessionPreviewRecord(sessionId: string | null | undefined): SessionPreviewRecord | null {
const id = sessionId?.trim()
if (!id) {
return null
}
return $sessionPreviewRegistry.get()[id]?.find(record => !record.dismissedAt && record.autoOpen !== false) ?? null
}
export function dismissSessionPreview(sessionId: string | null | undefined, url?: string) {
const id = sessionId?.trim()
if (!id) {
return
}
const current = $sessionPreviewRegistry.get()
const records = current[id]
if (!records?.length) {
return
}
const now = Date.now()
const targetUrl = url || records.find(record => !record.dismissedAt)?.normalized.url
if (!targetUrl) {
return
}
// The preview rail is a single active file, not a back stack. Dismissing the
// current preview should leave the rail closed instead of revealing an older
// record for the same session.
const dismissedRecords = records.map(record => ({
...record,
autoOpen: false,
dismissedAt: now
}))
$sessionPreviewRegistry.set({
...current,
[id]: dismissedRecords
})
}
/** User clicked the close X — clear the target and persist dismissal for the current session. */
export function dismissPreviewTarget() {
const current = $previewTarget.get()
if (current?.url) {
dismissSessionPreview(currentPreviewSessionId(), current.url)
}
$previewTarget.set(null)
if ($rightRailActiveTabId.get() === 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 || $artifactTabs.get().length > 0)
}
function closeFilePreviewTab(tabId: RightRailTabId) {
if (!tabId.startsWith('file:')) {
return
}
const current = $filePreviewTabs.get()
export function closeRightRailTab(tabId: RightRailTabId) {
const current = $previewTabs.get()
const index = current.findIndex(tab => tab.id === tabId)
if (index === -1) {
@ -447,112 +190,59 @@ function closeFilePreviewTab(tabId: RightRailTabId) {
const next = current.filter(tab => tab.id !== tabId)
$filePreviewTabs.set(next)
$previewTabs.set(next)
if ($rightRailActiveTabId.get() === tabId) {
selectRightRailTab(
next[Math.min(index, next.length - 1)]?.id ?? $artifactTabs.get()[0] ?? RIGHT_RAIL_PREVIEW_TAB_ID
)
selectRightRailTab(next[Math.min(index, next.length - 1)]?.id ?? null)
}
if (next.length === 0 && !$previewTarget.get() && $artifactTabs.get().length === 0) {
if (next.length === 0) {
setPaneOpen(PREVIEW_PANE_ID, false)
}
}
export function closeRightRailTab(tabId: RightRailTabId) {
if (tabId === RIGHT_RAIL_PREVIEW_TAB_ID) {
if ($previewTarget.get()) {
dismissPreviewTarget()
}
/** Close the tab showing `source`, if one is open. Returns whether it closed. */
export function closePreviewForSource(source: string): boolean {
const tab = $previewTabs.get().find(item => item.target.source === source)
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)
}
/** Close the tab the right rail is actually showing. Returns false when nothing
* closed (so W can fall through). Resolves a stale `preview` selection to the
* first file tab when the live preview target is already gone. */
export function closeActiveRightRailTab(): boolean {
let tabId = $rightRailActiveTabId.get()
if (tabId === RIGHT_RAIL_PREVIEW_TAB_ID && !$previewTarget.get()) {
const fallback = $filePreviewTabs.get()[0]?.id ?? $artifactTabs.get()[0]
if (!fallback) {
return false
}
tabId = fallback
}
if (tabId === RIGHT_RAIL_PREVIEW_TAB_ID) {
if (!$previewTarget.get()) {
return false
}
closeRightRailTab(tabId)
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)) {
if (!tab) {
return false
}
closeRightRailTab(tabId)
closeRightRailTab(tab.id)
return true
}
// The rail's visible tab order: the live preview tab (when present) first, then
// 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[] = []
/** Artifact tabs can't outlive the registry they read from, so clearing it
* closes them. File and URL tabs re-read from their source and are left alone. */
export function closeArtifactPreviewTabs() {
for (const tab of $previewTabs.get()) {
if (tab.target.kind === 'artifact') {
closeRightRailTab(tab.id)
}
}
}
if ($previewTarget.get()) {
ids.push(RIGHT_RAIL_PREVIEW_TAB_ID)
/** Close the tab the right rail is actually showing. Returns false when nothing
* closed, so W can fall through to the next handler. */
export function closeActiveRightRailTab(): boolean {
const tab = activePreviewTab()
if (!tab) {
return false
}
for (const tab of $filePreviewTabs.get()) {
ids.push(tab.id)
}
closeRightRailTab(tab.id)
for (const tabId of $artifactTabs.get()) {
ids.push(tabId)
}
return ids
return true
}
/** Close every rail tab except `keepId`, then make `keepId` active. */
export function closeOtherRightRailTabs(keepId: RightRailTabId) {
for (const id of rightRailTabOrder()) {
if (id !== keepId) {
closeRightRailTab(id)
for (const tab of $previewTabs.get()) {
if (tab.id !== keepId) {
closeRightRailTab(tab.id)
}
}
@ -561,37 +251,25 @@ export function closeOtherRightRailTabs(keepId: RightRailTabId) {
/** Close every rail tab positioned after `tabId` (VS Code's "Close to the Right"). */
export function closeRightRailTabsToRight(tabId: RightRailTabId) {
const order = rightRailTabOrder()
const index = order.indexOf(tabId)
const tabs = $previewTabs.get()
const index = tabs.findIndex(tab => tab.id === tabId)
if (index === -1) {
return
}
for (const id of order.slice(index + 1)) {
closeRightRailTab(id)
for (const tab of tabs.slice(index + 1)) {
closeRightRailTab(tab.id)
}
}
/** Dismisses the active preview + every file and artifact tab so the rail pane unmounts. */
/** Close every tab so the rail pane unmounts. */
export function closeRightRail() {
if ($previewTarget.get()) {
dismissPreviewTarget()
}
$filePreviewTabs.set([])
closeAllArtifactTabs()
$previewTabs.set([])
selectRightRailTab(null)
setPaneOpen(PREVIEW_PANE_ID, false)
}
export function clearSessionPreviewRegistry() {
$sessionPreviewRegistry.set({})
setPreviewTarget(null)
$filePreviewTabs.set([])
setPaneOpen(PREVIEW_PANE_ID, false)
selectRightRailTab(RIGHT_RAIL_PREVIEW_TAB_ID)
}
export function requestPreviewReload() {
$previewReloadRequest.set($previewReloadRequest.get() + 1)
}