mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-30 19:09:28 +00:00
Merge upstream main into feat/hermes-relay-shared-metrics
This commit is contained in:
commit
c07f2e023a
51 changed files with 1405 additions and 1678 deletions
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, [])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
112
apps/desktop/src/app/chat/right-rail/preview-artifact.test.tsx
Normal file
112
apps/desktop/src/app/chat/right-rail/preview-artifact.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
263
apps/desktop/src/app/chat/right-rail/preview-artifact.tsx
Normal file
263
apps/desktop/src/app/chat/right-rail/preview-artifact.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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() || ''
|
||||
|
|
|
|||
177
apps/desktop/src/app/session/hooks/preview-open.test.tsx
Normal file
177
apps/desktop/src/app/session/hooks/preview-open.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
})
|
||||
})
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 ||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -1503,10 +1503,7 @@ export const ar = defineLocale({
|
|||
open: 'فتح'
|
||||
},
|
||||
|
||||
artifactPane: {
|
||||
tabFallback: 'ناتج',
|
||||
modePreview: 'معاينة',
|
||||
modeSource: 'المصدر',
|
||||
artifactPreview: {
|
||||
versionOf: (current, total) => `الإصدار ${current} من ${total}`,
|
||||
olderVersion: 'إصدار أقدم',
|
||||
newerVersion: 'إصدار أحدث',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -1628,10 +1628,7 @@ export const ja = defineLocale({
|
|||
open: '開く'
|
||||
},
|
||||
|
||||
artifactPane: {
|
||||
tabFallback: 'アーティファクト',
|
||||
modePreview: 'プレビュー',
|
||||
modeSource: 'ソース',
|
||||
artifactPreview: {
|
||||
versionOf: (current, total) => `${total} 中 v${current}`,
|
||||
olderVersion: '前のバージョン',
|
||||
newerVersion: '次のバージョン',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1576,10 +1576,7 @@ export const zhHant = defineLocale({
|
|||
open: '開啟'
|
||||
},
|
||||
|
||||
artifactPane: {
|
||||
tabFallback: '產物',
|
||||
modePreview: '預覽',
|
||||
modeSource: '原始碼',
|
||||
artifactPreview: {
|
||||
versionOf: (current, total) => `第 ${current}/${total} 版`,
|
||||
olderVersion: '較舊版本',
|
||||
newerVersion: '較新版本',
|
||||
|
|
|
|||
|
|
@ -1954,10 +1954,7 @@ export const zh: Translations = {
|
|||
open: '打开'
|
||||
},
|
||||
|
||||
artifactPane: {
|
||||
tabFallback: '产物',
|
||||
modePreview: '预览',
|
||||
modeSource: '源码',
|
||||
artifactPreview: {
|
||||
versionOf: (current, total) => `第 ${current}/${total} 版`,
|
||||
olderVersion: '较旧版本',
|
||||
newerVersion: '较新版本',
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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([])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue