mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-27 17:58:07 +00:00
Merge pull request #72345 from NousResearch/hermes/hermes-5ee2515d
feat(desktop): artifacts — versioned cards, sandboxed live preview, right-rail viewer
This commit is contained in:
commit
b9ba7c78e4
25 changed files with 1766 additions and 47 deletions
|
|
@ -1,6 +1,7 @@
|
|||
import { closeActiveTerminal } from '@/app/right-sidebar/terminal/terminals'
|
||||
import { closeWorkspaceTab } from '@/components/pane-shell/tree/store'
|
||||
import { isFocusWithin } from '@/lib/keybinds/combo'
|
||||
import { $artifactTabs } from '@/store/artifacts'
|
||||
import { $filePreviewTabs, $previewTarget, closeActiveRightRailTab } from '@/store/preview'
|
||||
import { closeSessionTile, nextSessionTileForWorkspace } from '@/store/session-states'
|
||||
|
||||
|
|
@ -32,7 +33,7 @@ export function closeActiveTab(loadSessionIntoWorkspace?: (storedSessionId: stri
|
|||
// file tabs remain (the rail UI falls back to tabs[0]). Gating only on
|
||||
// `$filePreviewTarget` made ⌘W fall through to closeWorkspaceTab() and look
|
||||
// broken with a file tab still on screen.
|
||||
if ($previewTarget.get() || $filePreviewTabs.get().length > 0) {
|
||||
if ($previewTarget.get() || $filePreviewTabs.get().length > 0 || $artifactTabs.get().length > 0) {
|
||||
return closeActiveRightRailTab()
|
||||
}
|
||||
|
||||
|
|
|
|||
222
apps/desktop/src/app/chat/right-rail/artifact-pane.tsx
Normal file
222
apps/desktop/src/app/chat/right-rail/artifact-pane.tsx
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { artifactDownloadName } from '@/lib/artifact-detect'
|
||||
import { downloadTextFile } from '@/lib/download-text'
|
||||
import { ChevronLeft, ChevronRight, Download, ExternalLink } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$artifactRegistry,
|
||||
$artifactVersionSelection,
|
||||
type ArtifactRecord,
|
||||
selectArtifactVersion
|
||||
} from '@/store/artifacts'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
|
||||
import { ArtifactLivePreview, ArtifactSourceView, composeArtifactHtml } from './artifact-renderers'
|
||||
import { PreviewEmptyState } from './preview-file'
|
||||
|
||||
type ArtifactViewMode = 'preview' | 'source'
|
||||
|
||||
const MIME_BY_KIND = { code: 'text/plain', html: 'text/html', svg: 'image/svg+xml' } as const
|
||||
|
||||
const HEADER_BUTTON_CLASS =
|
||||
'flex h-5 items-center gap-1 rounded-md px-1 text-[0.625rem] font-bold text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-40'
|
||||
|
||||
/** Write the composed document to a real temp file through the existing
|
||||
* buffer-save IPC, then hand it to the OS browser. A blob/data URL can't
|
||||
* cross into the OS default browser, so a file on disk is the honest path. */
|
||||
async function openHtmlInBrowser(content: string): Promise<void> {
|
||||
const bridge = window.hermesDesktop
|
||||
|
||||
if (!bridge?.saveImageBuffer || !bridge.openExternal) {
|
||||
throw new Error('Desktop bridge unavailable')
|
||||
}
|
||||
|
||||
const bytes = new TextEncoder().encode(composeArtifactHtml(content))
|
||||
const path = await bridge.saveImageBuffer(bytes, '.html')
|
||||
|
||||
if (!path) {
|
||||
throw new Error('Could not write artifact file')
|
||||
}
|
||||
|
||||
const fileUrl = `file://${path.startsWith('/') ? '' : '/'}${path.replace(/\\/g, '/')}`
|
||||
|
||||
if (bridge.openPreviewInBrowser) {
|
||||
await bridge.openPreviewInBrowser(fileUrl)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await bridge.openExternal(fileUrl)
|
||||
}
|
||||
|
||||
function VersionStepper({
|
||||
current,
|
||||
onSelect,
|
||||
total
|
||||
}: {
|
||||
current: number
|
||||
onSelect: (index: number) => void
|
||||
total: number
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.artifactPane
|
||||
|
||||
if (total < 2) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-0.5 text-[0.625rem] font-bold text-muted-foreground">
|
||||
<Tip label={copy.olderVersion}>
|
||||
<button
|
||||
aria-label={copy.olderVersion}
|
||||
className={HEADER_BUTTON_CLASS}
|
||||
disabled={current === 0}
|
||||
onClick={() => onSelect(current - 1)}
|
||||
type="button"
|
||||
>
|
||||
<ChevronLeft className="size-3" />
|
||||
</button>
|
||||
</Tip>
|
||||
<span className="tabular-nums">{copy.versionOf(current + 1, total)}</span>
|
||||
<Tip label={copy.newerVersion}>
|
||||
<button
|
||||
aria-label={copy.newerVersion}
|
||||
className={HEADER_BUTTON_CLASS}
|
||||
disabled={current === total - 1}
|
||||
onClick={() => onSelect(current + 1)}
|
||||
type="button"
|
||||
>
|
||||
<ChevronRight className="size-3" />
|
||||
</button>
|
||||
</Tip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ArtifactPane({ artifactId }: { artifactId: string }) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.artifactPane
|
||||
const registry = useStore($artifactRegistry)
|
||||
const versionSelection = useStore($artifactVersionSelection)
|
||||
// View mode is per-pane, ephemeral: renderable artifacts open in preview.
|
||||
const [userMode, setUserMode] = useState<ArtifactViewMode | null>(null)
|
||||
|
||||
// Reset the explicit mode when the pane is reused for another artifact.
|
||||
useEffect(() => {
|
||||
setUserMode(null)
|
||||
}, [artifactId])
|
||||
|
||||
const record = useMemo<ArtifactRecord | null>(() => {
|
||||
for (const records of Object.values(registry)) {
|
||||
const found = records.find(candidate => candidate.id === artifactId)
|
||||
|
||||
if (found) {
|
||||
return found
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}, [artifactId, registry])
|
||||
|
||||
if (!record) {
|
||||
return <PreviewEmptyState body={copy.missingBody} title={copy.missingTitle} />
|
||||
}
|
||||
|
||||
const isRenderable = record.kind === 'html' || record.kind === 'svg'
|
||||
const versionIndex = Math.min(versionSelection[artifactId] ?? record.versions.length - 1, record.versions.length - 1)
|
||||
const version = record.versions[versionIndex]!
|
||||
const isCurrentVersion = versionIndex >= record.versions.length - 1
|
||||
const mode: ArtifactViewMode = isRenderable ? (userMode ?? 'preview') : 'source'
|
||||
const downloadName = artifactDownloadName(record.kind, record.language, record.title)
|
||||
|
||||
const modeLabel: Record<ArtifactViewMode, string> = {
|
||||
preview: copy.modePreview,
|
||||
source: copy.modeSource
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden bg-transparent">
|
||||
<div className="flex h-7 shrink-0 items-center gap-3 border-b border-border/40 px-3">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<VersionStepper
|
||||
current={versionIndex}
|
||||
onSelect={index => selectArtifactVersion(artifactId, index)}
|
||||
total={record.versions.length}
|
||||
/>
|
||||
{!isCurrentVersion && (
|
||||
<button
|
||||
className="text-[0.625rem] font-bold text-muted-foreground underline decoration-current/25 underline-offset-4 transition-colors hover:text-foreground"
|
||||
onClick={() => selectArtifactVersion(artifactId, record.versions.length - 1)}
|
||||
type="button"
|
||||
>
|
||||
{copy.latest}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{isRenderable &&
|
||||
(['preview', 'source'] as const).map(candidate => (
|
||||
<button
|
||||
className={cn(
|
||||
'text-[0.625rem] font-bold underline-offset-4 transition-colors',
|
||||
candidate === mode
|
||||
? 'text-foreground underline decoration-current/30'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
key={candidate}
|
||||
onClick={() => setUserMode(candidate)}
|
||||
type="button"
|
||||
>
|
||||
{modeLabel[candidate]}
|
||||
</button>
|
||||
))}
|
||||
<div className="flex items-center gap-0.5">
|
||||
<CopyButton
|
||||
appearance="inline"
|
||||
className="h-5 px-1 opacity-70 hover:opacity-100"
|
||||
iconClassName="size-3"
|
||||
label={copy.copyContent}
|
||||
showLabel={false}
|
||||
text={version.content}
|
||||
/>
|
||||
<Tip label={copy.download}>
|
||||
<button
|
||||
aria-label={copy.download}
|
||||
className={HEADER_BUTTON_CLASS}
|
||||
onClick={() => downloadTextFile(downloadName, version.content, MIME_BY_KIND[record.kind])}
|
||||
type="button"
|
||||
>
|
||||
<Download className="size-3" />
|
||||
</button>
|
||||
</Tip>
|
||||
{record.kind === 'html' && window.hermesDesktop && (
|
||||
<Tip label={copy.openInBrowser}>
|
||||
<button
|
||||
aria-label={copy.openInBrowser}
|
||||
className={HEADER_BUTTON_CLASS}
|
||||
onClick={() =>
|
||||
void openHtmlInBrowser(version.content).catch(error => notifyError(error, copy.openInBrowserFailed))
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<ExternalLink className="size-3" />
|
||||
</button>
|
||||
</Tip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
{mode === 'preview' && isRenderable ? (
|
||||
<ArtifactLivePreview content={version.content} kind={record.kind} title={record.title} />
|
||||
) : (
|
||||
<ArtifactSourceView language={record.kind === 'html' ? 'html' : record.language} text={version.content} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
107
apps/desktop/src/app/chat/right-rail/artifact-renderers.tsx
Normal file
107
apps/desktop/src/app/chat/right-rail/artifact-renderers.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import DOMPurify from 'dompurify'
|
||||
import { useMemo } from 'react'
|
||||
import ShikiHighlighter from 'react-shiki'
|
||||
|
||||
import { chunkTextLines, useFixedRowWindow } from '@/components/chat/fixed-row-window'
|
||||
import type { ArtifactKind } from '@/lib/artifact-detect'
|
||||
|
||||
const SHIKI_THEME = { dark: 'github-dark-default', light: 'github-light-default' } as const
|
||||
const SOURCE_CHUNK_LINES = 200
|
||||
const SOURCE_LINE_PX = 20
|
||||
const SOURCE_OVERSCAN_LINES = 400
|
||||
|
||||
/** Windowed, Shiki-highlighted source view for artifact content. Same fixed-row
|
||||
* windowing as the file preview's SourceView so a 5k-line artifact scrolls
|
||||
* smoothly, minus the gutter drag/selection machinery (artifact content has no
|
||||
* on-disk path to reference lines against). */
|
||||
export function ArtifactSourceView({ language, text }: { language: string; text: string }) {
|
||||
const chunks = useMemo(() => chunkTextLines(text, SOURCE_CHUNK_LINES), [text])
|
||||
const lastChunk = chunks.at(-1)
|
||||
const totalLines = lastChunk ? lastChunk.start + lastChunk.lines.length : 0
|
||||
|
||||
const { afterRows, beforeRows, endChunk, onScroll, scrollerRef, startChunk } = useFixedRowWindow({
|
||||
overscanRows: SOURCE_OVERSCAN_LINES,
|
||||
rowPx: SOURCE_LINE_PX,
|
||||
rowsPerChunk: SOURCE_CHUNK_LINES,
|
||||
totalRows: totalLines
|
||||
})
|
||||
|
||||
const visibleChunks = chunks.slice(startChunk, endChunk + 1)
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto" onScroll={onScroll} ref={scrollerRef}>
|
||||
<div className="min-w-max px-3 py-2 font-mono text-[0.7rem] leading-relaxed" data-selectable-text="true">
|
||||
{beforeRows > 0 && <div aria-hidden style={{ height: beforeRows * SOURCE_LINE_PX }} />}
|
||||
{visibleChunks.map(chunk => (
|
||||
<div className="[&_pre]:m-0" key={chunk.start}>
|
||||
<ShikiHighlighter
|
||||
addDefaultStyles={false}
|
||||
as="div"
|
||||
defaultColor="light-dark()"
|
||||
delay={80}
|
||||
language={language || 'text'}
|
||||
showLanguage={false}
|
||||
theme={SHIKI_THEME}
|
||||
>
|
||||
{chunk.text}
|
||||
</ShikiHighlighter>
|
||||
</div>
|
||||
))}
|
||||
{afterRows > 0 && <div aria-hidden style={{ height: afterRows * SOURCE_LINE_PX }} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Wrap an HTML fragment in a minimal document shell; full documents pass
|
||||
* through untouched. Keeps generated fragments (no <html>/<body>) rendering
|
||||
* with sane defaults instead of quirks-mode soup. */
|
||||
export function composeArtifactHtml(content: string): string {
|
||||
if (/<html[\s>]|<!doctype\s+html/i.test(content)) {
|
||||
return content
|
||||
}
|
||||
|
||||
return [
|
||||
'<!doctype html>',
|
||||
'<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">',
|
||||
'<style>body{margin:0;font-family:system-ui,sans-serif}</style></head><body>',
|
||||
content,
|
||||
'</body></html>'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Sandboxed live renderer for html/svg artifact content.
|
||||
*
|
||||
* HTML runs in an `<iframe sandbox="allow-scripts">` — scripts execute in an
|
||||
* opaque origin with no same-origin access, no top navigation, no popups, no
|
||||
* form submission out of the frame. The parent app is unreachable. SVG is
|
||||
* DOMPurify-sanitized with the same profile as the inline ```svg embed.
|
||||
*/
|
||||
export function ArtifactLivePreview({ content, kind, title }: { content: string; kind: ArtifactKind; title: string }) {
|
||||
const svgClean = useMemo(
|
||||
() => (kind === 'svg' ? DOMPurify.sanitize(content, { USE_PROFILES: { svg: true, svgFilters: true } }) : ''),
|
||||
[content, kind]
|
||||
)
|
||||
|
||||
if (kind === 'svg') {
|
||||
return (
|
||||
<div className="grid h-full place-items-center overflow-auto bg-background p-4 [&_svg]:h-auto [&_svg]:max-h-full [&_svg]:w-auto [&_svg]:max-w-full">
|
||||
<div dangerouslySetInnerHTML={{ __html: svgClean }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<iframe
|
||||
className="block size-full border-0 bg-white"
|
||||
sandbox="allow-scripts"
|
||||
srcDoc={composeArtifactHtml(content)}
|
||||
// Deliberately raw white + forced light scheme: the frame hosts foreign
|
||||
// generated HTML that assumes a light canvas, so it renders deterministically
|
||||
// light in both app themes instead of inheriting theme tokens it can't see.
|
||||
style={{ colorScheme: 'light' }}
|
||||
title={title}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ import { Tip } from '@/components/ui/tooltip'
|
|||
import { translateNow, useI18n } from '@/i18n'
|
||||
import { formatCombo } from '@/lib/keybinds/combo'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $openArtifacts, artifactIdFromTabId } from '@/store/artifacts'
|
||||
import {
|
||||
$panesFlipped,
|
||||
$rightRailActiveTabId,
|
||||
|
|
@ -34,6 +35,7 @@ import {
|
|||
} from '@/store/preview'
|
||||
import { $dirtyPreviewUrls } from '@/store/preview-edit'
|
||||
|
||||
import { ArtifactPane } from './artifact-pane'
|
||||
import { PreviewPane } from './preview-pane'
|
||||
|
||||
export const PREVIEW_RAIL_MIN_WIDTH = '18rem'
|
||||
|
|
@ -44,11 +46,9 @@ interface ChatPreviewRailProps {
|
|||
setTitlebarToolGroup?: SetTitlebarToolGroup
|
||||
}
|
||||
|
||||
interface RailTab {
|
||||
id: RightRailTabId
|
||||
label: string
|
||||
target: PreviewTarget
|
||||
}
|
||||
type RailTab =
|
||||
| { id: RightRailTabId; kind: 'preview'; label: string; target: PreviewTarget; tooltip: string }
|
||||
| { artifactId: string; id: RightRailTabId; kind: 'artifact'; label: string; tooltip: string }
|
||||
|
||||
function tabLabelFor(target: PreviewTarget): string {
|
||||
const value = target.label || target.path || target.source || target.url
|
||||
|
|
@ -65,15 +65,43 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
|
|||
const filePreviewTabs = useStore($filePreviewTabs)
|
||||
const previewTarget = useStore($previewTarget)
|
||||
const dirtyPreviewUrls = useStore($dirtyPreviewUrls)
|
||||
const openArtifacts = useStore($openArtifacts)
|
||||
|
||||
const tabs = useMemo<readonly RailTab[]>(
|
||||
() => [
|
||||
...(previewTarget
|
||||
? [{ id: RIGHT_RAIL_PREVIEW_TAB_ID, label: t.preview.tab, target: previewTarget } as RailTab]
|
||||
? [
|
||||
{
|
||||
id: RIGHT_RAIL_PREVIEW_TAB_ID,
|
||||
kind: 'preview',
|
||||
label: t.preview.tab,
|
||||
target: previewTarget,
|
||||
tooltip: previewTarget.path || previewTarget.url || t.preview.tab
|
||||
} as RailTab
|
||||
]
|
||||
: []),
|
||||
...filePreviewTabs.map(({ id, target }) => ({ id, label: tabLabelFor(target), target }) as RailTab)
|
||||
...filePreviewTabs.map(
|
||||
({ id, target }) =>
|
||||
({
|
||||
id,
|
||||
kind: 'preview',
|
||||
label: tabLabelFor(target),
|
||||
target,
|
||||
tooltip: target.path || target.url || tabLabelFor(target)
|
||||
}) as RailTab
|
||||
),
|
||||
...openArtifacts.map(
|
||||
({ record, tabId }) =>
|
||||
({
|
||||
artifactId: record.id,
|
||||
id: tabId,
|
||||
kind: 'artifact',
|
||||
label: record.title || t.artifactPane.tabFallback,
|
||||
tooltip: record.title || t.artifactPane.tabFallback
|
||||
}) as RailTab
|
||||
)
|
||||
],
|
||||
[filePreviewTabs, previewTarget, t.preview.tab]
|
||||
[filePreviewTabs, openArtifacts, previewTarget, t.artifactPane.tabFallback, t.preview.tab]
|
||||
)
|
||||
|
||||
const activeTab = tabs.find(tab => tab.id === activeTabId) ?? tabs[0]
|
||||
|
|
@ -117,13 +145,13 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
|
|||
const active = tab.id === activeTab.id
|
||||
const hasOthers = tabs.length > 1
|
||||
const hasTabsToRight = index < tabs.length - 1
|
||||
const dirty = Boolean(dirtyPreviewUrls[tab.target.url])
|
||||
const dirty = tab.kind === 'preview' && Boolean(dirtyPreviewUrls[tab.target.url])
|
||||
|
||||
return (
|
||||
<ContextMenu key={tab.id}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<PaneTab active={active} dirty={dirty} onClose={() => closeRightRailTab(tab.id)}>
|
||||
<Tip label={tab.target.path || tab.target.url || tab.label}>
|
||||
<Tip label={tab.tooltip}>
|
||||
<PaneTabLabel
|
||||
aria-selected={active}
|
||||
as="button"
|
||||
|
|
@ -132,6 +160,9 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
|
|||
role="tab"
|
||||
type="button"
|
||||
>
|
||||
{tab.kind === 'artifact' && (
|
||||
<Codicon className="mr-1 shrink-0 text-[0.6875rem] opacity-70" name="sparkle" />
|
||||
)}
|
||||
{tab.label}
|
||||
</PaneTabLabel>
|
||||
</Tip>
|
||||
|
|
@ -166,13 +197,17 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
|
|||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<PreviewPane
|
||||
embedded
|
||||
onRestartServer={isPreview ? onRestartServer : undefined}
|
||||
reloadRequest={previewReloadRequest}
|
||||
setTitlebarToolGroup={setTitlebarToolGroup}
|
||||
target={activeTab.target}
|
||||
/>
|
||||
{activeTab.kind === 'artifact' ? (
|
||||
<ArtifactPane artifactId={artifactIdFromTabId(activeTab.id) ?? activeTab.artifactId} />
|
||||
) : (
|
||||
<PreviewPane
|
||||
embedded
|
||||
onRestartServer={isPreview ? onRestartServer : undefined}
|
||||
reloadRequest={previewReloadRequest}
|
||||
setTitlebarToolGroup={setTitlebarToolGroup}
|
||||
target={activeTab.target}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import { sessionTitle as storedSessionTitle } from '@/lib/chat-runtime'
|
|||
import { LayoutDashboard } from '@/lib/icons'
|
||||
import { type KeybindContribution, KEYBINDS_AREA } from '@/lib/keybinds/actions'
|
||||
import { Codecs, persistentAtom } from '@/lib/persisted'
|
||||
import { $artifactTabs } from '@/store/artifacts'
|
||||
import {
|
||||
$fileBrowserOpen,
|
||||
$panesFlipped,
|
||||
|
|
@ -51,7 +52,7 @@ import {
|
|||
SIDEBAR_DEFAULT_WIDTH,
|
||||
SIDEBAR_MAX_WIDTH
|
||||
} from '@/store/layout'
|
||||
import { $filePreviewTarget, $previewTarget, closeRightRail } from '@/store/preview'
|
||||
import { $filePreviewTabs, $filePreviewTarget, $previewTarget, closeRightRail } from '@/store/preview'
|
||||
import { $reviewOpen, closeReview, REVIEW_PANE_ID } from '@/store/review'
|
||||
import { $currentCwd, $selectedStoredSessionId, $sessions, sessionMatchesStoredId } from '@/store/session'
|
||||
import { watchSessionPins } from '@/store/session-pin-sync'
|
||||
|
|
@ -552,9 +553,10 @@ bindPaneCollapse(
|
|||
// Preview EXISTS only while something is previewed (old-shell semantics:
|
||||
// closing the last preview tab closes the pane; a new target opens + fronts
|
||||
// it). Same visibility binding as every other self-managed surface, driven
|
||||
// by the live targets instead of a toggle.
|
||||
const $previewVisible = computed([$previewTarget, $filePreviewTarget], (target, fileTarget) =>
|
||||
Boolean(target || fileTarget)
|
||||
// by the live targets (and open artifact tabs) instead of a toggle.
|
||||
const $previewVisible = computed(
|
||||
[$previewTarget, $filePreviewTabs, $artifactTabs],
|
||||
(target, fileTabs, artifactTabs) => Boolean(target) || fileTabs.length > 0 || artifactTabs.length > 0
|
||||
)
|
||||
|
||||
bindPaneVisibility('preview', $previewVisible, closeRightRail)
|
||||
|
|
@ -603,6 +605,17 @@ const revealPreview = () => {
|
|||
|
||||
$previewTarget.listen(target => target && revealPreview())
|
||||
$filePreviewTarget.listen(target => target && revealPreview())
|
||||
// Artifact reveal keys on tab OPENS (length grows), not list identity — closing
|
||||
// one of two artifact tabs must not re-front the pane.
|
||||
let lastArtifactTabCount = $artifactTabs.get().length
|
||||
$artifactTabs.listen(tabs => {
|
||||
const grew = tabs.length > lastArtifactTabCount
|
||||
lastArtifactTabCount = tabs.length
|
||||
|
||||
if (grew) {
|
||||
revealPreview()
|
||||
}
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import { registry } from '@/contrib/registry'
|
|||
import { getLogs } from '@/hermes'
|
||||
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $openArtifacts } from '@/store/artifacts'
|
||||
import { $filePreviewTarget, $previewTarget, setCurrentSessionPreviewTarget } from '@/store/preview'
|
||||
import { $currentCwd } from '@/store/session'
|
||||
|
||||
|
|
@ -74,9 +75,10 @@ export const $restartPreviewServer = atom<((url: string, context?: string) => Pr
|
|||
export function PreviewRailPane() {
|
||||
const previewTarget = useStore($previewTarget)
|
||||
const fileTarget = useStore($filePreviewTarget)
|
||||
const openArtifacts = useStore($openArtifacts)
|
||||
const restartPreviewServer = useStore($restartPreviewServer)
|
||||
|
||||
if (!previewTarget && !fileTarget) {
|
||||
if (!previewTarget && !fileTarget && openArtifacts.length === 0) {
|
||||
return (
|
||||
<div className="grid h-full place-items-center px-4 text-center">
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
|
|
|
|||
145
apps/desktop/src/components/assistant-ui/artifact-card.tsx
Normal file
145
apps/desktop/src/components/assistant-ui/artifact-card.tsx
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
'use client'
|
||||
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
|
||||
import { useSessionView } from '@/app/chat/session-view'
|
||||
import { CodeCardIcon } from '@/components/chat/code-card'
|
||||
import { useI18n } from '@/i18n'
|
||||
import type { ArtifactDetection } from '@/lib/artifact-detect'
|
||||
import { codiconForLanguage } from '@/lib/markdown-code'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$artifactRegistry,
|
||||
artifactsForSession,
|
||||
openArtifactTab,
|
||||
selectArtifactVersion,
|
||||
upsertArtifact
|
||||
} from '@/store/artifacts'
|
||||
|
||||
interface ArtifactCardProps {
|
||||
code: string
|
||||
detection: ArtifactDetection
|
||||
streaming?: boolean
|
||||
}
|
||||
|
||||
const KIND_ICON: Record<ArtifactDetection['kind'], string> = {
|
||||
code: 'code',
|
||||
html: 'browser',
|
||||
svg: 'symbol-color'
|
||||
}
|
||||
|
||||
function detectionIcon(detection: ArtifactDetection): string {
|
||||
return detection.kind === 'code' ? codiconForLanguage(detection.language) : KIND_ICON[detection.kind]
|
||||
}
|
||||
|
||||
/**
|
||||
* Transcript stand-in for a fenced block that was promoted to an artifact.
|
||||
* Replaces the wall of code with a compact, openable card: icon, title, kind,
|
||||
* version badge. While the fence is still streaming
|
||||
* it shows a shimmer + line count instead of the growing source.
|
||||
*
|
||||
* Registration is automatic on completion (so version history accumulates
|
||||
* even if the user never opens the card) but opening the rail is strictly
|
||||
* click-driven — a background stream never steals the pane.
|
||||
*/
|
||||
export function ArtifactCard({ code, detection, streaming = false }: ArtifactCardProps) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.artifactCard
|
||||
const view = useSessionView()
|
||||
const runtimeId = useStore(view.$runtimeId)
|
||||
const storedId = useStore(view.$storedId)
|
||||
const registry = useStore($artifactRegistry)
|
||||
const sessionId = storedId || runtimeId || ''
|
||||
|
||||
const trimmed = code.trim()
|
||||
|
||||
// Register/version the artifact once its fence has finished streaming.
|
||||
// upsertArtifact dedupes on content hash, so re-renders and transcript
|
||||
// replays are no-ops.
|
||||
useEffect(() => {
|
||||
if (!streaming && sessionId && trimmed) {
|
||||
upsertArtifact(sessionId, detection, trimmed)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- detection derives from code
|
||||
}, [detection.kind, detection.language, detection.title, sessionId, streaming, trimmed])
|
||||
|
||||
const record = useMemo(() => {
|
||||
void registry
|
||||
|
||||
const slugMatch = artifactsForSession(sessionId).find(
|
||||
candidate => candidate.kind === detection.kind && candidate.versions.some(v => v.content === trimmed)
|
||||
)
|
||||
|
||||
return slugMatch ?? null
|
||||
}, [detection.kind, registry, sessionId, trimmed])
|
||||
|
||||
const lineCount = useMemo(() => trimmed.split('\n').length, [trimmed])
|
||||
const kindLabel = copy.kind[detection.kind]
|
||||
const versionCount = record?.versions.length ?? 0
|
||||
const title = (record?.title || detection.title || kindLabel).trim() || kindLabel
|
||||
|
||||
const open = () => {
|
||||
if (streaming || !sessionId || !trimmed) {
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure the registry row exists even if the completion effect hasn't
|
||||
// fired yet (e.g. clicked in the same frame the stream sealed).
|
||||
const result = upsertArtifact(sessionId, detection, trimmed)
|
||||
|
||||
if (!result) {
|
||||
return
|
||||
}
|
||||
|
||||
openArtifactTab(result.artifactId)
|
||||
|
||||
// An older card opens at ITS version, not silently the newest — the user
|
||||
// clicked this specific iteration. openArtifactTab resets to newest, so
|
||||
// re-pin when this card's content is a historical version.
|
||||
const versionIndex = result.record.versions.findIndex(version => version.content === trimmed)
|
||||
|
||||
if (versionIndex !== -1 && versionIndex < result.record.versions.length - 1) {
|
||||
selectArtifactVersion(result.artifactId, versionIndex)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'group/artifact my-1.5 flex w-full max-w-md items-center gap-2.5 overflow-hidden rounded-[0.625rem] border border-border px-3 py-2.5 text-left transition-colors',
|
||||
streaming ? 'cursor-default' : 'cursor-pointer hover:bg-accent/40'
|
||||
)}
|
||||
data-slot="aui_artifact-card"
|
||||
disabled={streaming}
|
||||
onClick={open}
|
||||
type="button"
|
||||
>
|
||||
<span className="grid size-8 shrink-0 place-items-center rounded-md bg-muted/55 text-muted-foreground">
|
||||
<CodeCardIcon className="text-[1rem]" name={detectionIcon(detection)} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span
|
||||
className={cn(
|
||||
'block truncate text-[length:var(--conversation-text-font-size)] font-medium text-foreground',
|
||||
streaming && 'shimmer text-foreground/55'
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
<span className="block truncate text-[length:var(--conversation-tool-font-size)] text-muted-foreground">
|
||||
{streaming
|
||||
? copy.generating(lineCount)
|
||||
: versionCount > 1
|
||||
? `${kindLabel} · ${copy.versionBadge(versionCount)}`
|
||||
: kindLabel}
|
||||
</span>
|
||||
</span>
|
||||
{!streaming && (
|
||||
<span className="shrink-0 text-[length:var(--conversation-tool-font-size)] font-medium text-muted-foreground opacity-0 transition-opacity group-hover/artifact:opacity-100">
|
||||
{copy.open}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { $artifactTabs, artifactsForSession, clearArtifactRegistry } from '@/store/artifacts'
|
||||
import { $activeSessionId, $selectedStoredSessionId } from '@/store/session'
|
||||
|
||||
import { MarkdownTextContent } from './markdown-text'
|
||||
|
||||
const HTML_DOC = `<!doctype html>
|
||||
<html>
|
||||
<head><title>Pomodoro Timer</title></head>
|
||||
<body>
|
||||
<h1>Pomodoro</h1>
|
||||
<p>A tiny focus timer that counts down twenty-five minutes.</p>
|
||||
<script>let seconds = 25 * 60; setInterval(() => { seconds -= 1 }, 1000)</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
const SMALL_SNIPPET = 'const x = 1'
|
||||
|
||||
function fenced(language: string, body: string): string {
|
||||
return `Here you go:\n\n\`\`\`${language}\n${body}\n\`\`\`\n`
|
||||
}
|
||||
|
||||
// End-to-end for the artifact path: a substantial ```html fence in assistant
|
||||
// markdown must come out of preprocessMarkdown -> Streamdown -> SyntaxHighlighter
|
||||
// as an artifact card (registered in the store), while small fences keep the
|
||||
// plain code-card path.
|
||||
describe('MarkdownTextContent artifacts', () => {
|
||||
beforeEach(() => {
|
||||
$activeSessionId.set('session-artifacts')
|
||||
$selectedStoredSessionId.set(null)
|
||||
window.localStorage.clear()
|
||||
clearArtifactRegistry()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
$activeSessionId.set(null)
|
||||
$selectedStoredSessionId.set(null)
|
||||
clearArtifactRegistry()
|
||||
window.localStorage.clear()
|
||||
})
|
||||
|
||||
it('renders a substantial html fence as an artifact card and registers it', async () => {
|
||||
render(<MarkdownTextContent isRunning={false} text={fenced('html', HTML_DOC)} />)
|
||||
|
||||
const card = await screen.findByText('Pomodoro Timer')
|
||||
|
||||
expect(card.closest('button')?.dataset.slot).toBe('aui_artifact-card')
|
||||
expect(artifactsForSession('session-artifacts')).toHaveLength(1)
|
||||
expect(artifactsForSession('session-artifacts')[0]?.kind).toBe('html')
|
||||
// Registration alone must not open the rail (offer, don't hijack).
|
||||
expect($artifactTabs.get()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps a small fence as a plain code block', async () => {
|
||||
const { container } = render(<MarkdownTextContent isRunning={false} text={fenced('js', SMALL_SNIPPET)} />)
|
||||
|
||||
// The code card mounts synchronously; Shiki may split tokens into spans,
|
||||
// so assert on the card slots rather than text content.
|
||||
expect(container.querySelector('[data-slot="code-card"]')).not.toBeNull()
|
||||
expect(container.querySelector('[data-slot="aui_artifact-card"]')).toBeNull()
|
||||
expect(artifactsForSession('session-artifacts')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not register while the message is still streaming', async () => {
|
||||
render(<MarkdownTextContent isRunning text={fenced('html', HTML_DOC)} />)
|
||||
|
||||
await screen.findByText('Pomodoro Timer')
|
||||
|
||||
expect(artifactsForSession('session-artifacts')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
|
@ -14,6 +14,7 @@ import { ExpandableBlock } from '@/components/chat/expandable-block'
|
|||
import { PreviewAttachment } from '@/components/chat/preview-attachment'
|
||||
import { chunkByLines, SyntaxHighlighter } from '@/components/chat/shiki-highlighter'
|
||||
import { ZoomableImage } from '@/components/chat/zoomable-image'
|
||||
import { detectArtifact } from '@/lib/artifact-detect'
|
||||
import { normalizeExternalUrl, openExternalLink, PrettyLink } from '@/lib/external-link'
|
||||
import { createMemoizedMathPlugin } from '@/lib/katex-memo'
|
||||
import { parseMarkdownIntoBlocksCached } from '@/lib/markdown-blocks'
|
||||
|
|
@ -33,6 +34,7 @@ import { previewTargetFromMarkdownHref } from '@/lib/preview-targets'
|
|||
import { sessionRefFromMarkdownHref } from '@/lib/session-refs'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { ArtifactCard } from './artifact-card'
|
||||
import { SessionRefLink } from './directive-text'
|
||||
import { detectEmbed, extractAlert, MarkdownAlert, RichCodeBlock, UrlEmbed } from './embeds'
|
||||
|
||||
|
|
@ -355,6 +357,9 @@ interface MarkdownTextSurfaceProps {
|
|||
containerClassName?: string
|
||||
containerProps?: ComponentProps<'div'>
|
||||
defer?: boolean
|
||||
/** Disable artifact-card promotion for fenced blocks (reasoning text — a
|
||||
* model's scratchpad draft must not register artifact versions). */
|
||||
disableArtifacts?: boolean
|
||||
}
|
||||
|
||||
// Headings shrink to chat scale rather than the prose default (h1≈xl). Kept
|
||||
|
|
@ -403,7 +408,12 @@ function HugeTextFallback({ containerClassName, text }: { containerClassName?: s
|
|||
)
|
||||
}
|
||||
|
||||
function MarkdownTextSurface({ containerClassName, containerProps, defer }: MarkdownTextSurfaceProps) {
|
||||
function MarkdownTextSurface({
|
||||
containerClassName,
|
||||
containerProps,
|
||||
defer,
|
||||
disableArtifacts
|
||||
}: MarkdownTextSurfaceProps) {
|
||||
const { status, text } = useMessagePartText()
|
||||
const isStreaming = status.type === 'running'
|
||||
|
||||
|
|
@ -508,18 +518,28 @@ function MarkdownTextSurface({ containerClassName, containerProps, defer }: Mark
|
|||
<td className={cn('px-2.5 py-1.5 align-top text-[0.8125rem] leading-snug', className)} {...props} />
|
||||
),
|
||||
img: MarkdownImage,
|
||||
// ```mermaid / ```svg fences route to their lazy renderers; every other
|
||||
// language falls back to the Shiki-highlighted code block.
|
||||
SyntaxHighlighter: (props: SyntaxHighlighterProps) => (
|
||||
<RichCodeBlock
|
||||
code={props.code}
|
||||
fallback={<SyntaxHighlighter {...props} defer={isStreaming} />}
|
||||
language={props.language}
|
||||
streaming={isStreaming}
|
||||
/>
|
||||
)
|
||||
// ```mermaid / ```svg fences route to their lazy renderers; substantial
|
||||
// html/svg/code fences promote to an artifact card that opens in the
|
||||
// right rail; every other language falls back to the Shiki-highlighted
|
||||
// code block.
|
||||
SyntaxHighlighter: (props: SyntaxHighlighterProps) => {
|
||||
const artifact = disableArtifacts ? null : detectArtifact(props.language, props.code)
|
||||
|
||||
if (artifact) {
|
||||
return <ArtifactCard code={props.code} detection={artifact} streaming={isStreaming} />
|
||||
}
|
||||
|
||||
return (
|
||||
<RichCodeBlock
|
||||
code={props.code}
|
||||
fallback={<SyntaxHighlighter {...props} defer={isStreaming} />}
|
||||
language={props.language}
|
||||
streaming={isStreaming}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}) as StreamdownTextComponents,
|
||||
[isStreaming]
|
||||
[disableArtifacts, isStreaming]
|
||||
)
|
||||
|
||||
if (text.length > MAX_MARKDOWN_CHARS) {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ import { MarkdownTextContent } from './markdown-text'
|
|||
|
||||
const openSessionTile = vi.fn()
|
||||
|
||||
vi.mock('@/store/session-states', () => ({
|
||||
vi.mock('@/store/session-states', async importOriginal => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
openSessionTile: (...args: unknown[]) => openSessionTile(...args)
|
||||
}))
|
||||
|
||||
|
|
|
|||
|
|
@ -198,6 +198,7 @@ const ReasoningTextPart: ReasoningMessagePartComponent = () => {
|
|||
<MarkdownTextContent
|
||||
containerClassName="text-xs leading-snug text-muted-foreground/85"
|
||||
containerProps={{ 'data-slot': 'aui_reasoning-text' } as ComponentProps<'div'>}
|
||||
disableArtifacts
|
||||
isRunning={status.type === 'running' || messageRunning}
|
||||
text={text.trimStart()}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1495,6 +1495,29 @@ export const ar = defineLocale({
|
|||
copyUrl: 'نسخ الرابط',
|
||||
copyPath: 'نسخ المسار'
|
||||
},
|
||||
|
||||
artifactCard: {
|
||||
kind: { code: 'كود', html: 'صفحة تفاعلية', svg: 'رسم' },
|
||||
generating: lines => `جارٍ الإنشاء… ${lines} سطرًا`,
|
||||
versionBadge: count => `${count} إصدارات`,
|
||||
open: 'فتح'
|
||||
},
|
||||
|
||||
artifactPane: {
|
||||
tabFallback: 'ناتج',
|
||||
modePreview: 'معاينة',
|
||||
modeSource: 'المصدر',
|
||||
versionOf: (current, total) => `الإصدار ${current} من ${total}`,
|
||||
olderVersion: 'إصدار أقدم',
|
||||
newerVersion: 'إصدار أحدث',
|
||||
latest: 'الأحدث',
|
||||
copyContent: 'نسخ المحتوى',
|
||||
download: 'تنزيل',
|
||||
openInBrowser: 'فتح في المتصفح',
|
||||
openInBrowserFailed: 'تعذّر الفتح في المتصفح',
|
||||
missingTitle: 'الناتج غير متاح',
|
||||
missingBody: 'لم يعد هذا الناتج موجودًا في السجل المحلي.'
|
||||
},
|
||||
sidebar: {
|
||||
nav: {
|
||||
'new-session': 'جلسة جديدة',
|
||||
|
|
|
|||
|
|
@ -1757,6 +1757,29 @@ export const en: Translations = {
|
|||
copyPath: 'Copy path'
|
||||
},
|
||||
|
||||
artifactCard: {
|
||||
kind: { code: 'Code', html: 'Interactive page', svg: 'Graphic' },
|
||||
generating: lines => `Generating… ${lines} lines`,
|
||||
versionBadge: count => `${count} versions`,
|
||||
open: 'Open'
|
||||
},
|
||||
|
||||
artifactPane: {
|
||||
tabFallback: 'Artifact',
|
||||
modePreview: 'PREVIEW',
|
||||
modeSource: 'SOURCE',
|
||||
versionOf: (current, total) => `v${current} of ${total}`,
|
||||
olderVersion: 'Older version',
|
||||
newerVersion: 'Newer version',
|
||||
latest: 'Latest',
|
||||
copyContent: 'Copy content',
|
||||
download: 'Download',
|
||||
openInBrowser: 'Open in browser',
|
||||
openInBrowserFailed: 'Could not open in browser',
|
||||
missingTitle: 'Artifact unavailable',
|
||||
missingBody: 'This artifact is no longer in the local registry.'
|
||||
},
|
||||
|
||||
sidebar: {
|
||||
nav: {
|
||||
'new-session': 'New session',
|
||||
|
|
|
|||
|
|
@ -1621,6 +1621,29 @@ export const ja = defineLocale({
|
|||
copyPath: 'パスをコピー'
|
||||
},
|
||||
|
||||
artifactCard: {
|
||||
kind: { code: 'コード', html: 'インタラクティブページ', svg: 'グラフィック' },
|
||||
generating: lines => `生成中… ${lines} 行`,
|
||||
versionBadge: count => `${count} 個のバージョン`,
|
||||
open: '開く'
|
||||
},
|
||||
|
||||
artifactPane: {
|
||||
tabFallback: 'アーティファクト',
|
||||
modePreview: 'プレビュー',
|
||||
modeSource: 'ソース',
|
||||
versionOf: (current, total) => `${total} 中 v${current}`,
|
||||
olderVersion: '前のバージョン',
|
||||
newerVersion: '次のバージョン',
|
||||
latest: '最新',
|
||||
copyContent: 'コンテンツをコピー',
|
||||
download: 'ダウンロード',
|
||||
openInBrowser: 'ブラウザで開く',
|
||||
openInBrowserFailed: 'ブラウザで開けませんでした',
|
||||
missingTitle: 'アーティファクトを利用できません',
|
||||
missingBody: 'このアーティファクトはローカルレジストリに存在しません。'
|
||||
},
|
||||
|
||||
sidebar: {
|
||||
nav: {
|
||||
'new-session': '新しいセッション',
|
||||
|
|
|
|||
|
|
@ -1466,6 +1466,29 @@ export interface Translations {
|
|||
copyPath: string
|
||||
}
|
||||
|
||||
artifactCard: {
|
||||
kind: Record<'code' | 'html' | 'svg', string>
|
||||
generating: (lines: number) => string
|
||||
versionBadge: (count: number) => string
|
||||
open: string
|
||||
}
|
||||
|
||||
artifactPane: {
|
||||
tabFallback: string
|
||||
modePreview: string
|
||||
modeSource: string
|
||||
versionOf: (current: number, total: number) => string
|
||||
olderVersion: string
|
||||
newerVersion: string
|
||||
latest: string
|
||||
copyContent: string
|
||||
download: string
|
||||
openInBrowser: string
|
||||
openInBrowserFailed: string
|
||||
missingTitle: string
|
||||
missingBody: string
|
||||
}
|
||||
|
||||
sidebar: {
|
||||
nav: Record<string, string>
|
||||
searchAria: string
|
||||
|
|
|
|||
|
|
@ -1569,6 +1569,29 @@ export const zhHant = defineLocale({
|
|||
copyPath: '複製路徑'
|
||||
},
|
||||
|
||||
artifactCard: {
|
||||
kind: { code: '程式碼', html: '互動頁面', svg: '圖形' },
|
||||
generating: lines => `產生中… ${lines} 行`,
|
||||
versionBadge: count => `${count} 個版本`,
|
||||
open: '開啟'
|
||||
},
|
||||
|
||||
artifactPane: {
|
||||
tabFallback: '產物',
|
||||
modePreview: '預覽',
|
||||
modeSource: '原始碼',
|
||||
versionOf: (current, total) => `第 ${current}/${total} 版`,
|
||||
olderVersion: '較舊版本',
|
||||
newerVersion: '較新版本',
|
||||
latest: '最新',
|
||||
copyContent: '複製內容',
|
||||
download: '下載',
|
||||
openInBrowser: '在瀏覽器中開啟',
|
||||
openInBrowserFailed: '無法在瀏覽器中開啟',
|
||||
missingTitle: '產物無法使用',
|
||||
missingBody: '此產物已不在本機註冊表中。'
|
||||
},
|
||||
|
||||
sidebar: {
|
||||
nav: {
|
||||
'new-session': '新工作階段',
|
||||
|
|
|
|||
|
|
@ -1947,6 +1947,29 @@ export const zh: Translations = {
|
|||
copyPath: '复制路径'
|
||||
},
|
||||
|
||||
artifactCard: {
|
||||
kind: { code: '代码', html: '交互页面', svg: '图形' },
|
||||
generating: lines => `生成中… ${lines} 行`,
|
||||
versionBadge: count => `${count} 个版本`,
|
||||
open: '打开'
|
||||
},
|
||||
|
||||
artifactPane: {
|
||||
tabFallback: '产物',
|
||||
modePreview: '预览',
|
||||
modeSource: '源码',
|
||||
versionOf: (current, total) => `第 ${current}/${total} 版`,
|
||||
olderVersion: '较旧版本',
|
||||
newerVersion: '较新版本',
|
||||
latest: '最新',
|
||||
copyContent: '复制内容',
|
||||
download: '下载',
|
||||
openInBrowser: '在浏览器中打开',
|
||||
openInBrowserFailed: '无法在浏览器中打开',
|
||||
missingTitle: '产物不可用',
|
||||
missingBody: '此产物已不在本地注册表中。'
|
||||
},
|
||||
|
||||
sidebar: {
|
||||
nav: {
|
||||
'new-session': '新建会话',
|
||||
|
|
|
|||
121
apps/desktop/src/lib/artifact-detect.test.ts
Normal file
121
apps/desktop/src/lib/artifact-detect.test.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { artifactContentHash, artifactDownloadName, artifactSlug, detectArtifact } from './artifact-detect'
|
||||
|
||||
const HTML_DOC = `<!doctype html>
|
||||
<html>
|
||||
<head><title>Pomodoro Timer</title></head>
|
||||
<body>
|
||||
<h1>Pomodoro</h1>
|
||||
<script>let t = 25 * 60; setInterval(() => { t -= 1 }, 1000)</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
function longCode(lines: number): string {
|
||||
return Array.from(
|
||||
{ length: lines },
|
||||
(_, i) => `export function helper${i}(value: number) { return value * ${i} }`
|
||||
).join('\n')
|
||||
}
|
||||
|
||||
describe('detectArtifact', () => {
|
||||
it('promotes a full html document', () => {
|
||||
const detection = detectArtifact('html', HTML_DOC)
|
||||
|
||||
expect(detection).not.toBeNull()
|
||||
expect(detection?.kind).toBe('html')
|
||||
expect(detection?.title).toBe('Pomodoro Timer')
|
||||
})
|
||||
|
||||
it('falls back to h1 when the document has no title tag', () => {
|
||||
const doc = `<!doctype html><html><body><h1>Budget <em>Dashboard</em></h1>${'<div>x</div>'.repeat(30)}</body></html>`
|
||||
|
||||
expect(detectArtifact('html', doc)?.title).toBe('Budget Dashboard')
|
||||
})
|
||||
|
||||
it('ignores a small html snippet', () => {
|
||||
expect(detectArtifact('html', '<div class="chip">hello</div>')).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores small svg fences (inline embed owns them)', () => {
|
||||
expect(detectArtifact('svg', '<svg viewBox="0 0 10 10"><rect width="10" height="10"/></svg>')).toBeNull()
|
||||
})
|
||||
|
||||
it('promotes a large svg', () => {
|
||||
const svg = `<svg viewBox="0 0 100 100"><title>Org Chart</title>${'<rect x="1" y="2" width="3" height="4"/>'.repeat(80)}</svg>`
|
||||
const detection = detectArtifact('svg', svg)
|
||||
|
||||
expect(detection?.kind).toBe('svg')
|
||||
expect(detection?.title).toBe('Org Chart')
|
||||
})
|
||||
|
||||
it('keeps short code inline', () => {
|
||||
expect(detectArtifact('python', 'print("hi")')).toBeNull()
|
||||
})
|
||||
|
||||
it('promotes long code and derives a declaration title', () => {
|
||||
const code = `export function buildDashboard(config: Config) {\n${longCode(60)}\n}`
|
||||
const detection = detectArtifact('typescript', code)
|
||||
|
||||
expect(detection?.kind).toBe('code')
|
||||
expect(detection?.title).toBe('buildDashboard')
|
||||
})
|
||||
|
||||
it('prefers a filename comment for the title', () => {
|
||||
const code = `# server.py\n${longCode(60)
|
||||
.replace(/export function/g, 'def')
|
||||
.replace(/\{|\}/g, '')}`
|
||||
|
||||
const detection = detectArtifact('python', code)
|
||||
|
||||
expect(detection?.kind).toBe('code')
|
||||
expect(detection?.title).toBe('server.py')
|
||||
})
|
||||
|
||||
it('never promotes prose-ish or terminal fences', () => {
|
||||
expect(detectArtifact('text', longCode(80))).toBeNull()
|
||||
expect(detectArtifact('diff', longCode(80))).toBeNull()
|
||||
expect(detectArtifact('markdown', longCode(80))).toBeNull()
|
||||
expect(detectArtifact('mermaid', longCode(80))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('artifactSlug', () => {
|
||||
it('is stable across regenerations of the same artifact', () => {
|
||||
const a = artifactSlug({ kind: 'html', language: 'html', title: 'Pomodoro Timer' })
|
||||
const b = artifactSlug({ kind: 'html', language: 'html', title: 'Pomodoro Timer' })
|
||||
|
||||
expect(a).toBe(b)
|
||||
expect(a).toContain('html')
|
||||
})
|
||||
|
||||
it('distinguishes different titles', () => {
|
||||
expect(artifactSlug({ kind: 'html', language: 'html', title: 'Timer' })).not.toBe(
|
||||
artifactSlug({ kind: 'html', language: 'html', title: 'Dashboard' })
|
||||
)
|
||||
})
|
||||
|
||||
it('handles empty/symbol-only titles', () => {
|
||||
expect(artifactSlug({ kind: 'code', language: 'ts', title: '!!!' })).toBe('code:ts:untitled')
|
||||
})
|
||||
})
|
||||
|
||||
describe('artifactContentHash', () => {
|
||||
it('is deterministic and content-sensitive', () => {
|
||||
expect(artifactContentHash('abc')).toBe(artifactContentHash('abc'))
|
||||
expect(artifactContentHash('abc')).not.toBe(artifactContentHash('abd'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('artifactDownloadName', () => {
|
||||
it('keeps an existing extension', () => {
|
||||
expect(artifactDownloadName('code', 'python', 'server.py')).toBe('server.py')
|
||||
})
|
||||
|
||||
it('appends by kind and language', () => {
|
||||
expect(artifactDownloadName('html', 'html', 'Pomodoro Timer')).toBe('Pomodoro-Timer.html')
|
||||
expect(artifactDownloadName('svg', 'svg', 'Org Chart')).toBe('Org-Chart.svg')
|
||||
expect(artifactDownloadName('code', 'typescript', 'buildDashboard')).toBe('buildDashboard.ts')
|
||||
expect(artifactDownloadName('code', 'unknownlang', '')).toBe('artifact.txt')
|
||||
})
|
||||
})
|
||||
232
apps/desktop/src/lib/artifact-detect.ts
Normal file
232
apps/desktop/src/lib/artifact-detect.ts
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
import { isLikelyProseCodeBlock, sanitizeLanguageTag } from '@/lib/markdown-code'
|
||||
|
||||
/**
|
||||
* Artifact detection — decides when a fenced block in an assistant message is
|
||||
* substantial, self-contained content that deserves an artifact card (opening
|
||||
* in the right rail) instead of an inline code block.
|
||||
*
|
||||
* Pure and cheap: it runs per streaming delta on the growing fence body, so
|
||||
* everything here is a bounded regex scan or a line count. No store access.
|
||||
*/
|
||||
|
||||
export type ArtifactKind = 'code' | 'html' | 'svg'
|
||||
|
||||
export interface ArtifactDetection {
|
||||
kind: ArtifactKind
|
||||
/** Sanitized fence language ('' possible for html/svg detected by shape). */
|
||||
language: string
|
||||
/** Human title derived from the content (html <title>, svg <title>, a named
|
||||
* declaration for code). Falls back to a kind/language label. */
|
||||
title: string
|
||||
}
|
||||
|
||||
// A fence only becomes an artifact once it is unambiguously a document (html),
|
||||
// a large standalone graphic (svg), or long enough that inlining it would
|
||||
// drown the conversation (code). Small snippets stay inline code cards.
|
||||
const HTML_DOC_RE = /<!doctype\s+html|<html[\s>]|<head[\s>]|<body[\s>]/i
|
||||
const HTML_TAG_RE = /<[a-z][a-z0-9-]*(\s[^>]*)?>/i
|
||||
const HTML_DOC_MIN_CHARS = 160
|
||||
const HTML_FRAGMENT_MIN_CHARS = 1200
|
||||
const SVG_MIN_CHARS = 2000
|
||||
const CODE_MIN_LINES = 48
|
||||
const CODE_MIN_CHARS = 3000
|
||||
|
||||
const HTML_LANGUAGES = new Set(['html', 'htm', 'xhtml'])
|
||||
|
||||
// Languages whose fences are never artifacts: prose-ish, terminal output, and
|
||||
// the fences already owned by richer renderers (mermaid diagrams, small svg).
|
||||
const NON_ARTIFACT_LANGUAGES = new Set([
|
||||
'',
|
||||
'console',
|
||||
'diff',
|
||||
'log',
|
||||
'logs',
|
||||
'markdown',
|
||||
'md',
|
||||
'mermaid',
|
||||
'output',
|
||||
'patch',
|
||||
'plain',
|
||||
'plaintext',
|
||||
'shell-session',
|
||||
'stdout',
|
||||
'text',
|
||||
'txt'
|
||||
])
|
||||
|
||||
function countLines(text: string): number {
|
||||
let lines = 1
|
||||
let index = text.indexOf('\n')
|
||||
|
||||
while (index !== -1) {
|
||||
lines += 1
|
||||
index = text.indexOf('\n', index + 1)
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
function stripTags(value: string): string {
|
||||
return value
|
||||
.replace(/<[^>]*>/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function titleFromTag(content: string, tag: 'h1' | 'title'): string {
|
||||
const match = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}>`, 'i').exec(content)
|
||||
|
||||
return match ? stripTags(match[1] || '').slice(0, 80) : ''
|
||||
}
|
||||
|
||||
const CODE_DECLARATION_RE =
|
||||
/(?:^|\n)\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|class|struct|interface|enum|trait|impl|def|fn)\s+([A-Za-z_$][\w$]*)/
|
||||
|
||||
// `// app.py`, `# server.ts`, `<!-- index.html -->`, `/* main.rs */` on the
|
||||
// first meaningful line — the de-facto LLM convention for naming a file.
|
||||
const FILENAME_COMMENT_RE = /^\s*(?:\/\/|#|--|<!--|\/\*)\s*([\w./-]+\.[a-z0-9]{1,8})\b/i
|
||||
|
||||
function codeTitle(language: string, content: string): string {
|
||||
const head = content.slice(0, 2000)
|
||||
const fileName = FILENAME_COMMENT_RE.exec(head)?.[1]
|
||||
|
||||
if (fileName) {
|
||||
return fileName
|
||||
}
|
||||
|
||||
const declaration = CODE_DECLARATION_RE.exec(head)?.[1]
|
||||
|
||||
if (declaration) {
|
||||
return declaration
|
||||
}
|
||||
|
||||
return language
|
||||
}
|
||||
|
||||
export function detectArtifact(language: string | undefined, code: string | undefined): ArtifactDetection | null {
|
||||
const trimmed = (code ?? '').trim()
|
||||
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
|
||||
const clean = sanitizeLanguageTag(language || '')
|
||||
|
||||
if (HTML_LANGUAGES.has(clean)) {
|
||||
const isDocument = HTML_DOC_RE.test(trimmed)
|
||||
|
||||
if (
|
||||
(isDocument && trimmed.length >= HTML_DOC_MIN_CHARS) ||
|
||||
(!isDocument && trimmed.length >= HTML_FRAGMENT_MIN_CHARS && HTML_TAG_RE.test(trimmed))
|
||||
) {
|
||||
return {
|
||||
kind: 'html',
|
||||
language: clean,
|
||||
title: titleFromTag(trimmed, 'title') || titleFromTag(trimmed, 'h1') || 'HTML'
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
if (clean === 'svg') {
|
||||
// Small svg fences keep their inline embed (svg-embed.tsx); only a large
|
||||
// standalone graphic graduates into an artifact tab.
|
||||
if (trimmed.length >= SVG_MIN_CHARS && /<svg[\s>]/i.test(trimmed)) {
|
||||
return { kind: 'svg', language: clean, title: titleFromTag(trimmed, 'title') || 'SVG' }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
if (NON_ARTIFACT_LANGUAGES.has(clean)) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (trimmed.length < CODE_MIN_CHARS && countLines(trimmed) < CODE_MIN_LINES) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (isLikelyProseCodeBlock(clean, trimmed)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { kind: 'code', language: clean, title: codeTitle(clean, trimmed) }
|
||||
}
|
||||
|
||||
/** Stable identity slug for versioning: the same (kind, title, language) in a
|
||||
* session is treated as one artifact the model iterates on. */
|
||||
export function artifactSlug(detection: Pick<ArtifactDetection, 'kind' | 'language' | 'title'>): string {
|
||||
const title = detection.title
|
||||
.toLowerCase()
|
||||
.replace(/[^\p{L}\p{N}]+/gu, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 48)
|
||||
|
||||
return `${detection.kind}:${detection.language}:${title || 'untitled'}`
|
||||
}
|
||||
|
||||
/** Tiny non-cryptographic content hash (FNV-1a) for version dedupe. */
|
||||
export function artifactContentHash(content: string): string {
|
||||
let hash = 0x811c9dc5
|
||||
|
||||
for (let i = 0; i < content.length; i += 1) {
|
||||
hash ^= content.charCodeAt(i)
|
||||
hash = Math.imul(hash, 0x01000193)
|
||||
}
|
||||
|
||||
return (hash >>> 0).toString(36)
|
||||
}
|
||||
|
||||
const DOWNLOAD_EXTENSION_BY_LANGUAGE: Record<string, string> = {
|
||||
bash: '.sh',
|
||||
c: '.c',
|
||||
cpp: '.cpp',
|
||||
csharp: '.cs',
|
||||
css: '.css',
|
||||
go: '.go',
|
||||
htm: '.html',
|
||||
html: '.html',
|
||||
java: '.java',
|
||||
javascript: '.js',
|
||||
js: '.js',
|
||||
json: '.json',
|
||||
jsx: '.jsx',
|
||||
kotlin: '.kt',
|
||||
php: '.php',
|
||||
py: '.py',
|
||||
python: '.py',
|
||||
rb: '.rb',
|
||||
rs: '.rs',
|
||||
ruby: '.rb',
|
||||
rust: '.rs',
|
||||
sh: '.sh',
|
||||
sql: '.sql',
|
||||
svg: '.svg',
|
||||
swift: '.swift',
|
||||
toml: '.toml',
|
||||
ts: '.ts',
|
||||
tsx: '.tsx',
|
||||
typescript: '.ts',
|
||||
xhtml: '.html',
|
||||
xml: '.xml',
|
||||
yaml: '.yaml',
|
||||
yml: '.yaml'
|
||||
}
|
||||
|
||||
export function artifactDownloadName(kind: ArtifactKind, language: string, title: string): string {
|
||||
const base =
|
||||
title
|
||||
.replace(/[^\p{L}\p{N}._ -]+/gu, '')
|
||||
.trim()
|
||||
.replace(/\s+/g, '-')
|
||||
.slice(0, 60) || 'artifact'
|
||||
|
||||
if (/\.[a-z0-9]{1,8}$/i.test(base)) {
|
||||
return base
|
||||
}
|
||||
|
||||
const ext = kind === 'html' ? '.html' : kind === 'svg' ? '.svg' : DOWNLOAD_EXTENSION_BY_LANGUAGE[language] || '.txt'
|
||||
|
||||
return `${base}${ext}`
|
||||
}
|
||||
16
apps/desktop/src/lib/download-text.ts
Normal file
16
apps/desktop/src/lib/download-text.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
/** Save generated text content to a file via a blob download. Electron's
|
||||
* default will-download behavior shows the OS save dialog, so this works
|
||||
* without a dedicated IPC handler (same pattern as use-image-download's
|
||||
* browser fallback). */
|
||||
export function downloadTextFile(name: string, content: string, mimeType = 'text/plain') {
|
||||
const blobUrl = URL.createObjectURL(new Blob([content], { type: `${mimeType};charset=utf-8` }))
|
||||
const link = document.createElement('a')
|
||||
|
||||
link.href = blobUrl
|
||||
link.download = name
|
||||
link.rel = 'noopener noreferrer'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 30_000)
|
||||
}
|
||||
170
apps/desktop/src/store/artifacts.test.ts
Normal file
170
apps/desktop/src/store/artifacts.test.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import type { ArtifactDetection } from '@/lib/artifact-detect'
|
||||
|
||||
import {
|
||||
$artifactRegistry,
|
||||
$artifactTabs,
|
||||
$artifactVersionSelection,
|
||||
artifactsForSession,
|
||||
artifactTabId,
|
||||
clearArtifactRegistry,
|
||||
closeArtifactTab,
|
||||
getArtifact,
|
||||
openArtifactTab,
|
||||
selectArtifactVersion,
|
||||
upsertArtifact
|
||||
} from './artifacts'
|
||||
import { $rightRailActiveTabId, PREVIEW_PANE_ID, RIGHT_RAIL_PREVIEW_TAB_ID } from './layout'
|
||||
import { $paneOpen } from './panes'
|
||||
import { $activeSessionId, $selectedStoredSessionId } from './session'
|
||||
|
||||
const HTML_DETECTION: ArtifactDetection = { kind: 'html', language: 'html', title: 'Pomodoro Timer' }
|
||||
|
||||
describe('artifacts store', () => {
|
||||
beforeEach(() => {
|
||||
$activeSessionId.set('session-1')
|
||||
$selectedStoredSessionId.set(null)
|
||||
window.localStorage.clear()
|
||||
clearArtifactRegistry()
|
||||
$rightRailActiveTabId.set(RIGHT_RAIL_PREVIEW_TAB_ID)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
$activeSessionId.set(null)
|
||||
$selectedStoredSessionId.set(null)
|
||||
clearArtifactRegistry()
|
||||
window.localStorage.clear()
|
||||
})
|
||||
|
||||
it('registers a new artifact with one version', () => {
|
||||
const result = upsertArtifact('session-1', HTML_DETECTION, '<html>v1</html>')
|
||||
|
||||
expect(result?.versionAdded).toBe(true)
|
||||
expect(artifactsForSession('session-1')).toHaveLength(1)
|
||||
expect(getArtifact(result!.artifactId)?.versions).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('dedupes identical content by hash (streaming replays are no-ops)', () => {
|
||||
const first = upsertArtifact('session-1', HTML_DETECTION, '<html>v1</html>')
|
||||
const replay = upsertArtifact('session-1', HTML_DETECTION, '<html>v1</html>')
|
||||
|
||||
expect(replay?.versionAdded).toBe(false)
|
||||
expect(replay?.artifactId).toBe(first?.artifactId)
|
||||
expect(getArtifact(first!.artifactId)?.versions).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('appends a version when the same artifact regenerates with new content', () => {
|
||||
const first = upsertArtifact('session-1', HTML_DETECTION, '<html>v1</html>')
|
||||
const second = upsertArtifact('session-1', HTML_DETECTION, '<html>v2</html>')
|
||||
|
||||
expect(second?.versionAdded).toBe(true)
|
||||
expect(second?.artifactId).toBe(first?.artifactId)
|
||||
|
||||
const record = getArtifact(first!.artifactId)
|
||||
|
||||
expect(record?.versions).toHaveLength(2)
|
||||
expect(record?.versions.at(-1)?.content).toBe('<html>v2</html>')
|
||||
expect(artifactsForSession('session-1')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps different titles as separate artifacts', () => {
|
||||
upsertArtifact('session-1', HTML_DETECTION, '<html>timer</html>')
|
||||
upsertArtifact('session-1', { ...HTML_DETECTION, title: 'Budget Dashboard' }, '<html>budget</html>')
|
||||
|
||||
expect(artifactsForSession('session-1')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('scopes artifacts per session', () => {
|
||||
upsertArtifact('session-1', HTML_DETECTION, '<html>a</html>')
|
||||
upsertArtifact('session-2', HTML_DETECTION, '<html>b</html>')
|
||||
|
||||
expect(artifactsForSession('session-1')).toHaveLength(1)
|
||||
expect(artifactsForSession('session-2')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('persists the registry to localStorage', () => {
|
||||
upsertArtifact('session-1', HTML_DETECTION, '<html>persisted</html>')
|
||||
|
||||
expect(window.localStorage.getItem('hermes.desktop.artifacts.v1')).toContain('persisted')
|
||||
})
|
||||
|
||||
it('rejects empty sessions and empty content', () => {
|
||||
expect(upsertArtifact('', HTML_DETECTION, '<html>x</html>')).toBeNull()
|
||||
expect(upsertArtifact('session-1', HTML_DETECTION, ' ')).toBeNull()
|
||||
})
|
||||
|
||||
it('opens and closes artifact tabs, driving the rail selection', () => {
|
||||
const result = upsertArtifact('session-1', HTML_DETECTION, '<html>v1</html>')!
|
||||
const tabId = artifactTabId(result.artifactId)
|
||||
|
||||
openArtifactTab(result.artifactId)
|
||||
|
||||
expect($artifactTabs.get()).toEqual([tabId])
|
||||
expect($rightRailActiveTabId.get()).toBe(tabId)
|
||||
expect($paneOpen(PREVIEW_PANE_ID).get()).toBe(true)
|
||||
|
||||
closeArtifactTab(tabId)
|
||||
|
||||
expect($artifactTabs.get()).toEqual([])
|
||||
expect($rightRailActiveTabId.get()).toBe(RIGHT_RAIL_PREVIEW_TAB_ID)
|
||||
})
|
||||
|
||||
it('does not duplicate a tab when the same artifact opens twice', () => {
|
||||
const result = upsertArtifact('session-1', HTML_DETECTION, '<html>v1</html>')!
|
||||
|
||||
openArtifactTab(result.artifactId)
|
||||
openArtifactTab(result.artifactId)
|
||||
|
||||
expect($artifactTabs.get()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('tracks version selection and snaps back to latest', () => {
|
||||
const result = upsertArtifact('session-1', HTML_DETECTION, '<html>v1</html>')!
|
||||
|
||||
upsertArtifact('session-1', HTML_DETECTION, '<html>v2</html>')
|
||||
upsertArtifact('session-1', HTML_DETECTION, '<html>v3</html>')
|
||||
|
||||
selectArtifactVersion(result.artifactId, 0)
|
||||
|
||||
expect($artifactVersionSelection.get()[result.artifactId]).toBe(0)
|
||||
|
||||
// Selecting the newest version clears the pin (absent = newest).
|
||||
selectArtifactVersion(result.artifactId, 2)
|
||||
|
||||
expect(result.artifactId in $artifactVersionSelection.get()).toBe(false)
|
||||
|
||||
// Out-of-range clamps.
|
||||
selectArtifactVersion(result.artifactId, -5)
|
||||
|
||||
expect($artifactVersionSelection.get()[result.artifactId]).toBe(0)
|
||||
})
|
||||
|
||||
it('reopening an artifact lands on the newest version', () => {
|
||||
const result = upsertArtifact('session-1', HTML_DETECTION, '<html>v1</html>')!
|
||||
|
||||
upsertArtifact('session-1', HTML_DETECTION, '<html>v2</html>')
|
||||
selectArtifactVersion(result.artifactId, 0)
|
||||
openArtifactTab(result.artifactId)
|
||||
|
||||
expect(result.artifactId in $artifactVersionSelection.get()).toBe(false)
|
||||
})
|
||||
|
||||
it('survives a registry reload round-trip', () => {
|
||||
upsertArtifact('session-1', HTML_DETECTION, '<html>v1</html>')
|
||||
upsertArtifact('session-1', HTML_DETECTION, '<html>v2</html>')
|
||||
|
||||
const raw = window.localStorage.getItem('hermes.desktop.artifacts.v1')!
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown[]>
|
||||
|
||||
expect(parsed['session-1']).toHaveLength(1)
|
||||
|
||||
// Simulate a fresh boot: hydrate a clean registry from the persisted JSON.
|
||||
$artifactRegistry.set(JSON.parse(raw))
|
||||
|
||||
const record = artifactsForSession('session-1')[0]
|
||||
|
||||
expect(record?.versions).toHaveLength(2)
|
||||
expect(record?.versions.at(-1)?.content).toBe('<html>v2</html>')
|
||||
})
|
||||
})
|
||||
386
apps/desktop/src/store/artifacts.ts
Normal file
386
apps/desktop/src/store/artifacts.ts
Normal file
|
|
@ -0,0 +1,386 @@
|
|||
import { atom, computed } from 'nanostores'
|
||||
|
||||
import { artifactContentHash, type ArtifactDetection, type ArtifactKind, artifactSlug } from '@/lib/artifact-detect'
|
||||
import { persistentAtom } from '@/lib/persisted'
|
||||
|
||||
import { $rightRailActiveTabId, PREVIEW_PANE_ID, RIGHT_RAIL_PREVIEW_TAB_ID, selectRightRailTab } from './layout'
|
||||
import { setPaneOpen } from './panes'
|
||||
import { $activeSessionId, $selectedStoredSessionId } from './session'
|
||||
|
||||
/**
|
||||
* ARTIFACT REGISTRY — substantial generated content (HTML pages, large SVGs,
|
||||
* long code) produced in the transcript, promoted out of the message flow into
|
||||
* versioned, openable artifacts. The renderer owns this state: artifacts are a
|
||||
* presentation of message content the backend already persists, so the store
|
||||
* is a cache keyed by session with bounded history.
|
||||
*
|
||||
* Identity: one artifact = one (session, slug) pair, where the slug derives
|
||||
* from kind + language + title. When the model regenerates "the dashboard"
|
||||
* three times in a session, that is ONE artifact with three versions, exactly
|
||||
* like a document the user keeps refining — not three cards.
|
||||
*/
|
||||
|
||||
export interface ArtifactVersion {
|
||||
content: string
|
||||
createdAt: number
|
||||
hash: string
|
||||
}
|
||||
|
||||
export interface ArtifactRecord {
|
||||
createdAt: number
|
||||
id: string
|
||||
kind: ArtifactKind
|
||||
language: string
|
||||
sessionId: string
|
||||
slug: string
|
||||
title: string
|
||||
updatedAt: number
|
||||
/** Oldest → newest. The last entry is the current version. */
|
||||
versions: ArtifactVersion[]
|
||||
}
|
||||
|
||||
type ArtifactRegistry = Record<string, ArtifactRecord[]>
|
||||
|
||||
const STORAGE_KEY = 'hermes.desktop.artifacts.v1'
|
||||
const MAX_ARTIFACTS_PER_SESSION = 24
|
||||
const MAX_VERSIONS_PER_ARTIFACT = 20
|
||||
const MAX_SESSIONS = 40
|
||||
// localStorage is ~5MB; artifacts carry full content, so cap the persisted
|
||||
// bytes per artifact aggressively. Oversized artifacts survive in memory for
|
||||
// the app's lifetime but persist only their newest version(s) that fit.
|
||||
const MAX_PERSISTED_CHARS_PER_ARTIFACT = 120_000
|
||||
|
||||
export type ArtifactTabId = `artifact:${string}`
|
||||
|
||||
export function artifactTabId(artifactId: string): ArtifactTabId {
|
||||
return `artifact:${artifactId}`
|
||||
}
|
||||
|
||||
export function artifactIdFromTabId(tabId: string): string | null {
|
||||
return tabId.startsWith('artifact:') ? tabId.slice('artifact:'.length) : null
|
||||
}
|
||||
|
||||
function isArtifactVersion(value: unknown): value is ArtifactVersion {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false
|
||||
}
|
||||
|
||||
const r = value as Record<string, unknown>
|
||||
|
||||
return typeof r.content === 'string' && typeof r.createdAt === 'number' && typeof r.hash === 'string'
|
||||
}
|
||||
|
||||
function isArtifactRecord(value: unknown): value is ArtifactRecord {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false
|
||||
}
|
||||
|
||||
const r = value as Record<string, unknown>
|
||||
|
||||
return (
|
||||
typeof r.createdAt === 'number' &&
|
||||
typeof r.id === 'string' &&
|
||||
(r.kind === 'code' || r.kind === 'html' || r.kind === 'svg') &&
|
||||
typeof r.language === 'string' &&
|
||||
typeof r.sessionId === 'string' &&
|
||||
typeof r.slug === 'string' &&
|
||||
typeof r.title === 'string' &&
|
||||
typeof r.updatedAt === 'number' &&
|
||||
Array.isArray(r.versions) &&
|
||||
r.versions.length > 0 &&
|
||||
r.versions.every(isArtifactVersion)
|
||||
)
|
||||
}
|
||||
|
||||
function sanitizeRegistry(value: unknown): ArtifactRegistry {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const out: ArtifactRegistry = {}
|
||||
|
||||
for (const [sessionId, records] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (!Array.isArray(records)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const valid = records.filter(isArtifactRecord)
|
||||
|
||||
if (valid.length > 0) {
|
||||
out[sessionId] = valid
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
function persistedVersions(record: ArtifactRecord): ArtifactVersion[] {
|
||||
const kept: ArtifactVersion[] = []
|
||||
let budget = MAX_PERSISTED_CHARS_PER_ARTIFACT
|
||||
|
||||
// Newest first; always keep at least the current version even if oversized.
|
||||
for (let i = record.versions.length - 1; i >= 0; i -= 1) {
|
||||
const version = record.versions[i]!
|
||||
|
||||
if (kept.length > 0 && version.content.length > budget) {
|
||||
break
|
||||
}
|
||||
|
||||
budget -= version.content.length
|
||||
kept.unshift(version)
|
||||
}
|
||||
|
||||
return kept
|
||||
}
|
||||
|
||||
function pruneRegistry(registry: ArtifactRegistry): ArtifactRegistry {
|
||||
const entries = Object.entries(registry)
|
||||
.map(([sessionId, records]) => {
|
||||
const trimmed = [...records]
|
||||
.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
.slice(0, MAX_ARTIFACTS_PER_SESSION)
|
||||
.sort((a, b) => a.createdAt - b.createdAt)
|
||||
|
||||
return [sessionId, trimmed] as const
|
||||
})
|
||||
.filter(([, records]) => records.length > 0)
|
||||
.sort(([, a], [, b]) => {
|
||||
const latest = (records: readonly ArtifactRecord[]) => Math.max(...records.map(record => record.updatedAt))
|
||||
|
||||
return latest(b) - latest(a)
|
||||
})
|
||||
.slice(0, MAX_SESSIONS)
|
||||
|
||||
return Object.fromEntries(entries)
|
||||
}
|
||||
|
||||
export const $artifactRegistry = persistentAtom<ArtifactRegistry>(
|
||||
STORAGE_KEY,
|
||||
{},
|
||||
{
|
||||
decode: raw => sanitizeRegistry(JSON.parse(raw) as unknown),
|
||||
encode: registry =>
|
||||
JSON.stringify(
|
||||
Object.fromEntries(
|
||||
Object.entries(pruneRegistry(registry)).map(([sessionId, records]) => [
|
||||
sessionId,
|
||||
records.map(record => ({ ...record, versions: persistedVersions(record) }))
|
||||
])
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
/** Artifact tabs open in the right rail (ids into the registry). */
|
||||
export const $artifactTabs = atom<ArtifactTabId[]>([])
|
||||
|
||||
/** Per-tab selected version index; absent = newest. Ephemeral by design: a
|
||||
* reopened artifact always lands on its current version. */
|
||||
export const $artifactVersionSelection = atom<Record<string, number>>({})
|
||||
|
||||
function currentArtifactSessionId(): string {
|
||||
return $selectedStoredSessionId.get() || $activeSessionId.get() || ''
|
||||
}
|
||||
|
||||
export function getArtifact(artifactId: string): ArtifactRecord | null {
|
||||
for (const records of Object.values($artifactRegistry.get())) {
|
||||
const found = records.find(record => record.id === artifactId)
|
||||
|
||||
if (found) {
|
||||
return found
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export const $openArtifacts = computed([$artifactRegistry, $artifactTabs], (registry, tabs) => {
|
||||
const byId = new Map<string, ArtifactRecord>()
|
||||
|
||||
for (const records of Object.values(registry)) {
|
||||
for (const record of records) {
|
||||
byId.set(record.id, record)
|
||||
}
|
||||
}
|
||||
|
||||
return tabs
|
||||
.map(tabId => {
|
||||
const id = artifactIdFromTabId(tabId)
|
||||
const record = id ? byId.get(id) : undefined
|
||||
|
||||
return record ? { record, tabId } : null
|
||||
})
|
||||
.filter((entry): entry is { record: ArtifactRecord; tabId: ArtifactTabId } => entry !== null)
|
||||
})
|
||||
|
||||
export function artifactsForSession(sessionId: string | null | undefined): ArtifactRecord[] {
|
||||
const id = sessionId?.trim()
|
||||
|
||||
if (!id) {
|
||||
return []
|
||||
}
|
||||
|
||||
return $artifactRegistry.get()[id] ?? []
|
||||
}
|
||||
|
||||
interface UpsertResult {
|
||||
artifactId: string
|
||||
record: ArtifactRecord
|
||||
/** True when this call appended a NEW version (vs. deduped/no-op). */
|
||||
versionAdded: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Register (or version) an artifact for a session. Same slug + same content
|
||||
* hash is a no-op (streaming remounts and transcript re-renders call this
|
||||
* repeatedly); same slug + new content appends a version.
|
||||
*/
|
||||
export function upsertArtifact(
|
||||
sessionId: string | null | undefined,
|
||||
detection: ArtifactDetection,
|
||||
content: string
|
||||
): UpsertResult | null {
|
||||
const id = sessionId?.trim()
|
||||
const trimmed = content.trim()
|
||||
|
||||
if (!id || !trimmed) {
|
||||
return null
|
||||
}
|
||||
|
||||
const slug = artifactSlug(detection)
|
||||
const hash = artifactContentHash(trimmed)
|
||||
const registry = $artifactRegistry.get()
|
||||
const records = registry[id] ?? []
|
||||
const existing = records.find(record => record.slug === slug)
|
||||
const now = Date.now()
|
||||
|
||||
if (existing) {
|
||||
const known = existing.versions.some(version => version.hash === hash)
|
||||
|
||||
if (known) {
|
||||
return { artifactId: existing.id, record: existing, versionAdded: false }
|
||||
}
|
||||
|
||||
const versions = [...existing.versions, { content: trimmed, createdAt: now, hash }].slice(
|
||||
-MAX_VERSIONS_PER_ARTIFACT
|
||||
)
|
||||
|
||||
const next: ArtifactRecord = {
|
||||
...existing,
|
||||
// A regenerated artifact may carry a sharper title (html <title> arrives
|
||||
// late in the stream); prefer the newest non-generic one.
|
||||
title: detection.title || existing.title,
|
||||
updatedAt: now,
|
||||
versions
|
||||
}
|
||||
|
||||
$artifactRegistry.set(
|
||||
pruneRegistry({
|
||||
...registry,
|
||||
[id]: records.map(record => (record.id === existing.id ? next : record))
|
||||
})
|
||||
)
|
||||
|
||||
return { artifactId: existing.id, record: next, versionAdded: true }
|
||||
}
|
||||
|
||||
const record: ArtifactRecord = {
|
||||
createdAt: now,
|
||||
id: `${id}:${slug}`,
|
||||
kind: detection.kind,
|
||||
language: detection.language,
|
||||
sessionId: id,
|
||||
slug,
|
||||
title: detection.title,
|
||||
updatedAt: now,
|
||||
versions: [{ content: trimmed, createdAt: now, hash }]
|
||||
}
|
||||
|
||||
$artifactRegistry.set(pruneRegistry({ ...registry, [id]: [...records, record] }))
|
||||
|
||||
return { artifactId: record.id, record, versionAdded: true }
|
||||
}
|
||||
|
||||
export function upsertCurrentSessionArtifact(detection: ArtifactDetection, content: string): UpsertResult | null {
|
||||
return upsertArtifact(currentArtifactSessionId(), detection, content)
|
||||
}
|
||||
|
||||
/** Open an artifact tab in the right rail and select it. User-initiated only
|
||||
* (card click) — never called from streaming, per the no-hijack rule. */
|
||||
export function openArtifactTab(artifactId: string) {
|
||||
const tabId = artifactTabId(artifactId)
|
||||
const current = $artifactTabs.get()
|
||||
|
||||
if (!current.includes(tabId)) {
|
||||
$artifactTabs.set([...current, tabId])
|
||||
}
|
||||
|
||||
// Land on the newest version whenever (re)opened.
|
||||
const selection = $artifactVersionSelection.get()
|
||||
|
||||
if (artifactId in selection) {
|
||||
const { [artifactId]: _dropped, ...rest } = selection
|
||||
$artifactVersionSelection.set(rest)
|
||||
}
|
||||
|
||||
setPaneOpen(PREVIEW_PANE_ID, true)
|
||||
selectRightRailTab(tabId)
|
||||
}
|
||||
|
||||
export function closeArtifactTab(tabId: ArtifactTabId): boolean {
|
||||
const current = $artifactTabs.get()
|
||||
const index = current.indexOf(tabId)
|
||||
|
||||
if (index === -1) {
|
||||
return false
|
||||
}
|
||||
|
||||
const next = current.filter(id => id !== tabId)
|
||||
|
||||
$artifactTabs.set(next)
|
||||
|
||||
const artifactId = artifactIdFromTabId(tabId)
|
||||
|
||||
if (artifactId) {
|
||||
const { [artifactId]: _dropped, ...rest } = $artifactVersionSelection.get()
|
||||
$artifactVersionSelection.set(rest)
|
||||
}
|
||||
|
||||
if ($rightRailActiveTabId.get() === tabId) {
|
||||
selectRightRailTab(next[Math.min(index, next.length - 1)] ?? RIGHT_RAIL_PREVIEW_TAB_ID)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function selectArtifactVersion(artifactId: string, versionIndex: number) {
|
||||
const record = getArtifact(artifactId)
|
||||
|
||||
if (!record) {
|
||||
return
|
||||
}
|
||||
|
||||
const clamped = Math.max(0, Math.min(record.versions.length - 1, versionIndex))
|
||||
const selection = $artifactVersionSelection.get()
|
||||
|
||||
if (clamped === record.versions.length - 1) {
|
||||
if (artifactId in selection) {
|
||||
const { [artifactId]: _dropped, ...rest } = selection
|
||||
$artifactVersionSelection.set(rest)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
$artifactVersionSelection.set({ ...selection, [artifactId]: clamped })
|
||||
}
|
||||
|
||||
export function closeAllArtifactTabs() {
|
||||
$artifactTabs.set([])
|
||||
$artifactVersionSelection.set({})
|
||||
}
|
||||
|
||||
export function clearArtifactRegistry() {
|
||||
$artifactRegistry.set({})
|
||||
closeAllArtifactTabs()
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import { atom } from 'nanostores'
|
|||
import { resetLiveRuntimeTracking } from '@/app/contrib/hooks/use-background-sync'
|
||||
import { resetSidebarBatchCapability } from '@/hermes'
|
||||
import { invalidateProfileScopedQueries } from '@/lib/query-client'
|
||||
import { closeAllArtifactTabs } from '@/store/artifacts'
|
||||
import { resetSessionsLimit } from '@/store/layout'
|
||||
import {
|
||||
$unreadFinishedSessionIds,
|
||||
|
|
@ -61,6 +62,10 @@ export function wipeSessionListsForGatewaySwitch(): void {
|
|||
setMessages([])
|
||||
setFreshDraftReady(true)
|
||||
|
||||
// Artifact tabs reference sessions on the previous backend; the registry
|
||||
// itself survives (it's local presentation state) but open tabs must not.
|
||||
closeAllArtifactTabs()
|
||||
|
||||
// Narrowed: account/marketplace/onboarding caches are global, not gateway-
|
||||
// scoped, so a mode swap must not refetch them.
|
||||
invalidateProfileScopedQueries()
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ export const FILE_BROWSER_PANE_ID = 'file-browser'
|
|||
export const PREVIEW_PANE_ID = 'preview'
|
||||
export const RIGHT_RAIL_PREVIEW_TAB_ID = 'preview'
|
||||
|
||||
export type RightRailTabId = typeof RIGHT_RAIL_PREVIEW_TAB_ID | `file:${string}`
|
||||
export type RightRailTabId = typeof RIGHT_RAIL_PREVIEW_TAB_ID | `artifact:${string}` | `file:${string}`
|
||||
|
||||
ensurePaneRegistered(CHAT_SIDEBAR_PANE_ID, { open: true })
|
||||
ensurePaneRegistered(FILE_BROWSER_PANE_ID, { open: false })
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { atom, computed } from 'nanostores'
|
|||
import { persistentAtom } from '@/lib/persisted'
|
||||
import { normalize } from '@/lib/text'
|
||||
|
||||
import { $artifactTabs, type ArtifactTabId, closeAllArtifactTabs, closeArtifactTab } from './artifacts'
|
||||
import {
|
||||
$rightRailActiveTabId,
|
||||
PREVIEW_PANE_ID,
|
||||
|
|
@ -79,10 +80,12 @@ export const $filePreviewTabs = persistentAtom<FilePreviewTab[]>(TABS_STORAGE_KE
|
|||
})
|
||||
|
||||
// Drop a restored active file-tab that didn't survive validation so the rail
|
||||
// never points at a tab that isn't there.
|
||||
// never points at a tab that isn't there. Artifact tabs are ephemeral (never
|
||||
// restored), so a persisted `artifact:` active id is always stale too.
|
||||
if (
|
||||
$rightRailActiveTabId.get().startsWith('file:') &&
|
||||
!$filePreviewTabs.get().some(tab => tab.id === $rightRailActiveTabId.get())
|
||||
($rightRailActiveTabId.get().startsWith('file:') &&
|
||||
!$filePreviewTabs.get().some(tab => tab.id === $rightRailActiveTabId.get())) ||
|
||||
$rightRailActiveTabId.get().startsWith('artifact:')
|
||||
) {
|
||||
selectRightRailTab(RIGHT_RAIL_PREVIEW_TAB_ID)
|
||||
}
|
||||
|
|
@ -424,10 +427,10 @@ export function dismissPreviewTarget() {
|
|||
$previewTarget.set(null)
|
||||
|
||||
if ($rightRailActiveTabId.get() === RIGHT_RAIL_PREVIEW_TAB_ID) {
|
||||
selectRightRailTab($filePreviewTabs.get()[0]?.id ?? RIGHT_RAIL_PREVIEW_TAB_ID)
|
||||
selectRightRailTab($filePreviewTabs.get()[0]?.id ?? $artifactTabs.get()[0] ?? RIGHT_RAIL_PREVIEW_TAB_ID)
|
||||
}
|
||||
|
||||
setPaneOpen(PREVIEW_PANE_ID, $filePreviewTabs.get().length > 0)
|
||||
setPaneOpen(PREVIEW_PANE_ID, $filePreviewTabs.get().length > 0 || $artifactTabs.get().length > 0)
|
||||
}
|
||||
|
||||
function closeFilePreviewTab(tabId: RightRailTabId) {
|
||||
|
|
@ -447,10 +450,12 @@ function closeFilePreviewTab(tabId: RightRailTabId) {
|
|||
$filePreviewTabs.set(next)
|
||||
|
||||
if ($rightRailActiveTabId.get() === tabId) {
|
||||
selectRightRailTab(next[Math.min(index, next.length - 1)]?.id ?? RIGHT_RAIL_PREVIEW_TAB_ID)
|
||||
selectRightRailTab(
|
||||
next[Math.min(index, next.length - 1)]?.id ?? $artifactTabs.get()[0] ?? RIGHT_RAIL_PREVIEW_TAB_ID
|
||||
)
|
||||
}
|
||||
|
||||
if (next.length === 0 && !$previewTarget.get()) {
|
||||
if (next.length === 0 && !$previewTarget.get() && $artifactTabs.get().length === 0) {
|
||||
setPaneOpen(PREVIEW_PANE_ID, false)
|
||||
}
|
||||
}
|
||||
|
|
@ -464,6 +469,16 @@ export function closeRightRailTab(tabId: RightRailTabId) {
|
|||
return
|
||||
}
|
||||
|
||||
if (tabId.startsWith('artifact:')) {
|
||||
closeArtifactTab(tabId as ArtifactTabId)
|
||||
|
||||
if (!$previewTarget.get() && $filePreviewTabs.get().length === 0 && $artifactTabs.get().length === 0) {
|
||||
setPaneOpen(PREVIEW_PANE_ID, false)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
closeFilePreviewTab(tabId)
|
||||
}
|
||||
|
||||
|
|
@ -474,7 +489,7 @@ export function closeActiveRightRailTab(): boolean {
|
|||
let tabId = $rightRailActiveTabId.get()
|
||||
|
||||
if (tabId === RIGHT_RAIL_PREVIEW_TAB_ID && !$previewTarget.get()) {
|
||||
const fallback = $filePreviewTabs.get()[0]?.id
|
||||
const fallback = $filePreviewTabs.get()[0]?.id ?? $artifactTabs.get()[0]
|
||||
|
||||
if (!fallback) {
|
||||
return false
|
||||
|
|
@ -493,6 +508,16 @@ export function closeActiveRightRailTab(): boolean {
|
|||
return true
|
||||
}
|
||||
|
||||
if (tabId.startsWith('artifact:')) {
|
||||
if (!$artifactTabs.get().includes(tabId as ArtifactTabId)) {
|
||||
return false
|
||||
}
|
||||
|
||||
closeRightRailTab(tabId)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (!$filePreviewTabs.get().some(tab => tab.id === tabId)) {
|
||||
return false
|
||||
}
|
||||
|
|
@ -503,7 +528,7 @@ export function closeActiveRightRailTab(): boolean {
|
|||
}
|
||||
|
||||
// The rail's visible tab order: the live preview tab (when present) first, then
|
||||
// the file tabs in their stored order. Mirrors `ChatPreviewRail`'s `tabs` memo
|
||||
// the file tabs, then artifact tabs. Mirrors `ChatPreviewRail`'s `tabs` memo
|
||||
// so "close others / to the right" act on what the user actually sees.
|
||||
function rightRailTabOrder(): RightRailTabId[] {
|
||||
const ids: RightRailTabId[] = []
|
||||
|
|
@ -516,6 +541,10 @@ function rightRailTabOrder(): RightRailTabId[] {
|
|||
ids.push(tab.id)
|
||||
}
|
||||
|
||||
for (const tabId of $artifactTabs.get()) {
|
||||
ids.push(tabId)
|
||||
}
|
||||
|
||||
return ids
|
||||
}
|
||||
|
||||
|
|
@ -544,13 +573,14 @@ export function closeRightRailTabsToRight(tabId: RightRailTabId) {
|
|||
}
|
||||
}
|
||||
|
||||
/** Dismisses the active preview + every file tab so the rail pane unmounts. */
|
||||
/** Dismisses the active preview + every file and artifact tab so the rail pane unmounts. */
|
||||
export function closeRightRail() {
|
||||
if ($previewTarget.get()) {
|
||||
dismissPreviewTarget()
|
||||
}
|
||||
|
||||
$filePreviewTabs.set([])
|
||||
closeAllArtifactTabs()
|
||||
setPaneOpen(PREVIEW_PANE_ID, false)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue