Merge pull request #51103 from NousResearch/bb/desktop-tool-preview-cleanup

fix(desktop): manual tool previews via status stack
This commit is contained in:
brooklyn! 2026-06-22 19:29:29 -05:00 committed by GitHub
commit 2a10b8384a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 390 additions and 156 deletions

View file

@ -944,6 +944,33 @@ function openExternalUrl(rawUrl) {
return true
}
async function openPreviewInBrowser(rawUrl) {
const raw = String(rawUrl || '').trim()
if (!raw) return false
let parsed
try {
parsed = new URL(raw)
} catch {
return false
}
if (parsed.protocol === 'file:') {
let localPath
try {
localPath = resolveRequestedPathForIpc(parsed.toString(), { purpose: 'Open preview in browser' })
} catch {
return false
}
await shell.openExternal(pathToFileURL(localPath).toString())
return true
}
return openExternalUrl(raw)
}
function ensureWslWindowsFonts() {
if (!IS_WSL) return
@ -5998,6 +6025,12 @@ ipcMain.handle('hermes:openExternal', (_event, url) => {
}
})
ipcMain.handle('hermes:openPreviewInBrowser', async (_event, url) => {
if (!(await openPreviewInBrowser(url))) {
throw new Error('Invalid preview URL')
}
})
// User-configurable default project directory. The renderer reads this on
// settings mount and seeds the value into the picker; writing back persists
// it via writeDefaultProjectDir so resolveHermesCwd picks it up on the next

View file

@ -44,6 +44,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
setTranslucency: payload => ipcRenderer.send('hermes:translucency', payload),
setPreviewShortcutActive: active => ipcRenderer.send('hermes:previewShortcutActive', Boolean(active)),
openExternal: url => ipcRenderer.invoke('hermes:openExternal', url),
openPreviewInBrowser: url => ipcRenderer.invoke('hermes:openPreviewInBrowser', url),
fetchLinkTitle: url => ipcRenderer.invoke('hermes:fetchLinkTitle', url),
sanitizeWorkspaceCwd: cwd => ipcRenderer.invoke('hermes:workspace:sanitize', cwd),
settings: {

View file

@ -60,6 +60,7 @@ import {
updateQueuedPrompt
} from '@/store/composer-queue'
import { $statusItemsBySession } from '@/store/composer-status'
import { $previewStatusBySession } from '@/store/preview-status'
import { notify } from '@/store/notifications'
import { $gatewayState, $messages, setSessionPickerOpen } from '@/store/session'
import { $threadScrolledUp } from '@/store/thread-scroll'
@ -195,6 +196,7 @@ export function ChatBar({
const attachments = useStore($composerAttachments)
const queuedPromptsBySession = useStore($queuedPromptsBySession)
const statusItemsBySession = useStore($statusItemsBySession)
const previewStatusBySession = useStore($previewStatusBySession)
const scrolledUp = useStore($threadScrolledUp)
// Pop-out is a shared, persisted state — but secondary windows (the Ctrl+Shift+N
// tiny window, subagent watch windows) always start docked and can't pop out:
@ -217,8 +219,12 @@ export function ChatBar({
const statusStackVisible = useMemo(
() =>
queuedPrompts.length > 0 || (statusSessionId ? (statusItemsBySession[statusSessionId]?.length ?? 0) > 0 : false),
[queuedPrompts.length, statusItemsBySession, statusSessionId]
queuedPrompts.length > 0 ||
(statusSessionId
? (statusItemsBySession[statusSessionId]?.length ?? 0) > 0 ||
(previewStatusBySession[statusSessionId]?.length ?? 0) > 0
: false),
[previewStatusBySession, queuedPrompts.length, statusItemsBySession, statusSessionId]
)
const composerRef = useRef<HTMLFormElement | null>(null)

View file

@ -19,9 +19,11 @@ import {
type StatusGroup,
stopBackgroundProcess
} from '@/store/composer-status'
import { $previewStatusBySession, dismissPreviewArtifact } from '@/store/preview-status'
import { $threadScrolledUp } from '@/store/thread-scroll'
import { openSessionInNewWindow } from '@/store/windows'
import { PreviewStatusRow } from './preview-row'
import { StatusItemRow } from './status-row'
// Slow safety-net poll for silent exits (processes without notify_on_complete
@ -52,6 +54,7 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
const { t } = useI18n()
const navigate = useNavigate()
const itemsBySession = useStore($statusItemsBySession)
const previewsBySession = useStore($previewStatusBySession)
const scrolledUp = useStore($threadScrolledUp)
const groups = useMemo(
@ -59,6 +62,8 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
[itemsBySession, sessionId]
)
const previews = sessionId ? (previewsBySession[sessionId] ?? []) : []
// Seed from the registry on session open; event-driven refreshes (terminal /
// process tool completions) live in use-message-stream.
useEffect(() => {
@ -122,6 +127,21 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
)
}))
if (previews.length > 0 && sessionId) {
sections.push({
key: 'preview',
// Not a collapsible group — preview links just sit there, one line each,
// each individually closeable.
node: (
<div className="px-1 py-0.5">
{previews.map(item => (
<PreviewStatusRow item={item} key={item.id} onDismiss={id => dismissPreviewArtifact(sessionId, id)} />
))}
</div>
)
})
}
if (queue) {
sections.push({ key: 'queue', node: queue })
}

View file

@ -0,0 +1,125 @@
import { useStore } from '@nanostores/react'
import { memo, useState } from 'react'
import { StatusRow } from '@/components/chat/status-row'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { ChevronRight, X } from '@/lib/icons'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
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 { type PreviewArtifact } from '@/store/preview-status'
interface PreviewStatusRowProps {
item: PreviewArtifact
onDismiss: (id: string) => void
}
/** 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 previewPaneOpen = useStore($paneOpen(PREVIEW_PANE_ID))
const [opening, setOpening] = useState(false)
const isOpen = activePreview?.source === item.target && previewPaneOpen
const resolveTarget = async () => {
const target = await normalizeOrLocalPreviewTarget(item.target, item.cwd || undefined)
if (!target) {
throw new Error(`Could not open preview target: ${item.target}`)
}
return target
}
const togglePreview = async () => {
if (opening) {
return
}
if (isOpen) {
dismissPreviewTarget()
return
}
setOpening(true)
try {
setCurrentSessionPreviewTarget(await resolveTarget(), 'tool-result', item.target)
} catch (error) {
notifyError(error, t.preview.unavailable)
} finally {
setOpening(false)
}
}
const openInBrowser = async () => {
try {
const bridge = window.hermesDesktop?.openPreviewInBrowser
if (!bridge) {
throw new Error('Desktop preview browser bridge is unavailable')
}
await bridge((await resolveTarget()).url)
} catch (error) {
notifyError(error, t.preview.unavailable)
}
}
return (
<StatusRow
leading={<ChevronRight aria-hidden className="size-3 text-muted-foreground/80" />}
onActivate={() => void togglePreview()}
trailing={
<span className="-my-1 flex items-center gap-0.5">
<Tip label={t.preview.openInBrowser}>
<Button
aria-label={t.preview.openInBrowser}
className="size-4 rounded-md text-muted-foreground/60 hover:text-foreground/90"
onClick={event => {
event.stopPropagation()
void openInBrowser()
}}
size="icon-xs"
type="button"
variant="ghost"
>
<Codicon name="link-external" size="0.75rem" />
</Button>
</Tip>
<Tip label={t.statusStack.dismiss}>
<Button
aria-label={t.statusStack.dismiss}
className="size-4 rounded-md text-muted-foreground/60 hover:text-foreground/90"
onClick={event => {
event.stopPropagation()
onDismiss(item.id)
}}
size="icon-xs"
type="button"
variant="ghost"
>
<X size={12} />
</Button>
</Tip>
</span>
}
trailingVisible
>
<span className="min-w-0 max-w-[18rem] truncate text-[0.73rem] leading-4 text-foreground/92" title={item.target}>
{item.label}
</span>
<span className={cn('shrink-0 text-[0.62rem] leading-4 text-muted-foreground/70', opening && 'animate-pulse')}>
{opening ? t.preview.opening : isOpen ? t.preview.hide : t.preview.openPreview}
</span>
</StatusRow>
)
})

View file

@ -33,6 +33,7 @@ import {
FILE_BROWSER_MAX_WIDTH,
FILE_BROWSER_MIN_WIDTH,
pinSession,
PREVIEW_PANE_ID,
setSidebarOverlayMounted,
SIDEBAR_DEFAULT_WIDTH,
SIDEBAR_MAX_WIDTH,
@ -1077,7 +1078,7 @@ export function DesktopController() {
const previewPane = (
<Pane
disabled={!chatOpen || (!previewTarget && !filePreviewTarget)}
id="preview"
id={PREVIEW_PANE_ID}
key="preview"
maxWidth={PREVIEW_RAIL_MAX_WIDTH}
minWidth={PREVIEW_RAIL_MIN_WIDTH}

View file

@ -120,31 +120,7 @@ describe('usePreviewRouting', () => {
expect(window.hermesDesktop.normalizePreviewTarget).not.toHaveBeenCalled()
})
it('registers structured tool-result preview targets', async () => {
render(
<PreviewRoutingHarness
onEvent={handler => {
handleEvent = handler
}}
/>
)
act(() =>
handleEvent({
payload: { path: './dist/index.html' },
session_id: 'session-1',
type: 'tool.complete'
})
)
await waitFor(() => {
expect($previewTarget.get()?.source).toBe('./dist/index.html')
})
expect(window.localStorage.getItem('hermes.desktop.sessionPreviews.v1')).toContain('./dist/index.html')
})
it('registers html previews from edit inline diffs', async () => {
it('does not auto-open a preview from tool results', async () => {
render(
<PreviewRoutingHarness
onEvent={handler => {
@ -160,9 +136,9 @@ describe('usePreviewRouting', () => {
type: 'tool.complete'
})
)
act(() => handleEvent({ payload: { path: './dist/index.html' }, session_id: 'session-1', type: 'tool.complete' }))
await waitFor(() => {
expect($previewTarget.get()?.source).toBe('preview-demo.html')
})
expect($previewTarget.get()).toBeNull()
expect(window.localStorage.getItem('hermes.desktop.sessionPreviews.v1')).toBeNull()
})
})

View file

@ -10,8 +10,7 @@ import {
getSessionPreviewRecord,
progressPreviewServerRestart,
requestPreviewReload,
setPreviewTarget,
setSessionPreviewTarget
setPreviewTarget
} from '@/store/preview'
import { $currentCwd } from '@/store/session'
import type { RpcEvent } from '@/types/hermes'
@ -40,53 +39,6 @@ function activePreviewSessionId(
return selectedStoredSessionId || routedSessionId || activeSessionIdRef.current || ''
}
function looksLikePreviewTarget(value: string): boolean {
return /^https?:\/\//i.test(value) || /^file:\/\//i.test(value) || /^(?:\/|\.{1,2}\/|~\/).+/.test(value)
}
function stripAnsi(value: string): string {
return value.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'), '')
}
function htmlPathFromInlineDiff(value: string): string {
const cleaned = stripAnsi(value).replace(/^\s*┊\s*review diff\s*\n/i, '')
for (const match of cleaned.matchAll(/(?:^|\s)(?:[ab]\/)?([^\s]+\.html?)(?=\s|$)/gi)) {
const candidate = match[1]?.trim()
if (candidate) {
return candidate
}
}
return ''
}
function structuredPreviewCandidate(payload: unknown): string {
const record = asRecord(payload)
const fields = ['url', 'target', 'path', 'file', 'filepath', 'preview']
for (const field of fields) {
const value = record[field]
if (typeof value === 'string') {
const target = value.trim()
if (target && looksLikePreviewTarget(target)) {
return target
}
}
}
const inlineDiff = record.inline_diff
if (typeof inlineDiff === 'string') {
return htmlPathFromInlineDiff(inlineDiff)
}
return ''
}
export function usePreviewRouting({
activeSessionIdRef,
baseHandleGatewayEvent,
@ -99,6 +51,10 @@ export function usePreviewRouting({
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)
@ -111,53 +67,6 @@ export function usePreviewRouting({
setPreviewTarget(record?.normalized ?? null)
}, [currentView, previewRegistry, previewSessionId])
const registerStructuredPreview = useCallback(
async (event: RpcEvent) => {
if (
event.session_id &&
event.session_id !== activeSessionIdRef.current &&
event.session_id !== previewSessionId
) {
return
}
if (!event.type.startsWith('tool.')) {
return
}
if (!previewSessionId) {
return
}
const candidate = structuredPreviewCandidate(event.payload)
if (!candidate) {
return
}
const desktop = window.hermesDesktop
if (!desktop?.normalizePreviewTarget) {
return
}
const sessionId = previewSessionId
const cwd = currentCwd || ''
const target = await desktop.normalizePreviewTarget(candidate, cwd || undefined).catch(() => null)
if (
!target ||
sessionId !== activePreviewSessionId(activeSessionIdRef, routedSessionId, selectedStoredSessionId) ||
$currentCwd.get() !== cwd
) {
return
}
setSessionPreviewTarget(sessionId, target, 'tool-result', candidate)
},
[activeSessionIdRef, currentCwd, previewSessionId, routedSessionId, selectedStoredSessionId]
)
const restartPreviewServer = useCallback(
async (url: string, context?: string) => {
const sessionId = activeSessionIdRef.current
@ -210,13 +119,14 @@ export function usePreviewRouting({
return
}
void registerStructuredPreview(event)
// 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)) {
requestPreviewReload()
}
},
[activeSessionIdRef, baseHandleGatewayEvent, registerStructuredPreview]
[activeSessionIdRef, baseHandleGatewayEvent]
)
return { handleDesktopGatewayEvent, restartPreviewServer }

View file

@ -37,6 +37,7 @@ import {
updateComposerAttachment
} from '@/store/composer'
import { resetSessionBackground } from '@/store/composer-status'
import { clearPreviewArtifacts } from '@/store/preview-status'
import { clearNotifications, notify, notifyError } from '@/store/notifications'
import { requestDesktopOnboarding } from '@/store/onboarding'
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
@ -1643,6 +1644,7 @@ export function usePromptActions({
// rows (and kill the live processes) before the fresh run repopulates.
clearSessionTodos(sessionId)
resetSessionBackground(sessionId)
clearPreviewArtifacts(sessionId)
clearNotifications()
setMutableRef(busyRef, true)
@ -1705,6 +1707,7 @@ export function usePromptActions({
// processes) before the re-run repopulates them.
clearSessionTodos(sessionId)
resetSessionBackground(sessionId)
clearPreviewArtifacts(sessionId)
clearNotifications()
setMutableRef(busyRef, true)

View file

@ -2,7 +2,7 @@
import { type ToolCallMessagePartProps, useAuiState } from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import { createContext, type FC, type PropsWithChildren, type ReactNode, useContext, useMemo } from 'react'
import { createContext, type FC, type PropsWithChildren, type ReactNode, useContext, useEffect, useMemo } from 'react'
import { AnsiText } from '@/components/assistant-ui/ansi-text'
import { useElapsedSeconds } from '@/components/chat/activity-timer'
@ -10,7 +10,6 @@ import { ActivityTimerText } from '@/components/chat/activity-timer-text'
import { CompactMarkdown } from '@/components/chat/compact-markdown'
import { FileDiffPanel } from '@/components/chat/diff-lines'
import { DisclosureRow } from '@/components/chat/disclosure-row'
import { PreviewAttachment } from '@/components/chat/preview-attachment'
import { ZoomableImage } from '@/components/chat/zoomable-image'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
@ -25,6 +24,8 @@ import { PrettyLink, LinkifiedText as SharedLinkifiedText, urlSlugTitleLabel } f
import { AlertCircle, CheckCircle2 } from '@/lib/icons'
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 { $toolInlineDiffs } from '@/store/tool-diffs'
import { $toolRowDismissed, dismissToolRow } from '@/store/tool-dismiss'
import { $toolDisclosureOpen, $toolViewMode, setToolDisclosureOpen } from '@/store/tool-view'
@ -76,6 +77,8 @@ const TOOL_SECTION_LABEL_CLASS = 'mb-1 text-[0.65rem] font-medium uppercase trac
const TOOL_SECTION_SURFACE_CLASS =
'max-h-20 max-w-full overflow-auto bg-transparent px-2 py-1.5 text-(--ui-text-secondary)'
const TOOL_EXPANDED_SHELL_CLASS = 'rounded-[0.3125rem] border border-(--ui-stroke-tertiary)'
const TOOL_SECTION_PRE_CLASS = cn(TOOL_SECTION_SURFACE_CLASS, 'font-mono text-[0.7rem] leading-relaxed')
interface ToolStatusCopy {
@ -242,6 +245,22 @@ function ToolEntry({ part }: ToolEntryProps) {
return buildToolView(p, inlineDiff)
}, [inlineDiff, isPending, part])
// 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.
const activeSessionId = useStore($activeSessionId)
const currentCwd = useStore($currentCwd)
const previewTarget = view.previewTarget
useEffect(() => {
if (isPending || !activeSessionId || !previewTarget || !isPreviewableTarget(previewTarget)) {
return
}
recordPreviewArtifact(activeSessionId, previewTarget, currentCwd || '')
}, [activeSessionId, currentCwd, isPending, previewTarget])
const detailSections = useMemo(() => {
if (!view.detail) {
return { body: '', summary: '' }
@ -291,12 +310,7 @@ function ToolEntry({ part }: ToolEntryProps) {
Boolean(view.rawResult.trim())
const hasExpandableContent = Boolean(
(view.previewTarget && isPreviewableTarget(view.previewTarget)) ||
view.imageUrl ||
view.inlineDiff ||
showDetail ||
hasSearchHits ||
toolViewMode === 'technical'
view.imageUrl || view.inlineDiff || showDetail || hasSearchHits || toolViewMode === 'technical'
)
const copyAction = useMemo(() => toolCopyPayload(part, view), [part, view])
@ -360,7 +374,7 @@ function ToolEntry({ part }: ToolEntryProps) {
<div
className={cn(
'min-w-0 max-w-full overflow-hidden text-[length:var(--conversation-tool-font-size)] text-(--ui-text-tertiary)',
open && 'rounded-[0.625rem] border border-(--ui-stroke-tertiary)'
open && TOOL_EXPANDED_SHELL_CLASS
)}
data-file-edit={isFileEdit && open ? '' : undefined}
data-slot="tool-block"
@ -425,9 +439,6 @@ function ToolEntry({ part }: ToolEntryProps) {
text={copyAction.text}
/>
)}
{!embedded && view.previewTarget && isPreviewableTarget(view.previewTarget) && (
<PreviewAttachment source="tool-result" target={view.previewTarget} />
)}
{view.imageUrl && (
<div className="max-w-72 overflow-hidden rounded-[0.25rem] border border-(--ui-stroke-tertiary)">
<ZoomableImage alt={copy.outputAlt} className="h-auto w-full object-cover" src={view.imageUrl} />

View file

@ -104,16 +104,15 @@ export function PreviewAttachment({ source = 'manual', target }: { source?: Prev
}
return (
<div className="flex w-full max-w-160 flex-wrap items-center gap-2.5 rounded-lg border border-border/55 bg-card/55 px-2.5 py-1.5 text-sm">
<span className="grid size-7 shrink-0 place-items-center rounded-md bg-muted/55 text-muted-foreground/85">
<div className="flex w-full max-w-160 items-center gap-2 rounded-lg border border-border/55 bg-card/55 px-2.5 py-1.5 text-sm">
<span className="grid size-6 shrink-0 place-items-center rounded-md bg-muted/55 text-muted-foreground/85">
<MonitorPlay className="size-3.5" />
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-[0.78rem] font-medium leading-[1.15rem] text-foreground/90">{name}</div>
<div className="truncate font-mono text-[0.66rem] leading-4 text-muted-foreground/70">{target}</div>
</div>
<span className="min-w-0 flex-1 truncate text-[0.78rem] font-medium text-foreground/90" title={target}>
{name}
</span>
<button
className="ml-auto shrink-0 rounded-md border border-border/55 bg-background/40 px-2 py-1 text-[0.7rem] font-medium text-muted-foreground transition-colors hover:bg-accent/55 hover:text-foreground disabled:opacity-50 max-[28rem]:ml-9 max-[28rem]:w-[calc(100%-2.25rem)]"
className="shrink-0 rounded-md border border-border/55 bg-background/40 px-2 py-1 text-[0.7rem] font-medium text-muted-foreground transition-colors hover:bg-accent/55 hover:text-foreground disabled:opacity-50"
disabled={opening}
onClick={() => void togglePreview()}
type="button"

View file

@ -60,6 +60,7 @@ declare global {
setTranslucency?: (payload: { intensity: number }) => void
setPreviewShortcutActive?: (active: boolean) => void
openExternal: (url: string) => Promise<void>
openPreviewInBrowser?: (url: string) => Promise<void>
fetchLinkTitle: (url: string) => Promise<string>
sanitizeWorkspaceCwd: (cwd?: null | string) => Promise<{ cwd: string; sanitized: boolean }>
settings: {

View file

@ -1671,6 +1671,7 @@ export const en: Translations = {
opening: 'Opening...',
hide: 'Hide',
openPreview: 'Open preview',
openInBrowser: 'Open in browser',
sourceLineTitle: 'Click to select · shift-click to extend · drag to composer',
source: 'SOURCE',
renderedPreview: 'PREVIEW',

View file

@ -1800,6 +1800,7 @@ export const ja = defineLocale({
opening: '開いています...',
hide: '非表示',
openPreview: 'プレビューを開く',
openInBrowser: 'ブラウザで開く',
sourceLineTitle: 'クリックして選択 · Shift クリックで拡張 · コンポーザーにドラッグ',
source: 'ソース',
renderedPreview: 'プレビュー',

View file

@ -1308,6 +1308,7 @@ export interface Translations {
opening: string
hide: string
openPreview: string
openInBrowser: string
sourceLineTitle: string
source: string
renderedPreview: string

View file

@ -1743,6 +1743,7 @@ export const zhHant = defineLocale({
opening: '開啟中...',
hide: '隱藏',
openPreview: '開啟預覽',
openInBrowser: '在瀏覽器中開啟',
sourceLineTitle: '點擊選取 · shift 點擊擴展 · 拖曳至輸入框',
source: '原始碼',
renderedPreview: '預覽',

View file

@ -1848,6 +1848,7 @@ export const zh: Translations = {
opening: '正在打开...',
hide: '隐藏',
openPreview: '打开预览',
openInBrowser: '在浏览器中打开',
sourceLineTitle: '点击选择 · shift 点击扩展 · 拖到输入框',
source: '源码',
renderedPreview: '预览',

View file

@ -32,12 +32,14 @@ const PANES_FLIPPED_STORAGE_KEY = 'hermes.desktop.panesFlipped'
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 | `file:${string}`
ensurePaneRegistered(CHAT_SIDEBAR_PANE_ID, { open: true })
ensurePaneRegistered(FILE_BROWSER_PANE_ID, { open: false })
ensurePaneRegistered(PREVIEW_PANE_ID, { open: true })
export const $sidebarOpen: ReadableAtom<boolean> = computed(
$paneStates,

View file

@ -0,0 +1,41 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
$previewStatusBySession,
clearPreviewArtifacts,
dismissPreviewArtifact,
recordPreviewArtifact
} from './preview-status'
beforeEach(() => $previewStatusBySession.set({}))
describe('recordPreviewArtifact', () => {
it('appends new targets newest-last and is idempotent', () => {
recordPreviewArtifact('s1', '/a/index.html', '/work')
recordPreviewArtifact('s1', '/a/about.html', '/work')
recordPreviewArtifact('s1', '/a/index.html', '/work')
expect($previewStatusBySession.get().s1.map(i => i.id)).toEqual(['/a/index.html', '/a/about.html'])
})
it('caps the list and derives a label', () => {
for (const n of [1, 2, 3, 4, 5]) {
recordPreviewArtifact('s1', `/a/p${n}.html`, '/work')
}
const list = $previewStatusBySession.get().s1
expect(list).toHaveLength(4)
expect(list[0].id).toBe('/a/p2.html')
expect(list[3].label).toBe('p5.html')
})
it('dismiss and clear remove rows', () => {
recordPreviewArtifact('s1', '/a/index.html', '/work')
recordPreviewArtifact('s1', '/a/about.html', '/work')
dismissPreviewArtifact('s1', '/a/index.html')
expect($previewStatusBySession.get().s1.map(i => i.id)).toEqual(['/a/about.html'])
clearPreviewArtifacts('s1')
expect($previewStatusBySession.get().s1).toBeUndefined()
})
})

View file

@ -0,0 +1,79 @@
import { atom } from 'nanostores'
import { previewName } from '@/lib/preview-targets'
/**
* Session-scoped feed of previewable artifacts (HTML files, localhost dev URLs)
* a tool produced. Surfaced as compact links in the composer status stack
* NOT auto-opened and NOT a bulky inline card. Click opens the rail preview or
* the browser; both are manual.
*
* Fed from the tool row itself (see tool-fallback.tsx) using the same detected
* target the inline card used, so detection parity is exact.
*/
export interface PreviewArtifact {
/** cwd captured at detection so a relative path still resolves on click. */
cwd: string
/** Dedupe key + display id (the raw target). */
id: string
label: string
target: string
}
const MAX_PER_SESSION = 4
export const $previewStatusBySession = atom<Record<string, PreviewArtifact[]>>({})
const writePreviews = (sid: string, items: PreviewArtifact[]) => {
const current = $previewStatusBySession.get()
if (items.length === 0) {
if (!current[sid]) {
return
}
const next = { ...current }
delete next[sid]
$previewStatusBySession.set(next)
return
}
$previewStatusBySession.set({ ...current, [sid]: items })
}
/**
* Record a detected artifact, newest last, capped. Idempotent: a target already
* in the list keeps its slot (the tool row re-registers on every render, so this
* must not churn the atom or reorder rows).
*/
export function recordPreviewArtifact(sid: string, target: string, cwd: string) {
const raw = target.trim()
if (!sid || !raw) {
return
}
const list = $previewStatusBySession.get()[sid] ?? []
if (list.some(item => item.id === raw)) {
return
}
writePreviews(sid, [...list, { cwd, id: raw, label: previewName(raw), target: raw }].slice(-MAX_PER_SESSION))
}
export function dismissPreviewArtifact(sid: string, id: string) {
const list = $previewStatusBySession.get()[sid]
if (list) {
writePreviews(
sid,
list.filter(item => item.id !== id)
)
}
}
export function clearPreviewArtifacts(sid: string) {
writePreviews(sid, [])
}

View file

@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { $rightRailActiveTabId, RIGHT_RAIL_PREVIEW_TAB_ID } from './layout'
import { $rightRailActiveTabId, PREVIEW_PANE_ID, RIGHT_RAIL_PREVIEW_TAB_ID } from './layout'
import { $paneOpen } from './panes'
import {
$filePreviewTabs,
$filePreviewTarget,
@ -69,12 +70,14 @@ describe('preview store', () => {
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')
dismissPreviewTarget()
expect($previewTarget.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))

View file

@ -1,6 +1,13 @@
import { atom, computed } from 'nanostores'
import { $rightRailActiveTabId, RIGHT_RAIL_PREVIEW_TAB_ID, type RightRailTabId, selectRightRailTab } from './layout'
import {
$rightRailActiveTabId,
PREVIEW_PANE_ID,
RIGHT_RAIL_PREVIEW_TAB_ID,
type RightRailTabId,
selectRightRailTab
} from './layout'
import { setPaneOpen } from './panes'
import { $activeSessionId, $selectedStoredSessionId } from './session'
export interface PreviewTarget {
@ -88,10 +95,15 @@ function isSamePreviewTarget(a: PreviewTarget | null, b: PreviewTarget | null):
)
}
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) {
selectRightRailTab(RIGHT_RAIL_PREVIEW_TAB_ID)
showLivePreviewTab()
}
return
@ -100,7 +112,7 @@ export function setPreviewTarget(target: PreviewTarget | null) {
$previewTarget.set(target)
if (target) {
selectRightRailTab(RIGHT_RAIL_PREVIEW_TAB_ID)
showLivePreviewTab()
}
}
@ -115,6 +127,7 @@ function openFilePreviewTarget(target: PreviewTarget) {
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)
}
@ -372,6 +385,8 @@ export function dismissPreviewTarget() {
if ($rightRailActiveTabId.get() === RIGHT_RAIL_PREVIEW_TAB_ID) {
selectRightRailTab($filePreviewTabs.get()[0]?.id ?? RIGHT_RAIL_PREVIEW_TAB_ID)
}
setPaneOpen(PREVIEW_PANE_ID, $filePreviewTabs.get().length > 0)
}
function closeFilePreviewTab(tabId: RightRailTabId) {
@ -393,6 +408,10 @@ function closeFilePreviewTab(tabId: RightRailTabId) {
if ($rightRailActiveTabId.get() === tabId) {
selectRightRailTab(next[Math.min(index, next.length - 1)]?.id ?? RIGHT_RAIL_PREVIEW_TAB_ID)
}
if (next.length === 0 && !$previewTarget.get()) {
setPaneOpen(PREVIEW_PANE_ID, false)
}
}
export function closeRightRailTab(tabId: RightRailTabId) {
@ -416,12 +435,14 @@ export function closeRightRail() {
}
$filePreviewTabs.set([])
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)
}

View file

@ -264,7 +264,6 @@
);
--ui-chat-bubble-opaque-background: var(--ui-bg-editor);
--ui-inline-code-background: color-mix(in srgb, #141414 5%, transparent);
--ui-inline-code-border: color-mix(in srgb, #141414 8%, transparent);
--ui-inline-code-foreground: color-mix(in srgb, #141414 88%, transparent);
--ui-selection-background: color-mix(in srgb, #ffd24a 55%, transparent);
@ -408,7 +407,6 @@
--backdrop-invert-mul: 0;
--ui-inline-code-background: color-mix(in srgb, #ffffff 7%, transparent);
--ui-inline-code-border: color-mix(in srgb, #ffffff 10%, transparent);
--ui-inline-code-foreground: color-mix(in srgb, #ffffff 88%, transparent);
--ui-selection-background: color-mix(in srgb, #ffd24a 38%, transparent);
}
@ -1180,7 +1178,6 @@ canvas {
}
[data-slot='aui_assistant-message-content'] .aui-md :not(pre) > code {
border: 0.0625rem solid var(--ui-inline-code-border);
background: var(--ui-inline-code-background);
color: var(--ui-inline-code-foreground);
}