Merge upstream main into feat/hermes-relay-shared-metrics

This commit is contained in:
Alex Fournier 2026-07-27 16:10:24 -07:00
commit c07f2e023a
51 changed files with 1405 additions and 1678 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

@ -1,5 +1,6 @@
import { useStore } from '@nanostores/react'
import { useSessionView } from '@/app/chat/session-view'
import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
@ -8,8 +9,7 @@ import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import { cn } from '@/lib/utils'
import type { ComposerAttachment } from '@/store/composer'
import { notifyError } from '@/store/notifications'
import { setCurrentSessionPreviewTarget } from '@/store/preview'
import { $currentCwd } from '@/store/session'
import { openPreview } from '@/store/preview'
export function AttachmentList({
attachments,
@ -31,13 +31,15 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
const { t } = useI18n()
const c = t.composer
const Icon = { folder: FolderOpen, url: Link, image: ImageIcon, file: FileText, terminal: Terminal }[attachment.kind]
const cwd = useStore($currentCwd)
// The tile's cwd when this pill lives in a tile composer, not the primary's:
// a relative attachment path has to resolve against its own session's root.
const cwd = useStore(useSessionView().$cwd)
const isUploading = attachment.uploadState === 'uploading'
const hasUploadError = attachment.uploadState === 'error'
const canPreview = attachment.kind !== 'folder' && attachment.kind !== 'terminal' && !isUploading
const detail = attachment.detail && attachment.detail !== attachment.label ? attachment.detail : undefined
async function openPreview() {
async function openAttachmentPreview() {
if (!canPreview) {
return
}
@ -70,7 +72,7 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
? { ...preview, dataUrl: attachment.previewUrl, previewKind: 'image' as const }
: preview
setCurrentSessionPreviewTarget(withBytes, 'manual', target)
openPreview(withBytes, 'manual')
} catch (error) {
notifyError(error, c.previewUnavailable)
}
@ -89,7 +91,7 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
: 'border-border/60 hover:border-primary/35 hover:bg-accent/45'
)}
disabled={!canPreview}
onClick={() => void openPreview()}
onClick={() => void openAttachmentPreview()}
type="button"
>
<span className="relative grid size-8 shrink-0 place-items-center overflow-hidden rounded-lg border border-border/55 bg-muted/35 text-muted-foreground">

View file

@ -2,12 +2,14 @@ import type { Unstable_TriggerItem } from '@assistant-ui/core'
import { describe, expect, it } from 'vitest'
import {
acceptsTriggerCompletion,
isPendingDraftPersistCurrent,
type PendingDraftPersist,
pickPlaceholder,
slashArgStage,
slashChipKindForItem,
slashCommandToken
slashCommandToken,
type TriggerAcceptInput
} from './composer-utils'
const item = (group: string): Unstable_TriggerItem =>
@ -39,6 +41,53 @@ describe('slashChipKindForItem', () => {
})
})
describe('acceptsTriggerCompletion', () => {
const press = (key: string, overrides: Partial<TriggerAcceptInput> = {}) =>
acceptsTriggerCompletion({
activeExplicit: false,
freeTextArgStage: false,
key,
kind: '/',
query: 'personality alic',
...overrides
})
it('accepts on Enter / Tab / Space for a finite option list', () => {
expect(press('Enter')).toBe(true)
expect(press('Tab')).toBe(true)
expect(press(' ')).toBe(true)
})
it('ignores keys that are neither navigation nor acceptance', () => {
expect(press('a')).toBe(false)
expect(press('Escape')).toBe(false)
})
it('lets an `@` mention take a literal space', () => {
expect(press(' ', { kind: '@', query: 'src/comp' })).toBe(false)
expect(press('Enter', { kind: '@', query: 'src/comp' })).toBe(true)
})
it('types a space on a bare `/ ` instead of accepting', () => {
expect(press(' ', { query: '' })).toBe(false)
})
// The `/goal <prose>` class: the popover may be live over free-form text, so
// the keys that mean something else in prose must keep meaning it.
it('sends the prose rather than the unchosen first row', () => {
expect(press('Enter', { freeTextArgStage: true, query: 'goal ship the redesign' })).toBe(false)
expect(press(' ', { freeTextArgStage: true, query: 'goal ship the' })).toBe(false)
})
it('accepts on Enter once the user has arrowed to a row deliberately', () => {
expect(press('Enter', { activeExplicit: true, freeTextArgStage: true, query: 'goal stat' })).toBe(true)
})
it('keeps Tab as the explicit accept even over free text', () => {
expect(press('Tab', { freeTextArgStage: true, query: 'goal stat' })).toBe(true)
})
})
describe('pickPlaceholder', () => {
it('returns a member of the pool', () => {
const pool = ['a', 'b', 'c'] as const

View file

@ -4,6 +4,8 @@ import type { SlashChipKind } from '@/components/assistant-ui/directive-text'
import type { ComposerAttachment } from '@/store/composer'
import { setSessionPickerOpen } from '@/store/session'
import type { TriggerState } from './text-utils'
export const COMPOSER_STACK_BREAKPOINT_PX = 320
// Above the stack breakpoint but still cramped: the model pill sheds its label
@ -59,6 +61,48 @@ export const slashArgStage = (query: string) => query.includes(' ')
/** The `/command` token of a slash query (`personality x` → `/personality`). */
export const slashCommandToken = (query: string) => `/${query.split(/\s+/, 1)[0]?.toLowerCase() ?? ''}`
export interface TriggerAcceptInput {
/** The user moved the highlight themselves (arrow keys) rather than
* inheriting the list's default first row. */
activeExplicit: boolean
/** The trigger is a slash command whose argument is arbitrary prose. */
freeTextArgStage: boolean
key: string
kind: TriggerState['kind']
query: string
}
/**
* Whether a keypress accepts the highlighted completion while the popover is
* open. Tab is always an accept it has no other meaning in the composer.
*
* Enter and Space are conditional, because both mean something else while a
* free-text argument is being written (`/goal ship the redesign`). Space types
* a space, and Enter sends the message; letting either take the popover's
* pre-highlighted row would swap the prose the user is mid-sentence on for a
* subcommand they never chose. Enter still accepts once the user has arrowed
* to a row deliberately, so the highlight never lies about what Enter will do.
*/
export function acceptsTriggerCompletion({
activeExplicit,
freeTextArgStage,
key,
kind,
query
}: TriggerAcceptInput): boolean {
if (key === 'Tab') {
return true
}
if (key === 'Enter') {
return !freeTextArgStage || activeExplicit
}
// Space is slash-only (an `@` mention takes a literal space) and gated to a
// non-empty query so a bare `/ ` still types a space.
return key === ' ' && kind === '/' && Boolean(query.trim()) && !freeTextArgStage
}
export interface QueueEditState {
attachments: ComposerAttachment[]
draft: string

View file

@ -4,10 +4,11 @@ import { useEffect, useRef } from 'react'
import { playSpeechText } from '@/lib/voice-playback'
import { ownsAmbientCue } from '@/store/ambient'
import { notifyError } from '@/store/notifications'
import { $messages } from '@/store/session'
import { $voicePlayback } from '@/store/voice-playback'
import { $autoSpeakReplies } from '@/store/voice-prefs'
import { useComposerScope } from '../scope'
interface AutoSpeakReply {
id: string
pending: boolean
@ -40,6 +41,9 @@ export function useAutoSpeakReplies({
sessionId
}: UseAutoSpeakReplies) {
const enabled = useStore($autoSpeakReplies)
// Wake on THIS composer's transcript: a tile subscribed to the primary's
// would never fire on its own replies (and would fire on someone else's).
const { $messages } = useComposerScope()
const latest = useRef({ conversationActive, failureLabel, markSpoken, pendingReply })
latest.current = { conversationActive, failureLabel, markSpoken, pendingReply }
@ -83,5 +87,5 @@ export function useAutoSpeakReplies({
const stops = [$messages.subscribe(speakLatest), $voicePlayback.listen(speakLatest)]
return () => stops.forEach(f => f())
}, [enabled, sessionId])
}, [$messages, enabled, sessionId])
}

View file

@ -148,6 +148,44 @@ describe('useComposerTrigger — free-text slash arguments', () => {
expect(editor.querySelector('[data-slash-kind]')).toBeNull()
})
it('treats the default highlight as a suggestion until the user arrows to a row', () => {
const editor = mountEditor('/goal stat')
const { hook } = mountTrigger(editor, [item('/goal status', 'Options')])
act(() => hook.result.current.refreshTrigger())
expect(hook.result.current.triggerActiveExplicit).toBe(false)
act(() => hook.result.current.moveTriggerActive(1))
expect(hook.result.current.triggerActiveExplicit).toBe(true)
})
it('drops a deliberate selection once the query moves on', () => {
const editor = mountEditor('/goal stat')
const { hook } = mountTrigger(editor, [item('/goal status', 'Options')])
act(() => hook.result.current.refreshTrigger())
act(() => hook.result.current.moveTriggerActive(1))
renderComposerContents(editor, '/goal start the migration')
act(() => hook.result.current.refreshTrigger())
expect(hook.result.current.triggerActiveExplicit).toBe(false)
})
it('keeps a multi-word /resume search typeable instead of firing the picker action', () => {
// The session list always ends in a "Browse all sessions…" action row, so
// an accept here doesn't insert a chip — it empties the composer and opens
// the overlay, taking the half-typed query with it.
const editor = mountEditor('/resume my new')
const { hook } = mountTrigger(editor, [item('/resume', 'Sessions')])
act(() => hook.result.current.refreshTrigger())
expect(hook.result.current.slashFreeTextArgStage).toBe(true)
expect(hook.result.current.commitTypedSlashDirective()).toBe(false)
expect(composerPlainText(editor)).toBe('/resume my new')
})
it('still commits a fully typed finite option as one directive chip', () => {
const editor = mountEditor('/personality creative')
const { hook } = mountTrigger(editor, [])

View file

@ -53,6 +53,11 @@ export function useComposerTrigger({
}: UseComposerTriggerOptions) {
const [trigger, setTrigger] = useState<TriggerState | null>(null)
const [triggerActive, setTriggerActive] = useState(0)
// The list highlights its first row on open, which is a suggestion rather
// than a choice. This records that the user moved the highlight themselves,
// which is what lets Enter accept a completion in a free-text argument stage
// without stealing prose from everyone who never touched the arrows.
const [triggerActiveExplicit, setTriggerActiveExplicit] = useState(false)
const [triggerItems, setTriggerItems] = useState<readonly Unstable_TriggerItem[]>([])
// Set synchronously in keydown when the open trigger popover consumes a
// navigation/control key (Arrow/Enter/Tab/Escape). The subsequent keyup must
@ -63,6 +68,11 @@ export function useComposerTrigger({
// re-rendered and the handler closure sees the post-keydown state.
const triggerKeyConsumedRef = useRef(false)
const resetTriggerActive = useCallback(() => {
setTriggerActive(0)
setTriggerActiveExplicit(false)
}, [])
const refreshTrigger = useCallback(() => {
const editor = editorRef.current
@ -80,7 +90,7 @@ export function useComposerTrigger({
if (!rawText.includes('@') && !rawText.includes('/')) {
if (trigger) {
setTrigger(null)
setTriggerActive(0)
resetTriggerActive()
}
return
@ -109,9 +119,9 @@ export function useComposerTrigger({
// caret move (mouseup) or a stray refresh — must preserve the user's
// current selection instead of snapping back to the first item.
if (detected?.kind !== trigger?.kind || detected?.query !== trigger?.query) {
setTriggerActive(0)
resetTriggerActive()
}
}, [editorRef, trigger])
}, [editorRef, resetTriggerActive, trigger])
const triggerAdapter: Unstable_TriggerAdapter | null =
trigger?.kind === '@' ? at.adapter : trigger?.kind === '/' ? slash.adapter : null
@ -139,15 +149,23 @@ export function useComposerTrigger({
// Space/Tab — neither should dead-end on a popover.
const argStageEmpty = trigger?.kind === '/' && slashArgStage(trigger.query) && !triggerLoading && !triggerItems.length
const slashFreeTextArgStage =
trigger?.kind === '/' &&
slashArgStage(trigger.query) &&
['mixed', 'text'].includes(desktopSlashCommandArgumentMode(slashCommandToken(trigger.query)) ?? '')
const slashArgumentMode =
trigger?.kind === '/' && slashArgStage(trigger.query)
? desktopSlashCommandArgumentMode(slashCommandToken(trigger.query))
: null
const slashFreeTextArgStage = slashArgumentMode === 'mixed' || slashArgumentMode === 'text'
const closeTrigger = () => {
setTrigger(null)
setTriggerItems([])
setTriggerActive(0)
resetTriggerActive()
}
/** Step the highlight, marking it as the user's own deliberate pick. */
const moveTriggerActive = (delta: number) => {
setTriggerActiveExplicit(true)
setTriggerActive(idx => (idx + delta + triggerItems.length) % triggerItems.length)
}
useEffect(() => {
@ -358,12 +376,14 @@ export function useComposerTrigger({
ascendTriggerPath,
closeTrigger,
commitTypedSlashDirective,
moveTriggerActive,
refreshTrigger,
replaceTriggerWithChip,
setTriggerActive,
slashFreeTextArgStage,
trigger,
triggerActive,
triggerActiveExplicit,
triggerItems,
triggerKeyConsumedRef,
triggerLoading

View file

@ -5,11 +5,11 @@ import { chatMessageText, collectUnspokenTurnSpeech } from '@/lib/chat-messages'
import { triggerHaptic } from '@/lib/haptics'
import { resetBrowseState } from '@/store/composer-input-history'
import { notifyError } from '@/store/notifications'
import { $messages } from '@/store/session'
import { $autoSpeakReplies, setAutoSpeakReplies } from '@/store/voice-prefs'
import type { ComposerTarget } from '../focus'
import { onComposerVoiceToggleRequest } from '../focus'
import { useComposerScope } from '../scope'
import type { ChatBarProps } from '../types'
import { useAutoSpeakReplies } from './use-auto-speak-replies'
@ -50,6 +50,8 @@ export function useComposerVoice({
target
}: UseComposerVoiceArgs) {
const { t } = useI18n()
// A tile's composer speaks ITS transcript, not the primary chat's.
const { $messages } = useComposerScope()
const [voiceConversationActive, setVoiceConversationActive] = useState(false)
const lastSpokenIdRef = useRef<string | null>(null)

View file

@ -1,6 +1,14 @@
import { ComposerPrimitive } from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import { type ClipboardEvent, type FormEvent, type KeyboardEvent, useCallback, useEffect, useRef } from 'react'
import {
type ClipboardEvent,
type FormEvent,
type KeyboardEvent,
useCallback,
useEffect,
useMemo,
useRef
} from 'react'
import { composerFill, composerSurfaceGlass } from '@/components/chat/composer-dock'
import { Button } from '@/components/ui/button'
@ -11,7 +19,7 @@ import { sanitizeComposerInput } from '@/lib/composer-input-sanitize'
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import { $compactionActive } from '@/store/compaction'
import { sessionCompacting } from '@/store/compaction'
import { browseBackward, browseForward, deriveUserHistory, isBrowsingHistory } from '@/store/composer-input-history'
import { POPOUT_WIDTH_REM } from '@/store/composer-popout'
import { parkQueuedPrompts, removeQueuedPrompt, unparkQueuedPrompts } from '@/store/composer-queue'
@ -22,7 +30,12 @@ import { $autoSpeakReplies } from '@/store/voice-prefs'
import { useTheme } from '@/themes'
import { AttachmentList } from './attachments'
import { COMPOSER_FADE_BACKGROUND, type QueueEditState, slashArgStage } from './composer-utils'
import {
acceptsTriggerCompletion,
COMPOSER_FADE_BACKGROUND,
type QueueEditState,
slashArgStage
} from './composer-utils'
import { ContextMenu } from './context-menu'
import { COMPOSER_AREAS, runComposerMiddleware } from './contrib'
import { ComposerControls } from './controls'
@ -108,7 +121,7 @@ export function ChatBar({
// focus-bus key, and awaiting-input edge. Main scope = the legacy globals.
const scope = useComposerScope()
const attachments = useStore(scope.attachments.$attachments)
const compacting = useStore($compactionActive)
const compacting = useStore(useMemo(() => sessionCompacting(sessionId ?? null), [sessionId]))
const scrolledUp = useStore($threadScrolledUp)
const autoSpeak = useStore($autoSpeakReplies)
// The turn is parked on the user (clarify / approval / sudo / secret). Esc must
@ -302,12 +315,14 @@ export function ChatBar({
ascendTriggerPath,
closeTrigger,
commitTypedSlashDirective,
moveTriggerActive,
refreshTrigger,
replaceTriggerWithChip,
setTriggerActive,
slashFreeTextArgStage,
trigger,
triggerActive,
triggerActiveExplicit,
triggerItems,
triggerKeyConsumedRef,
triggerLoading
@ -528,7 +543,7 @@ export function ChatBar({
if (event.key === 'ArrowDown') {
event.preventDefault()
triggerKeyConsumedRef.current = true
setTriggerActive(idx => (idx + 1) % triggerItems.length)
moveTriggerActive(1)
return
}
@ -536,20 +551,21 @@ export function ChatBar({
if (event.key === 'ArrowUp') {
event.preventDefault()
triggerKeyConsumedRef.current = true
setTriggerActive(idx => (idx - 1 + triggerItems.length) % triggerItems.length)
moveTriggerActive(-1)
return
}
// Enter / Tab / Space all accept the highlighted item: a no-arg command
// commits its directive chip, an arg-taking command expands to its
// options step, and an arg option commits the full `/cmd arg` chip. Space
// is slash-only (an `@` mention takes a literal space) and gated to a
// non-empty query so a bare `/ ` still types a space.
const acceptOnSpace =
event.key === ' ' && trigger.kind === '/' && Boolean(trigger.query.trim()) && !slashFreeTextArgStage
const accept = event.key === 'Enter' || event.key === 'Tab' || acceptOnSpace
// Accepting the highlighted item: a no-arg command commits its directive
// chip, an arg-taking command expands to its options step, and an arg
// option commits the full `/cmd arg` chip.
const accept = acceptsTriggerCompletion({
activeExplicit: triggerActiveExplicit,
freeTextArgStage: slashFreeTextArgStage,
key: event.key,
kind: trigger.kind,
query: trigger.query
})
if (accept) {
event.preventDefault()
@ -637,7 +653,7 @@ export function ChatBar({
// $messages is read imperatively (not subscribed) so the composer
// doesn't re-render on every streaming delta flush.
const history = deriveUserHistory(scope.readMessages(), chatMessageText)
const history = deriveUserHistory(scope.$messages.get(), chatMessageText)
const entry = browseBackward(sessionId, currentDraft, history)
if (entry !== null) {
@ -662,7 +678,7 @@ export function ChatBar({
event.preventDefault()
triggerKeyConsumedRef.current = true
const history = deriveUserHistory(scope.readMessages(), chatMessageText)
const history = deriveUserHistory(scope.$messages.get(), chatMessageText)
const result = browseForward(sessionId, history)
if (result !== null) {

View file

@ -25,18 +25,19 @@ export interface ComposerScope {
attachments: ComposerAttachmentScope
/** Only the main scope may pop out (the floating composer is a singleton). */
popoutAllowed: boolean
/** Imperative read of this scope's transcript (input-history browse)
* never subscribed, so streaming stays out of the composer's renders. */
readMessages: () => ChatMessage[]
/** This scope's transcript. Read it imperatively (input-history browse) to
* keep streaming out of the composer's renders; subscribe only off-render
* (auto-speak) where the reply edge is the whole point. */
$messages: ReadableAtom<ChatMessage[]>
/** Focus-bus routing key (`'main'` | `'tile:<id>'`). */
target: ComposerTarget
}
export const MAIN_COMPOSER_SCOPE: ComposerScope = {
$awaitingInput: $activeSessionAwaitingInput,
$messages,
attachments: mainComposerScope,
popoutAllowed: true,
readMessages: () => $messages.get(),
target: 'main'
}

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

@ -126,9 +126,9 @@ function TileChat({
const scope = useMemo<ComposerScope>(
() => ({
$awaitingInput: sessionAwaitingInput(runtimeId),
$messages: view.$messages,
attachments,
popoutAllowed: false,
readMessages: () => view.$messages.get(),
target: `tile:${storedSessionId}`
}),
[attachments, runtimeId, storedSessionId, view.$messages]

View file

@ -27,6 +27,8 @@ import {
revealTreePane,
setPaneCollapsed,
setTreePaneHidden,
setTreeSideCollapsed,
treeSideOfPane,
watchContributedPanes
} from '@/components/pane-shell/tree/store'
import { SidebarProvider } from '@/components/ui/sidebar'
@ -39,7 +41,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 +53,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'
@ -534,7 +535,13 @@ bindTreeSideVisibility('right', $fileBrowserOpen, setFileBrowserOpen)
// rode the rail's row and vanished with it), its zone stands on its own.
const $hasWorkspace = computed($currentCwd, cwd => Boolean(cwd.trim()))
bindPaneVisibility('files', $hasWorkspace)
// The tree pane's own presence tracks ⌘J directly, not just the column's
// collapse — otherwise revealing a preview (which opens that shared column)
// would drag the tree along with it. See revealPreview.
bindPaneVisibility(
'files',
computed([$hasWorkspace, $fileBrowserOpen], (workspace, open) => workspace && open)
)
// ⌘G — the review sidebar appears/disappears (and comes to the front).
bindPaneVisibility(
'review',
@ -553,11 +560,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)
@ -600,22 +604,25 @@ registerPaneCloser('files', () =>
// the side, unhide, front — a NEW target while already visible still fronts.
const revealPreview = () => {
dockPaneBeside('preview', 'files')
// The preview shares a collapsible column with the file tree, and
// revealTreePane un-collapses a column through its bound store — here ⌘J /
// $fileBrowserOpen, which IS the tree's toggle. Going through it would open
// the tree every time a preview opened. Un-collapse the column directly and
// leave the toggle alone, so a preview can appear on its own.
const side = treeSideOfPane('preview')
if (side) {
setTreeSideCollapsed(side, false)
}
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

@ -1,7 +1,8 @@
import { useAuiState } from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import { type FC, type ReactNode, useEffect, useState } from 'react'
import { type FC, type ReactNode, useEffect, useMemo, useState } from 'react'
import { useSessionView } from '@/app/chat/session-view'
import { useElapsedSeconds } from '@/components/chat/activity-timer'
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
import { Codicon } from '@/components/ui/codicon'
@ -9,9 +10,9 @@ import { Loader } from '@/components/ui/loader'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
import { $backgroundResume } from '@/store/background-delegation'
import { $compactionActive } from '@/store/compaction'
import { $activeSessionAwaitingInput } from '@/store/prompts'
import { $activeSessionId, $turnStartedAt } from '@/store/session'
import { sessionCompacting } from '@/store/compaction'
import { sessionAwaitingInput } from '@/store/prompts'
import { $turnStartedAt } from '@/store/session'
const StatusRow: FC<{ children: ReactNode; label: string } & React.ComponentPropsWithoutRef<'div'>> = ({
children,
@ -37,11 +38,23 @@ const CompactionHint: FC = () => (
<span className="shimmer min-w-0 truncate text-muted-foreground/55">{COMPACTION_LABEL}</span>
)
function useActiveTurnTimerKey(): string | undefined {
const activeSessionId = useStore($activeSessionId)
/** These indicators render inside whichever transcript mounted them, so every
* session-scoped signal comes from that surface's view a tile must never
* show the primary chat's compaction, prompt-wait, or turn timer. */
function useThreadSessionStatus() {
const sessionId = useStore(useSessionView().$runtimeId)
const turnStartedAt = useStore($turnStartedAt)
const compacting = useStore(useMemo(() => sessionCompacting(sessionId), [sessionId]))
// A pending clarify / approval / sudo / secret means the turn is paused on the
// user, not working — so don't resurrect the "thinking" timer while they
// decide (matches the pet's awaitingInput pose taking priority over busy).
const awaitingInput = useStore(useMemo(() => sessionAwaitingInput(sessionId), [sessionId]))
return activeSessionId && turnStartedAt ? `turn:${activeSessionId}:${turnStartedAt}` : undefined
return {
awaitingInput,
compacting,
turnTimerKey: sessionId && turnStartedAt ? `turn:${sessionId}:${turnStartedAt}` : undefined
}
}
export const CenteredThreadSpinner: FC = () => {
@ -67,9 +80,8 @@ export const CenteredThreadSpinner: FC = () => {
export const ResponseLoadingIndicator: FC = () => {
const { t } = useI18n()
const timerKey = useActiveTurnTimerKey()
const elapsed = useElapsedSeconds(true, timerKey)
const compacting = useStore($compactionActive)
const { compacting, turnTimerKey } = useThreadSessionStatus()
const elapsed = useElapsedSeconds(true, turnTimerKey)
return (
<StatusRow
@ -147,12 +159,7 @@ export const StreamStallIndicator: FC = () => {
// what lets the timer read "quiet for 12s" rather than the age of this
// component, which is the whole turn so far.
const [quietSince, setQuietSince] = useState<number | undefined>(undefined)
const compacting = useStore($compactionActive)
const turnTimerKey = useActiveTurnTimerKey()
// A pending clarify / approval / sudo / secret means the turn is paused on the
// user, not working — so don't resurrect the "thinking" timer while they
// decide (matches the pet's awaitingInput pose taking priority over busy).
const awaitingInput = useStore($activeSessionAwaitingInput)
const { awaitingInput, compacting, turnTimerKey } = useThreadSessionStatus()
useEffect(() => {
setQuietSince(undefined)

View file

@ -0,0 +1,76 @@
import { cleanup, render } from '@testing-library/react'
import { atom } from 'nanostores'
import type { ComponentProps, ReactNode } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { type SessionView, SessionViewProvider } from '@/app/chat/session-view'
import { $previewStatusBySession } from '@/store/preview-status'
import { $activeSessionId, $currentCwd } from '@/store/session'
vi.mock('@assistant-ui/react', async importOriginal => ({
...(await importOriginal<Record<string, unknown>>()),
useAuiState: (select: (state: unknown) => unknown) =>
select({ message: { id: 'msg-1', status: { type: 'complete' } }, thread: { isRunning: false } })
}))
const { ToolFallback } = await import('./fallback')
const PRIMARY_ID = 'primary-session'
const TILE_ID = 'tile-session'
/** Minimal tile view: only the fields the tool row reads. */
function tileView(): SessionView {
return {
...({} as SessionView),
$cwd: atom('/tile/work'),
$messages: atom([]),
$runtimeId: atom<null | string>(TILE_ID),
kind: 'tile'
}
}
function renderToolRow(wrap: (node: ReactNode) => ReactNode) {
const props = {
args: { path: '/tile/work/report.html' },
result: { path: '/tile/work/report.html' },
toolCallId: 'call-1',
toolName: 'write_file'
} as unknown as ComponentProps<typeof ToolFallback>
render(<>{wrap(<ToolFallback {...props} />)}</>)
}
afterEach(() => {
cleanup()
$previewStatusBySession.set({})
$activeSessionId.set(null)
$currentCwd.set('')
})
describe('tool row preview recording', () => {
// The row used to record under the global (primary-only) $activeSessionId, so
// a preview produced inside a session TILE surfaced in the main chat's
// composer instead of the tile's own.
it('records into the session whose transcript the row is in, not the primary', () => {
$activeSessionId.set(PRIMARY_ID)
$currentCwd.set('/primary/work')
const view = tileView()
renderToolRow(node => <SessionViewProvider value={view}>{node}</SessionViewProvider>)
const recorded = $previewStatusBySession.get()
expect(Object.keys(recorded)).toEqual([TILE_ID])
expect(recorded[TILE_ID]?.[0]?.cwd).toBe('/tile/work')
})
it('still records into the primary session for the main chat', () => {
$activeSessionId.set(PRIMARY_ID)
$currentCwd.set('/primary/work')
renderToolRow(node => node)
expect(Object.keys($previewStatusBySession.get())).toEqual([PRIMARY_ID])
})
})

View file

@ -16,6 +16,7 @@ import {
useState
} from 'react'
import { useSessionView } from '@/app/chat/session-view'
import { AnsiText } from '@/components/assistant-ui/ansi-text'
import { useElapsedSeconds } from '@/components/chat/activity-timer'
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
@ -39,7 +40,6 @@ import { normalize } from '@/lib/text'
import { useEnterAnimation } from '@/lib/use-enter-animation'
import { cn } from '@/lib/utils'
import { recordPreviewArtifact } from '@/store/preview-status'
import { $activeSessionId, $currentCwd } from '@/store/session'
import { $toolInlineDiff } from '@/store/tool-diffs'
import { $toolRowDismissed, dismissToolRow } from '@/store/tool-dismiss'
import { $toolDisclosureOpen, $toolViewMode, setToolDisclosureOpen } from '@/store/tool-view'
@ -364,9 +364,12 @@ function ToolEntry({ part }: ToolEntryProps) {
// Surface a previewable artifact (HTML file / localhost URL) as a compact link
// in the composer status stack rather than a bulky inline card. Uses the same
// detected target the old inline card did, keyed to the active session the
// stack reads from. Idempotent + dedup'd, so re-renders don't churn.
// detected target the old inline card did. Idempotent + dedup'd, so re-renders
// don't churn.
const previewTarget = view.previewTarget
// The session whose transcript this row is IN, which is not necessarily the
// primary one: a tool row inside a session tile must feed that tile's composer.
const { $cwd: $sessionCwd, $runtimeId: $sessionRuntimeId } = useSessionView()
useEffect(() => {
if (isPending || !previewTarget || !isPreviewableTarget(previewTarget)) {
@ -376,12 +379,12 @@ function ToolEntry({ part }: ToolEntryProps) {
// Read (don't subscribe) session/cwd: this only fires when a previewable
// target appears, and subscribing re-rendered every tool row on any session
// or cwd change.
const activeSessionId = $activeSessionId.get()
const sessionId = $sessionRuntimeId.get()
if (activeSessionId) {
recordPreviewArtifact(activeSessionId, previewTarget, $currentCwd.get() || '')
if (sessionId) {
recordPreviewArtifact(sessionId, previewTarget, $sessionCwd.get() || '')
}
}, [isPending, previewTarget])
}, [$sessionCwd, $sessionRuntimeId, isPending, previewTarget])
const detailSections = useMemo(() => {
if (!view.detail) {

View file

@ -1,33 +1,28 @@
import { useStore } from '@nanostores/react'
import { useEffect, useRef, useState } from 'react'
import { useSessionView } from '@/app/chat/session-view'
import { useI18n } from '@/i18n'
import { MonitorPlay } from '@/lib/icons'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import { previewName } from '@/lib/preview-targets'
import { notifyError } from '@/store/notifications'
import {
$previewTarget,
dismissPreviewTarget,
type PreviewRecordSource,
setCurrentSessionPreviewTarget
} from '@/store/preview'
import { $currentCwd } from '@/store/session'
import { $previewTabSources, closePreviewForSource, openPreview, type PreviewRecordSource } from '@/store/preview'
export function PreviewAttachment({ source = 'manual', target }: { source?: PreviewRecordSource; target: string }) {
const { t } = useI18n()
const cwd = useStore($currentCwd)
const activePreview = useStore($previewTarget)
// This link lives in one session's transcript; resolve it against THAT
// session's cwd, not the primary chat's.
const cwd = useStore(useSessionView().$cwd)
const openSources = useStore($previewTabSources)
const [opening, setOpening] = useState(false)
const activePreviewRef = useRef(activePreview)
const cwdRef = useRef(cwd)
const mountedRef = useRef(false)
const requestTokenRef = useRef(0)
const targetRef = useRef(target)
const name = previewName(target)
const isActive = activePreview?.source === target
const isActive = openSources.includes(target)
activePreviewRef.current = activePreview
cwdRef.current = cwd
targetRef.current = target
@ -53,7 +48,7 @@ export function PreviewAttachment({ source = 'manual', target }: { source?: Prev
}
if (isActive) {
dismissPreviewTarget()
closePreviewForSource(target)
return
}
@ -80,13 +75,7 @@ export function PreviewAttachment({ source = 'manual', target }: { source?: Prev
throw new Error(`Could not open preview target: ${requestTarget}`)
}
const currentPreview = activePreviewRef.current
if (currentPreview?.source === preview.source && currentPreview.url === preview.url) {
return
}
setCurrentSessionPreviewTarget(preview, source, requestTarget)
openPreview(preview, source)
} catch (error) {
if (
!mountedRef.current ||

View file

@ -127,6 +127,38 @@ describe('reactive pane unhide', () => {
expect(tree.$collapsedTreeSides.get().has('right')).toBe(false)
})
// Opening a preview used to drag the file tree open with it: the preview
// shares ⌘J's column, and `revealTreePane` un-collapses a column through its
// bound store — which for the right side IS the file-browser toggle. The
// reveal now un-collapses the column directly and leaves the toggle alone.
it('revealing a preview opens its column without flipping the file-tree toggle', async () => {
const { tree, layout } = await setupWithFiles()
const { registry } = await import('@/contrib/registry')
registry.register({
id: 'preview',
area: 'panes',
data: { placement: 'right' },
render: () => null,
title: 'preview'
})
layout.setFileBrowserOpen(false)
expect(tree.$collapsedTreeSides.get().has('right')).toBe(true)
// The revealPreview sequence from controller.tsx.
const side = tree.treeSideOfPane('files')
expect(side).toBe('right')
tree.setTreeSideCollapsed(side!, false)
tree.revealTreePane('preview')
// The column is showing…
expect(tree.$collapsedTreeSides.get().has('right')).toBe(false)
// …but the tree's own toggle never moved, so the tree stays closed.
expect(layout.$fileBrowserOpen.get()).toBe(false)
})
it('reactive unhide does not invoke the right side opener directly', async () => {
const { tree, layout } = await setupWithFiles()

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

@ -188,7 +188,13 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [
description: 'Resume a saved session',
aliases: ['/sessions', '/switch'],
surface: picker('session'),
argumentMode: 'options'
// `mixed`, not `options`: the argument is a free-text search the picker
// fuzzy-matches against titles and previews, so multi-word queries have to
// stay typeable. Its completion list also always carries a trailing
// "Browse all sessions…" action row, which meant Space-to-accept could
// never fall through — the first space wiped the composer and threw the
// user into the overlay.
argumentMode: 'mixed'
},
// Backend-executed commands that render useful inline output.

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

@ -1,18 +1,11 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { $compactingSessions, $compactionActive, setSessionCompacting } from './compaction'
import { $activeSessionId } from './session'
import { $compactingSessions, sessionCompacting, setSessionCompacting } from './compaction'
describe('compaction store', () => {
beforeEach(() => {
$compactingSessions.set({})
$activeSessionId.set(null)
})
beforeEach(() => $compactingSessions.set({}))
afterEach(() => {
$compactingSessions.set({})
$activeSessionId.set(null)
})
afterEach(() => $compactingSessions.set({}))
it('tracks compaction per session independently', () => {
setSessionCompacting('session-a', true)
@ -21,16 +14,11 @@ describe('compaction store', () => {
expect($compactingSessions.get()).toEqual({ 'session-a': true, 'session-b': true })
})
it('exposes only the active session via the focus-scoped view', () => {
it('scopes the view to the session asked for, not whichever is active', () => {
setSessionCompacting('session-a', true)
expect($compactionActive.get()).toBe(false)
$activeSessionId.set('session-a')
expect($compactionActive.get()).toBe(true)
$activeSessionId.set('session-b')
expect($compactionActive.get()).toBe(false)
expect(sessionCompacting('session-a').get()).toBe(true)
expect(sessionCompacting('session-b').get()).toBe(false)
})
it('clears a session without disturbing the others', () => {

View file

@ -1,7 +1,5 @@
import { atom, computed } from 'nanostores'
import { $activeSessionId } from './session'
// Per-session flag while auto-compaction runs mid-turn. Without it the
// transcript looks like it reset; per-session so a background chat can't
// clobber the foreground view.
@ -9,10 +7,11 @@ const keyFor = (sessionId: string | null | undefined): string => sessionId ?? ''
export const $compactingSessions = atom<Record<string, true>>({})
export const $compactionActive = computed(
[$compactingSessions, $activeSessionId],
(sessions, activeId) => keyFor(activeId) in sessions
)
/** Is `sessionId` compacting? Per-session because a transcript may be a tile,
* and a tile must never wear the primary chat's compaction state. */
export function sessionCompacting(sessionId: null | string) {
return computed($compactingSessions, sessions => keyFor(sessionId) in sessions)
}
export function setSessionCompacting(sessionId: string | null | undefined, active: boolean): void {
const key = keyFor(sessionId)

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