feat: file tabs

This commit is contained in:
Brooklyn Nicholson 2026-05-05 13:17:40 -05:00
parent 5ec0667fb3
commit 5269012c51
27 changed files with 763 additions and 133 deletions

View file

@ -32,6 +32,8 @@ const BUNDLED_HERMES_ROOT = path.join(process.resourcesPath, 'hermes-agent')
const BUNDLED_VENV_ROOT = path.join(app.getPath('userData'), 'hermes-runtime')
const BUNDLED_VENV_MARKER = path.join(BUNDLED_VENV_ROOT, '.hermes-desktop-runtime.json')
const DESKTOP_LOG_PATH = path.join(app.getPath('userData'), 'desktop.log')
const DESKTOP_LOG_FLUSH_MS = 120
const DESKTOP_LOG_BUFFER_MAX_CHARS = 64 * 1024
const RUNTIME_SCHEMA_VERSION = 3
const BUNDLED_RUNTIME_REQUIREMENTS = [
'openai>=2.21.0,<3',
@ -186,6 +188,47 @@ let connectionPromise = null
const hermesLog = []
const previewWatchers = new Map()
let previewShortcutActive = false
let desktopLogBuffer = ''
let desktopLogFlushTimer = null
let desktopLogFlushPromise = Promise.resolve()
function flushDesktopLogBufferSync() {
if (!desktopLogBuffer) return
const chunk = desktopLogBuffer
desktopLogBuffer = ''
try {
fs.mkdirSync(path.dirname(DESKTOP_LOG_PATH), { recursive: true })
fs.appendFileSync(DESKTOP_LOG_PATH, chunk)
} catch {
// Logging must never block app startup/shutdown.
}
}
function flushDesktopLogBufferAsync() {
if (!desktopLogBuffer) return desktopLogFlushPromise
const chunk = desktopLogBuffer
desktopLogBuffer = ''
desktopLogFlushPromise = desktopLogFlushPromise
.then(async () => {
await fs.promises.mkdir(path.dirname(DESKTOP_LOG_PATH), { recursive: true })
await fs.promises.appendFile(DESKTOP_LOG_PATH, chunk)
})
.catch(() => {
// Logging must never crash the desktop shell.
})
return desktopLogFlushPromise
}
function scheduleDesktopLogFlush() {
if (desktopLogFlushTimer) return
desktopLogFlushTimer = setTimeout(() => {
desktopLogFlushTimer = null
void flushDesktopLogBufferAsync()
}, DESKTOP_LOG_FLUSH_MS)
}
function rememberLog(chunk) {
const text = String(chunk || '').trim()
@ -196,12 +239,19 @@ function rememberLog(chunk) {
hermesLog.splice(0, hermesLog.length - 300)
}
try {
fs.mkdirSync(path.dirname(DESKTOP_LOG_PATH), { recursive: true })
fs.appendFileSync(DESKTOP_LOG_PATH, `${lines.join('\n')}\n`)
} catch {
// Logging must never block app startup.
desktopLogBuffer += `${lines.join('\n')}\n`
if (desktopLogBuffer.length >= DESKTOP_LOG_BUFFER_MAX_CHARS) {
if (desktopLogFlushTimer) {
clearTimeout(desktopLogFlushTimer)
desktopLogFlushTimer = null
}
void flushDesktopLogBufferAsync()
return
}
scheduleDesktopLogFlush()
}
function fileExists(filePath) {
@ -1156,6 +1206,7 @@ function createWindow() {
preload: path.join(__dirname, 'preload.cjs'),
contextIsolation: true,
webviewTag: true,
sandbox: true,
nodeIntegration: false,
devTools: Boolean(DEV_SERVER)
}
@ -1177,6 +1228,10 @@ function createWindow() {
} else {
mainWindow.loadURL(pathToFileURL(resolveRendererIndex()).toString())
}
mainWindow.webContents.once('did-finish-load', () => {
startHermes().catch(error => rememberLog(error.stack || error.message))
})
}
ipcMain.handle('hermes:connection', async () => startHermes())
@ -1461,7 +1516,6 @@ app.whenReady().then(() => {
Menu.setApplicationMenu(buildApplicationMenu())
installMediaPermissions()
createWindow()
startHermes().catch(error => rememberLog(error.stack || error.message))
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
@ -1469,6 +1523,11 @@ app.whenReady().then(() => {
})
app.on('before-quit', () => {
if (desktopLogFlushTimer) {
clearTimeout(desktopLogFlushTimer)
desktopLogFlushTimer = null
}
flushDesktopLogBufferSync()
closePreviewWatchers()
if (hermesProcess && !hermesProcess.killed) {

View file

@ -11,6 +11,8 @@
"dev": "concurrently -k \"npm:dev:renderer\" \"npm:dev:electron\"",
"dev:renderer": "vite --host 127.0.0.1 --port 5174",
"dev:electron": "wait-on http://127.0.0.1:5174 && cross-env HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .",
"profile:main": "wait-on http://127.0.0.1:5174 && cross-env HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron --inspect=9229 .",
"profile:main:cpu": "wait-on http://127.0.0.1:5174 && cross-env NODE_OPTIONS=--cpu-prof HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .",
"start": "npm run build && electron .",
"build": "tsc -b && vite build",
"stage:hermes": "node scripts/stage-hermes-payload.mjs",
@ -121,7 +123,7 @@
"asar": true,
"afterSign": "scripts/notarize.cjs",
"asarUnpack": [
"dist/**"
"**/*.node"
],
"mac": {
"category": "public.app-category.developer-tools",

View file

@ -38,7 +38,13 @@ import { useComposerGlassTweaks } from './hooks/use-composer-glass-tweaks'
import { useSlashCompletions } from './hooks/use-slash-completions'
import { useVoiceConversation } from './hooks/use-voice-conversation'
import { useVoiceRecorder } from './hooks/use-voice-recorder'
import { composerPlainText, placeCaretEnd, refChipElement, renderComposerContents, RICH_INPUT_SLOT } from './rich-editor'
import {
composerPlainText,
placeCaretEnd,
refChipElement,
renderComposerContents,
RICH_INPUT_SLOT
} from './rich-editor'
import { SkinSlashPopover } from './skin-slash-popover'
import { ComposerTriggerPopover } from './trigger-popover'
import type { ChatBarProps } from './types'

View file

@ -36,6 +36,7 @@ type PreviewWebview = HTMLElement & {
}
interface PreviewPaneProps {
embedded?: boolean
onClose: () => void
onRestartServer?: (url: string, context?: string) => Promise<string>
reloadRequest?: number
@ -359,15 +360,30 @@ function PreviewConsolePanel({
const selectedLogIds = useStore(consoleState.$selectedLogIds)
const visibleSelection = useMemo(() => logs.filter(log => selectedLogIds.has(log.id)), [logs, selectedLogIds])
const sendableLogs = visibleSelection.length > 0 ? visibleSelection : logs
const stickScrollRafRef = useRef<number | null>(null)
useEffect(() => {
if (!consoleShouldStickRef.current) {
return
}
const consoleBody = consoleBodyRef.current
if (stickScrollRafRef.current !== null) {
window.cancelAnimationFrame(stickScrollRafRef.current)
stickScrollRafRef.current = null
}
consoleBody?.scrollTo({ top: consoleBody.scrollHeight })
stickScrollRafRef.current = window.requestAnimationFrame(() => {
stickScrollRafRef.current = null
const consoleBody = consoleBodyRef.current
consoleBody?.scrollTo({ top: consoleBody.scrollHeight })
})
return () => {
if (stickScrollRafRef.current !== null) {
window.cancelAnimationFrame(stickScrollRafRef.current)
stickScrollRafRef.current = null
}
}
}, [consoleBodyRef, consoleHeight, consoleShouldStickRef, logs])
function sendLogsToComposer(entries: ConsoleEntry[]) {
@ -917,6 +933,7 @@ function LocalFilePreview({ reloadKey, target }: { reloadKey: number; target: Pr
const TITLEBAR_GROUP_ID = 'preview'
export function PreviewPane({
embedded = false,
onClose,
onRestartServer,
reloadRequest = 0,
@ -1136,9 +1153,12 @@ export function PreviewPane({
consoleShouldStickRef.current = true
const consoleBody = consoleBodyRef.current
const handle = window.requestAnimationFrame(() => {
const consoleBody = consoleBodyRef.current
consoleBody?.scrollTo({ top: consoleBody.scrollHeight })
})
consoleBody?.scrollTo({ top: consoleBody.scrollHeight })
return () => window.cancelAnimationFrame(handle)
}, [consoleOpen])
useEffect(() => {
@ -1423,21 +1443,23 @@ export function PreviewPane({
}, [appendConsoleEntry, consoleState, isWebPreview, target.url])
return (
<aside className="relative flex h-full w-full min-w-0 flex-col overflow-hidden border-l border-border/60 bg-background text-muted-foreground">
<aside className="relative flex h-full w-full min-w-0 flex-col overflow-hidden bg-background text-muted-foreground">
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="pointer-events-none flex min-h-(--titlebar-height) items-center gap-1.5 border-b border-border/60 bg-background px-2 py-1">
<div className="min-w-0 flex-1">
<a
className="pointer-events-auto inline max-w-full cursor-pointer truncate text-left text-xs font-medium text-foreground underline-offset-4 transition-colors hover:text-primary hover:underline"
href={currentUrl}
rel="noreferrer"
target="_blank"
title={`Open ${currentUrl}`}
>
{previewLabel || 'Preview'}
</a>
{!embedded && (
<div className="pointer-events-none flex min-h-(--titlebar-height) items-center gap-1.5 border-b border-border/60 bg-background px-2 py-1">
<div className="min-w-0 flex-1">
<a
className="pointer-events-auto inline max-w-full cursor-pointer truncate text-left text-xs font-medium text-foreground underline-offset-4 transition-colors hover:text-primary hover:underline"
href={currentUrl}
rel="noreferrer"
target="_blank"
title={`Open ${currentUrl}`}
>
{previewLabel || 'Preview'}
</a>
</div>
</div>
</div>
)}
<div
className="pointer-events-auto relative min-h-0 flex-1 overflow-hidden bg-background"

View file

@ -1,12 +1,23 @@
import { useStore } from '@nanostores/react'
import { type MouseEvent, useCallback, useEffect, useMemo, useRef } from 'react'
import type { SetTitlebarToolGroup } from '@/app/shell/titlebar-controls'
import { X } from '@/lib/icons'
import { cn } from '@/lib/utils'
import {
$filePreviewTarget,
$rightRailActiveTabId,
RIGHT_RAIL_PREVIEW_TAB_ID,
type RightRailTabId,
selectRightRailTab
} from '@/store/layout'
import {
$filePreviewTabs,
$previewReloadRequest,
$previewTarget,
dismissFilePreviewTarget,
dismissPreviewTarget
closeFilePreviewTab,
dismissPreviewTarget,
type FilePreviewTab,
type PreviewTarget
} from '@/store/preview'
import { PreviewPane } from './preview-pane'
@ -27,23 +38,152 @@ interface ChatPreviewRailProps {
setTitlebarToolGroup?: SetTitlebarToolGroup
}
interface RailTab {
closeLabel: string
id: RightRailTabId
label: string
target: PreviewTarget
}
function previewTabLabel(target: PreviewTarget): string {
const value = target.label || target.path || target.source || target.url
const parts = value.split(/[\\/]/).filter(Boolean)
return parts.at(-1) || value || 'Preview'
}
function tabLabel(tab: FilePreviewTab): string {
return previewTabLabel(tab.target)
}
export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatPreviewRailProps) {
const previewReloadRequest = useStore($previewReloadRequest)
const filePreviewTarget = useStore($filePreviewTarget)
const activeTabId = useStore($rightRailActiveTabId)
const filePreviewTabs = useStore($filePreviewTabs)
const previewTarget = useStore($previewTarget)
const target = filePreviewTarget ?? previewTarget
if (!target) {
const tabs = useMemo<readonly RailTab[]>(
() => [
...(previewTarget
? [
{
closeLabel: 'Close preview',
id: RIGHT_RAIL_PREVIEW_TAB_ID,
label: 'Preview',
target: previewTarget
} satisfies RailTab
]
: []),
...filePreviewTabs.map(tab => ({
closeLabel: `Close ${tabLabel(tab)}`,
id: tab.id,
label: tabLabel(tab),
target: tab.target
}))
],
[filePreviewTabs, previewTarget]
)
const activeTab = tabs.find(tab => tab.id === activeTabId) ?? tabs[0]
// Read-by-ref so close handlers stay reference-stable across renders.
const activeTabRef = useRef<RailTab | undefined>(activeTab)
activeTabRef.current = activeTab
useEffect(() => {
if (activeTab && activeTab.id !== activeTabId) {
selectRightRailTab(activeTab.id)
}
}, [activeTab, activeTabId])
const closeRailTab = useCallback((tab: RailTab) => {
if (tab.id === RIGHT_RAIL_PREVIEW_TAB_ID) {
dismissPreviewTarget()
return
}
closeFilePreviewTab(tab.id)
}, [])
// Stable: PreviewPane lists onClose in a useEffect dep array that pushes
// titlebar tools. A fresh closure every render → setTitlebarToolGroup every
// render → DesktopController setState → re-render → ∞.
const handleCloseDocument = useCallback(() => {
const tab = activeTabRef.current
if (tab) {
closeRailTab(tab)
}
}, [closeRailTab])
const closeTab = (event: MouseEvent, tab: RailTab) => {
event.stopPropagation()
closeRailTab(tab)
}
if (!activeTab) {
return null
}
const isPreview = activeTab.id === RIGHT_RAIL_PREVIEW_TAB_ID
return (
<PreviewPane
onClose={filePreviewTarget ? dismissFilePreviewTarget : dismissPreviewTarget}
onRestartServer={filePreviewTarget ? undefined : onRestartServer}
reloadRequest={previewReloadRequest}
setTitlebarToolGroup={setTitlebarToolGroup}
target={target}
/>
<aside className="relative flex h-full w-full min-w-0 flex-col overflow-hidden border-l border-border/60 bg-background text-muted-foreground">
<div
className="flex h-(--titlebar-height) shrink-0 overflow-x-auto overflow-y-hidden overscroll-x-contain border-b border-border/60 bg-[color-mix(in_srgb,var(--dt-sidebar-bg)_94%,transparent)] [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
role="tablist"
>
{tabs.map(tab => {
const active = tab.id === activeTab.id
return (
<div
className={cn(
'group/tab relative flex h-full max-w-48 shrink-0 items-center text-[0.6875rem] font-medium [-webkit-app-region:no-drag]',
active
? 'bg-background text-foreground'
: 'border-r border-border/40 text-muted-foreground hover:bg-accent/30 hover:text-foreground'
)}
key={tab.id}
>
{active && <span aria-hidden="true" className="absolute inset-x-0 top-0 h-px bg-primary/70" />}
<button
aria-selected={active}
className="flex h-full min-w-0 flex-1 items-center truncate pl-3 pr-1.5 text-left outline-none"
onClick={() => selectRightRailTab(tab.id)}
role="tab"
title={tab.label}
type="button"
>
{tab.label}
</button>
<button
aria-label={tab.closeLabel}
className={cn(
'mr-1.5 hidden size-4 shrink-0 place-items-center rounded-sm text-muted-foreground/55 transition-colors hover:bg-accent hover:text-foreground focus-visible:grid group-hover/tab:grid',
active && 'grid'
)}
onClick={event => closeTab(event, tab)}
title={tab.closeLabel}
type="button"
>
<X className="size-3" />
</button>
</div>
)
})}
</div>
<div className="min-h-0 flex-1 overflow-hidden">
<PreviewPane
embedded
onClose={handleCloseDocument}
onRestartServer={isPreview ? onRestartServer : undefined}
reloadRequest={previewReloadRequest}
setTitlebarToolGroup={setTitlebarToolGroup}
target={activeTab.target}
/>
</div>
</aside>
)
}

View file

@ -1,6 +1,6 @@
import { useStore } from '@nanostores/react'
import { useQueryClient } from '@tanstack/react-query'
import { useCallback, useEffect, useRef } from 'react'
import { lazy, Suspense, useCallback, useEffect, useRef } from 'react'
import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from 'react-router-dom'
import { Pane, PaneMain } from '@/components/pane-shell'
@ -35,13 +35,15 @@ import {
setSessionsLoading
} from '../store/session'
import { AgentsView } from './agents'
import { ArtifactsView } from './artifacts'
import { ChatView } from './chat'
import { useComposerActions } from './chat/hooks/use-composer-actions'
import { ChatPreviewRail, PREVIEW_RAIL_MAX_WIDTH, PREVIEW_RAIL_MIN_WIDTH, PREVIEW_RAIL_PANE_WIDTH } from './chat/right-rail'
import {
ChatPreviewRail,
PREVIEW_RAIL_MAX_WIDTH,
PREVIEW_RAIL_MIN_WIDTH,
PREVIEW_RAIL_PANE_WIDTH
} from './chat/right-rail'
import { ChatSidebar } from './chat/sidebar'
import { CommandCenterView } from './command-center'
import { FileBrowserPane } from './file-browser'
import { useGatewayBoot } from './gateway/hooks/use-gateway-boot'
import { useGatewayRequest } from './gateway/hooks/use-gateway-request'
@ -57,7 +59,6 @@ import { usePromptActions } from './session/hooks/use-prompt-actions'
import { useRouteResume } from './session/hooks/use-route-resume'
import { useSessionActions } from './session/hooks/use-session-actions'
import { useSessionStateCache } from './session/hooks/use-session-state-cache'
import { SettingsView } from './settings'
import { AppShell } from './shell/app-shell'
import { useOverlayRouting } from './shell/hooks/use-overlay-routing'
import { useStatusSnapshot } from './shell/hooks/use-status-snapshot'
@ -65,7 +66,12 @@ import { useStatusbarItems } from './shell/hooks/use-statusbar-items'
import type { StatusbarItem } from './shell/statusbar-controls'
import type { TitlebarTool } from './shell/titlebar-controls'
import { useGroupRegistry } from './shell/use-group-registry'
import { SkillsView } from './skills'
const AgentsView = lazy(async () => ({ default: (await import('./agents')).AgentsView }))
const ArtifactsView = lazy(async () => ({ default: (await import('./artifacts')).ArtifactsView }))
const CommandCenterView = lazy(async () => ({ default: (await import('./command-center')).CommandCenterView }))
const SettingsView = lazy(async () => ({ default: (await import('./settings')).SettingsView }))
const SkillsView = lazy(async () => ({ default: (await import('./skills')).SkillsView }))
export function DesktopController() {
const queryClient = useQueryClient()
@ -404,34 +410,42 @@ export function DesktopController() {
<ModelPickerOverlay gateway={gatewayRef.current || undefined} onSelect={selectModel} />
{settingsOpen && (
<SettingsView
onClose={closeOverlayToPreviousRoute}
onConfigSaved={() => {
void refreshHermesConfig()
void refreshCurrentModel()
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
}}
/>
<Suspense fallback={null}>
<SettingsView
onClose={closeOverlayToPreviousRoute}
onConfigSaved={() => {
void refreshHermesConfig()
void refreshCurrentModel()
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
}}
/>
</Suspense>
)}
{commandCenterOpen && (
<CommandCenterView
initialSection={commandCenterInitialSection}
onClose={closeOverlayToPreviousRoute}
onDeleteSession={removeSession}
onMainModelChanged={(provider, model) => {
setCurrentProvider(provider)
setCurrentModel(model)
updateModelOptionsCache(provider, model, true)
void refreshCurrentModel()
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
}}
onNavigateRoute={path => navigate(path)}
onOpenSession={sessionId => navigate(sessionRoute(sessionId))}
/>
<Suspense fallback={null}>
<CommandCenterView
initialSection={commandCenterInitialSection}
onClose={closeOverlayToPreviousRoute}
onDeleteSession={removeSession}
onMainModelChanged={(provider, model) => {
setCurrentProvider(provider)
setCurrentModel(model)
updateModelOptionsCache(provider, model, true)
void refreshCurrentModel()
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
}}
onNavigateRoute={path => navigate(path)}
onOpenSession={sessionId => navigate(sessionRoute(sessionId))}
/>
</Suspense>
)}
{agentsOpen && <AgentsView onClose={closeOverlayToPreviousRoute} />}
{agentsOpen && (
<Suspense fallback={null}>
<AgentsView onClose={closeOverlayToPreviousRoute} />
</Suspense>
)}
</>
)
@ -489,16 +503,20 @@ export function DesktopController() {
<Route element={chatView} path=":sessionId" />
<Route
element={
<SkillsView setStatusbarItemGroup={setStatusbarItemGroup} setTitlebarToolGroup={setTitlebarToolGroup} />
<Suspense fallback={null}>
<SkillsView setStatusbarItemGroup={setStatusbarItemGroup} setTitlebarToolGroup={setTitlebarToolGroup} />
</Suspense>
}
path="skills"
/>
<Route
element={
<ArtifactsView
setStatusbarItemGroup={setStatusbarItemGroup}
setTitlebarToolGroup={setTitlebarToolGroup}
/>
<Suspense fallback={null}>
<ArtifactsView
setStatusbarItemGroup={setStatusbarItemGroup}
setTitlebarToolGroup={setTitlebarToolGroup}
/>
</Suspense>
}
path="artifacts"
/>

View file

@ -24,12 +24,14 @@ interface FileBrowserPaneProps {
export function FileBrowserPane({ onActivateFile, onChangeCwd }: FileBrowserPaneProps) {
const currentCwd = useStore($currentCwd).trim()
const hasCwd = currentCwd.length > 0
const cwdName = hasCwd
? (currentCwd
.split(/[\\/]+/)
.filter(Boolean)
.pop() ?? currentCwd)
: 'No folder selected'
const { data, loadChildren, openState, refreshRoot, rootError, rootLoading, setNodeOpen } = useProjectTree(currentCwd)
const chooseFolder = async () => {

View file

@ -179,6 +179,7 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {
if (!cwd || inflight.has(id)) {
return
}
inflight.add(id)
setProjectTree(current => {

View file

@ -1,5 +1,5 @@
import type { QueryClient } from '@tanstack/react-query'
import { type MutableRefObject, useCallback } from 'react'
import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
import {
appendAssistantTextPart,
@ -51,6 +51,13 @@ interface MessageStreamOptions {
) => ClientSessionState
}
interface QueuedStreamDeltas {
assistant: string
reasoning: string
}
const STREAM_DELTA_FLUSH_MS = 16
export function useMessageStream({
activeSessionIdRef,
hydrateFromStoredSession,
@ -123,19 +130,108 @@ export function useMessageStream({
[updateSessionState]
)
const queuedDeltasRef = useRef<Map<string, QueuedStreamDeltas>>(new Map())
const flushHandleRef = useRef<number | null>(null)
const flushQueuedDeltas = useCallback(
(sessionId?: string) => {
const queue = queuedDeltasRef.current
const ids = sessionId ? [sessionId] : [...queue.keys()]
for (const id of ids) {
const queued = queue.get(id)
if (!queued) {
continue
}
queue.delete(id)
if (queued.assistant) {
mutateStream(
id,
parts => appendAssistantTextPart(parts, queued.assistant),
() => [assistantTextPart(queued.assistant)]
)
}
if (queued.reasoning) {
mutateStream(
id,
parts => appendReasoningPart(parts, queued.reasoning),
() => [reasoningPart(queued.reasoning)]
)
}
}
},
[mutateStream]
)
const scheduleDeltaFlush = useCallback(() => {
if (flushHandleRef.current !== null) {
return
}
if (typeof window === 'undefined') {
flushQueuedDeltas()
return
}
if (typeof window.requestAnimationFrame === 'function') {
flushHandleRef.current = window.requestAnimationFrame(() => {
flushHandleRef.current = null
flushQueuedDeltas()
})
return
}
flushHandleRef.current = window.setTimeout(() => {
flushHandleRef.current = null
flushQueuedDeltas()
}, STREAM_DELTA_FLUSH_MS)
}, [flushQueuedDeltas])
const queueDelta = useCallback(
(sessionId: string, key: keyof QueuedStreamDeltas, delta: string) => {
if (!delta) {
return
}
const queued = queuedDeltasRef.current.get(sessionId) ?? { assistant: '', reasoning: '' }
queued[key] += delta
queuedDeltasRef.current.set(sessionId, queued)
scheduleDeltaFlush()
},
[scheduleDeltaFlush]
)
useEffect(
() => () => {
if (flushHandleRef.current !== null && typeof window !== 'undefined') {
if (typeof window.cancelAnimationFrame === 'function') {
window.cancelAnimationFrame(flushHandleRef.current)
} else {
window.clearTimeout(flushHandleRef.current)
}
}
flushHandleRef.current = null
flushQueuedDeltas()
},
[flushQueuedDeltas]
)
const appendAssistantDelta = useCallback(
(sessionId: string, delta: string) => {
if (!delta) {
return
}
mutateStream(
sessionId,
parts => appendAssistantTextPart(parts, delta),
() => [assistantTextPart(delta)]
)
queueDelta(sessionId, 'assistant', delta)
},
[mutateStream]
[queueDelta]
)
const appendReasoningDelta = useCallback(
@ -144,6 +240,14 @@ export function useMessageStream({
return
}
if (!replace) {
queueDelta(sessionId, 'reasoning', delta)
return
}
flushQueuedDeltas(sessionId)
mutateStream(
sessionId,
(parts, message) => {
@ -160,7 +264,7 @@ export function useMessageStream({
() => [reasoningPart(delta)]
)
},
[mutateStream]
[flushQueuedDeltas, mutateStream, queueDelta]
)
const upsertToolCall = useCallback(
@ -384,6 +488,8 @@ export function useMessageStream({
return
}
flushQueuedDeltas(sessionId)
if (isActiveEvent) {
triggerHaptic('streamStart')
}
@ -421,6 +527,8 @@ export function useMessageStream({
return
}
flushQueuedDeltas(sessionId)
if (isActiveEvent) {
triggerHaptic('streamDone')
}
@ -440,9 +548,12 @@ export function useMessageStream({
return
}
flushQueuedDeltas(sessionId)
upsertToolCall(sessionId, payload, 'running')
} else if (event.type === 'tool.complete') {
if (sessionId) {
flushQueuedDeltas(sessionId)
upsertToolCall(sessionId, payload, 'complete')
}
@ -478,6 +589,7 @@ export function useMessageStream({
}
if (sessionId) {
flushQueuedDeltas(sessionId)
updateSessionState(sessionId, state => ({
...state,
awaitingResponse: false,
@ -495,6 +607,7 @@ export function useMessageStream({
appendReasoningDelta,
activeSessionIdRef,
completeAssistantMessage,
flushQueuedDeltas,
queryClient,
refreshHermesConfig,
updateSessionState,

View file

@ -65,6 +65,7 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway
if (selection.persistGlobal) {
void refreshCurrentModel()
}
void queryClient.invalidateQueries({
queryKey: selection.persistGlobal ? ['model-options'] : ['model-options', activeSessionId]
})

View file

@ -134,11 +134,13 @@ export function usePreviewRouting({
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)

View file

@ -25,6 +25,7 @@ function rawHashLooksLikeSession(): boolean {
if (typeof window === 'undefined') {
return false
}
const hash = window.location.hash.replace(/^#/, '')
if (!hash || hash === '/') {

View file

@ -29,6 +29,8 @@ export function useSessionStateCache({
const selectedStoredSessionIdRef = useRef<string | null>(null)
const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())
const runtimeIdByStoredSessionIdRef = useRef(new Map<string, string>())
const pendingViewStateRef = useRef<{ sessionId: string; state: ClientSessionState } | null>(null)
const viewSyncRafRef = useRef<number | null>(null)
useEffect(() => {
activeSessionIdRef.current = activeSessionId
@ -78,18 +80,60 @@ export function useSessionStateCache({
const syncSessionStateToView = useCallback(
(sessionId: string, state: ClientSessionState) => {
if (sessionId !== activeSessionIdRef.current) {
pendingViewStateRef.current = { sessionId, state }
if (viewSyncRafRef.current !== null) {
return
}
setMessages(state.messages)
setBusy(state.busy)
busyRef.current = state.busy
setAwaitingResponse(state.awaitingResponse)
if (typeof window === 'undefined') {
const pending = pendingViewStateRef.current
if (!pending || pending.sessionId !== activeSessionIdRef.current) {
pendingViewStateRef.current = null
return
}
pendingViewStateRef.current = null
setMessages(pending.state.messages)
setBusy(pending.state.busy)
busyRef.current = pending.state.busy
setAwaitingResponse(pending.state.awaitingResponse)
return
}
viewSyncRafRef.current = window.requestAnimationFrame(() => {
viewSyncRafRef.current = null
const pending = pendingViewStateRef.current
if (!pending || pending.sessionId !== activeSessionIdRef.current) {
pendingViewStateRef.current = null
return
}
pendingViewStateRef.current = null
setMessages(pending.state.messages)
setBusy(pending.state.busy)
busyRef.current = pending.state.busy
setAwaitingResponse(pending.state.awaitingResponse)
})
},
[busyRef, setAwaitingResponse, setBusy, setMessages]
)
useEffect(
() => () => {
if (viewSyncRafRef.current !== null && typeof window !== 'undefined') {
window.cancelAnimationFrame(viewSyncRafRef.current)
viewSyncRafRef.current = null
}
},
[]
)
const updateSessionState = useCallback(
(
sessionId: string,

View file

@ -121,7 +121,7 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
{visiblePaneTools.length > 0 && (
<div
aria-label="Pane controls"
className="fixed top-(--titlebar-controls-top) right-[calc(var(--titlebar-tools-right)+var(--shell-preview-toolbar-gap,0px))] z-70 flex flex-row items-center gap-px pointer-events-auto select-none [-webkit-app-region:no-drag]"
className="fixed top-(--titlebar-controls-top) right-[calc(var(--titlebar-tools-right)+var(--shell-preview-toolbar-gap,0))] z-70 flex flex-row items-center gap-px pointer-events-auto select-none [-webkit-app-region:no-drag]"
>
{visiblePaneTools.map(tool => (
<TitlebarToolButton key={tool.id} navigate={navigate} tool={tool} />

View file

@ -1,6 +1,11 @@
'use client'
import { type StreamdownTextComponents, StreamdownTextPrimitive } from '@assistant-ui/react-streamdown'
import { useAuiState } from '@assistant-ui/react'
import {
type StreamdownTextComponents,
StreamdownTextPrimitive,
type SyntaxHighlighterProps
} from '@assistant-ui/react-streamdown'
import { code } from '@streamdown/code'
import { type ComponentProps, memo, useEffect, useMemo, useState } from 'react'
@ -203,6 +208,8 @@ function MarkdownImage({ className, src, alt, ...props }: ComponentProps<'img'>)
}
const MarkdownTextImpl = () => {
const isStreaming = useAuiState(s => s.message.status?.type === 'running')
const components = useMemo(
() =>
({
@ -267,10 +274,10 @@ const MarkdownTextImpl = () => {
<td className={cn('px-3 py-2 align-top text-sm leading-snug', className)} {...props} />
),
img: MarkdownImage,
SyntaxHighlighter,
SyntaxHighlighter: (props: SyntaxHighlighterProps) => <SyntaxHighlighter {...props} defer={isStreaming} />,
CodeHeader
}) as StreamdownTextComponents,
[]
[isStreaming]
)
return (
@ -280,8 +287,8 @@ const MarkdownTextImpl = () => {
containerClassName="aui-md max-w-full overflow-hidden text-foreground"
lineNumbers={false}
mode="streaming"
parseIncompleteMarkdown
plugins={{ code }}
parseIncompleteMarkdown={!isStreaming}
plugins={isStreaming ? undefined : { code }}
preprocess={preprocessMarkdown}
shikiTheme={['github-light-default', 'github-dark-default']}
/>

View file

@ -19,10 +19,15 @@ import { isLikelyProseCodeBlock } from '@/lib/markdown-code'
* `showLanguage` is disabled because we render our own `CodeHeader`;
* leaving it on causes the language to appear twice.
*/
export const SyntaxHighlighter: FC<SyntaxHighlighterProps> = ({
interface HermesSyntaxHighlighterProps extends SyntaxHighlighterProps {
defer?: boolean
}
export const SyntaxHighlighter: FC<HermesSyntaxHighlighterProps> = ({
components: { Pre, Code: _UnusedCode },
language,
code
code,
defer = false
}) => {
// Streamdown may hand us fence contents with edge newlines. Strip blank
// fence padding without touching indentation on the first real line.
@ -38,6 +43,14 @@ export const SyntaxHighlighter: FC<SyntaxHighlighterProps> = ({
return <div className="whitespace-pre-wrap wrap-anywhere text-foreground">{trimmed}</div>
}
if (defer) {
return (
<Pre className="aui-shiki m-0 overflow-hidden rounded-b-md border border-t-0 border-border bg-card font-mono text-sm leading-relaxed [&_pre]:m-0 [&_pre]:overflow-x-auto [&_pre]:bg-transparent! [&_pre]:px-4 [&_pre]:py-3 [&_pre]:font-mono [&_pre]:leading-relaxed">
<code className="block whitespace-pre">{trimmed}</code>
</Pre>
)
}
return (
<Pre className="aui-shiki m-0 overflow-hidden rounded-b-md border border-t-0 border-border bg-card font-mono text-sm leading-relaxed [&_pre]:m-0 [&_pre]:overflow-x-auto [&_pre]:bg-transparent! [&_pre]:px-4 [&_pre]:py-3 [&_pre]:font-mono [&_pre]:leading-relaxed">
<ShikiHighlighter

View file

@ -11,7 +11,7 @@ import {
useAuiState
} from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import { type FC, type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
import { type FC, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import spinners from 'unicode-animations'
// Scroll behavior: delegated to `use-stick-to-bottom` (StackBlitz), the
// reference implementation that powers bolt.new and several other streaming
@ -204,6 +204,9 @@ const ThreadScrollSync: FC<{ sessionKey?: string | null }> = ({ sessionKey }) =>
// "Armed" behavior ref. Non-null = "keep chasing bottom across resize
// ticks until we get there." Null = "user owns the viewport."
const armedRef = useRef<ScrollBehavior | null>(null)
const pinRafRef = useRef<number | null>(null)
const previousScrollTopRef = useRef(0)
const suppressNextScrollEventRef = useRef(false)
const messageCount = useAuiState(s => s.thread.messages.length)
const prevMessageCountRef = useRef(messageCount)
@ -233,7 +236,9 @@ const ThreadScrollSync: FC<{ sessionKey?: string | null }> = ({ sessionKey }) =>
// state object so its resize handler sees a clean follow state.
state.escapedFromLock = false
state.isAtBottom = true
suppressNextScrollEventRef.current = true
el.scrollTop = el.scrollHeight
previousScrollTopRef.current = el.scrollTop
},
[scrollRef, state]
)
@ -248,21 +253,29 @@ const ThreadScrollSync: FC<{ sessionKey?: string | null }> = ({ sessionKey }) =>
}
const observer = new ResizeObserver(() => {
const behavior = armedRef.current
if (!behavior) {
if (pinRafRef.current !== null) {
return
}
const distance = el.scrollHeight - (el.scrollTop + el.clientHeight)
pinRafRef.current = window.requestAnimationFrame(() => {
pinRafRef.current = null
if (distance < 2) {
armedRef.current = null
if (!armedRef.current) {
return
}
return
}
const distance = el.scrollHeight - (el.scrollTop + el.clientHeight)
el.scrollTop = el.scrollHeight
if (distance < 2) {
armedRef.current = null
return
}
suppressNextScrollEventRef.current = true
el.scrollTop = el.scrollHeight
previousScrollTopRef.current = el.scrollTop
})
})
observer.observe(el)
@ -273,7 +286,14 @@ const ThreadScrollSync: FC<{ sessionKey?: string | null }> = ({ sessionKey }) =>
observer.observe(content)
}
return () => observer.disconnect()
return () => {
observer.disconnect()
if (pinRafRef.current !== null) {
window.cancelAnimationFrame(pinRafRef.current)
pinRafRef.current = null
}
}
}, [scrollRef])
// User-intent detection — any upward gesture disarms the chase.
@ -294,12 +314,31 @@ const ThreadScrollSync: FC<{ sessionKey?: string | null }> = ({ sessionKey }) =>
armedRef.current = null
}
const onScroll = () => {
const currentTop = el.scrollTop
if (suppressNextScrollEventRef.current) {
suppressNextScrollEventRef.current = false
previousScrollTopRef.current = currentTop
return
}
if (currentTop + 1 < previousScrollTopRef.current) {
armedRef.current = null
}
previousScrollTopRef.current = currentTop
}
el.addEventListener('wheel', onWheel, { passive: true })
el.addEventListener('touchmove', onTouch, { passive: true })
el.addEventListener('scroll', onScroll, { passive: true })
return () => {
el.removeEventListener('wheel', onWheel)
el.removeEventListener('touchmove', onTouch)
el.removeEventListener('scroll', onScroll)
}
}, [scrollRef])
@ -409,12 +448,30 @@ const ComposerClearance: FC = () => {
}
bindComposer()
const mutationObserver = new MutationObserver(() => void bindComposer())
mutationObserver.observe(document.body, { childList: true, subtree: true })
let bindRaf: number | null = null
let bindAttempts = 0
const tryBindComposer = () => {
if (bindComposer()) {
return
}
if (bindAttempts >= 120) {
return
}
bindAttempts += 1
bindRaf = window.requestAnimationFrame(tryBindComposer)
}
tryBindComposer()
return () => {
composerObserver?.disconnect()
mutationObserver.disconnect()
if (bindRaf !== null) {
window.cancelAnimationFrame(bindRaf)
}
}
}, [])
@ -452,7 +509,15 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
const messageId = useAuiState(s => s.message.id)
const content = useAuiState(s => s.message.content)
const messageText = messageContentText(content)
const previewTargets = pickPrimaryPreviewTarget(extractPreviewTargets(messageText))
const previewTargets = useMemo(() => {
if (!messageText || !/(https?:\/\/|file:\/\/)/i.test(messageText)) {
return []
}
return pickPrimaryPreviewTarget(extractPreviewTargets(messageText))
}, [messageText])
const isPlaceholder = useAuiState(s => s.message.status?.type === 'running' && s.message.content.length === 0)
if (isPlaceholder) {

View file

@ -3,9 +3,9 @@ import {
Children,
type CSSProperties,
isValidElement,
type PointerEvent as ReactPointerEvent,
type ReactElement,
type ReactNode,
type PointerEvent as ReactPointerEvent,
useCallback,
useContext,
useEffect,
@ -71,10 +71,14 @@ const remPx = () =>
// Resolves PaneProps.minWidth/maxWidth (number | "Npx" | "Nrem") to pixels for drag clamping.
function widthToPx(value: WidthValue | undefined) {
if (typeof value === 'number') {return Number.isFinite(value) ? value : undefined}
if (typeof value === 'number') {
return Number.isFinite(value) ? value : undefined
}
const match = value?.trim().match(/^(-?\d*\.?\d+)(px|rem)?$/)
if (!match) {return undefined}
if (!match) {
return undefined
}
return Number.parseFloat(match[1]) * (match[2] === 'rem' ? remPx() : 1)
}
@ -95,7 +99,9 @@ function collectPanes(children: ReactNode) {
return
}
if (!isRole(child, 'pane')) {return}
if (!isRole(child, 'pane')) {
return
}
const props = child.props as PaneProps
@ -117,7 +123,9 @@ function trackForPane(pane: CollectedPane, states: Record<string, { open: boolea
const stateOpen = states[pane.id]?.open ?? pane.defaultOpen
const open = !pane.disabled && stateOpen
if (!open) {return { open: false, track: '0px' }}
if (!open) {
return { open: false, track: '0px' }
}
const override = states[pane.id]?.widthOverride
@ -192,7 +200,9 @@ export function Pane({
const paneRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
if (registered.current) {return}
if (registered.current) {
return
}
registered.current = true
ensurePaneRegistered(id, { open: defaultOpen })
}, [defaultOpen, id])
@ -208,7 +218,9 @@ export function Pane({
(event: ReactPointerEvent<HTMLDivElement>) => {
const paneWidth = paneRef.current?.getBoundingClientRect().width ?? 0
if (!canResize || paneWidth <= 0) {return}
if (!canResize || paneWidth <= 0) {
return
}
event.preventDefault()
const handle = event.currentTarget
@ -245,12 +257,16 @@ export function Pane({
)
if (!ctx) {
if (import.meta.env.DEV) {console.warn(`[Pane:${id}] must be rendered inside <PaneShell>`)}
if (import.meta.env.DEV) {
console.warn(`[Pane:${id}] must be rendered inside <PaneShell>`)
}
return null
}
if (!slot) {return null}
if (!slot) {
return null
}
return (
<div
@ -288,7 +304,9 @@ export function PaneMain({ children, className }: PaneMainProps) {
const ctx = useContext(PaneShellContext)
if (!ctx) {
if (import.meta.env.DEV) {console.warn('[PaneMain] must be rendered inside <PaneShell>')}
if (import.meta.env.DEV) {
console.warn('[PaneMain] must be rendered inside <PaneShell>')
}
return null
}

View file

@ -174,7 +174,14 @@ export function appendAssistantTextPart(parts: ChatMessagePart[], delta: string)
const last = next.at(-1)
if (last?.type === 'text') {
next[next.length - 1] = { ...last, text: renderMediaTags(last.text) }
const current = last.text
const deltaMayContainMedia =
delta.includes('MEDIA:') || delta.includes('DIA:') || delta.includes('EDIA:') || delta.includes('IA:')
const needsMediaPass = deltaMayContainMedia || current.includes('MEDIA:')
const nextText = needsMediaPass ? renderMediaTags(current) : current
next[next.length - 1] = nextText === current ? last : { ...last, text: nextText }
}
return next

View file

@ -15,6 +15,7 @@ export function gatewayEventCompletedFileDiff(event: RpcEventLike): boolean {
if (event.type !== 'tool.complete') {
return false
}
const diff = asRecord(event.payload).inline_diff
return typeof diff === 'string' && diff.trim().length > 0

View file

@ -14,9 +14,10 @@ const SIDEBAR_PINNED_STORAGE_KEY = 'hermes.desktop.pinnedSessions'
export const CHAT_SIDEBAR_PANE_ID = 'chat-sidebar'
export const FILE_BROWSER_PANE_ID = 'file-browser'
export const RIGHT_RAIL_PREVIEW_TAB_ID = 'preview'
export type RightRailTabId = typeof RIGHT_RAIL_PREVIEW_TAB_ID | `file:${string}`
// Pre-register app chrome panes so legacy callers see the correct initial
// values whether or not the user has any persisted state.
ensurePaneRegistered(CHAT_SIDEBAR_PANE_ID, { open: true })
ensurePaneRegistered(FILE_BROWSER_PANE_ID, { open: false })
@ -30,6 +31,8 @@ export const $fileBrowserOpen: ReadableAtom<boolean> = computed(
states => states[FILE_BROWSER_PANE_ID]?.open ?? false
)
export const $rightRailActiveTabId = atom<RightRailTabId>(RIGHT_RAIL_PREVIEW_TAB_ID)
export const $sidebarWidth: ReadableAtom<number> = computed($paneStates, states => {
const override = states[CHAT_SIDEBAR_PANE_ID]?.widthOverride
@ -60,6 +63,10 @@ export function toggleFileBrowserOpen() {
togglePane(FILE_BROWSER_PANE_ID)
}
export function selectRightRailTab(id: RightRailTabId) {
$rightRailActiveTabId.set(id)
}
export function setSidebarPinsOpen(open: boolean) {
$sidebarPinsOpen.set(open)
}

View file

@ -16,6 +16,7 @@ function isSnapshot(value: unknown): value is PaneStateSnapshot {
if (!value || typeof value !== 'object') {
return false
}
const r = value as Record<string, unknown>
if (typeof r.open !== 'boolean') {
@ -60,6 +61,7 @@ function persist(states: Record<string, PaneStateSnapshot>) {
if (typeof window === 'undefined') {
return
}
const minimal: Record<string, { open: boolean }> = {}
for (const [id, s] of Object.entries(states)) {
@ -107,6 +109,7 @@ export function ensurePaneRegistered(id: string, defaults: PaneRegisterDefaults)
if (current[id] !== undefined) {
return
}
$paneStates.set({ ...current, [id]: { open: defaults.open, widthOverride: defaults.widthOverride } })
}
@ -117,6 +120,7 @@ export function setPaneOpen(id: string, open: boolean) {
if (existing?.open === open) {
return
}
$paneStates.set({ ...current, [id]: { open, widthOverride: existing?.widthOverride } })
}
@ -133,6 +137,7 @@ export function setPaneWidthOverride(id: string, width: number | undefined) {
if (existing.widthOverride === width) {
return
}
$paneStates.set({ ...current, [id]: { open: existing.open, widthOverride: width } })
}

View file

@ -1,6 +1,8 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { $rightRailActiveTabId, RIGHT_RAIL_PREVIEW_TAB_ID } from './layout'
import {
$filePreviewTabs,
$filePreviewTarget,
$previewServerRestart,
$previewServerRestartStatus,
@ -118,14 +120,16 @@ describe('preview store', () => {
expect($previewTarget.get()).toEqual(withRenderMode(preview, 'preview'))
})
it('clears file inspection when a live preview opens', () => {
it('keeps file tabs when a live preview opens', () => {
const file = previewTarget('/work/file.html')
const live = previewTarget('/work/live.html')
setCurrentSessionPreviewTarget(file, 'manual')
setCurrentSessionPreviewTarget(live, 'tool-result')
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'))
})
})

View file

@ -1,5 +1,6 @@
import { atom, computed } from 'nanostores'
import { $rightRailActiveTabId, RIGHT_RAIL_PREVIEW_TAB_ID, type RightRailTabId, selectRightRailTab } from './layout'
import { $activeSessionId, $selectedStoredSessionId } from './session'
export interface PreviewTarget {
@ -39,12 +40,27 @@ export interface SessionPreviewRecord {
type SessionPreviewRegistry = Record<string, SessionPreviewRecord[]>
export interface FilePreviewTab {
id: `file:${string}`
target: PreviewTarget
}
const REGISTRY_STORAGE_KEY = 'hermes.desktop.sessionPreviews.v1'
const MAX_RECORDS_PER_SESSION = 1
const MAX_SESSIONS = 120
export const $previewTarget = atom<PreviewTarget | null>(null)
export const $filePreviewTarget = atom<PreviewTarget | null>(null)
export const $filePreviewTabs = atom<FilePreviewTab[]>([])
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 $rightRailHasContent = computed([$previewTarget, $filePreviewTabs], (target, tabs) =>
Boolean(target || tabs.length)
)
export const $previewReloadRequest = atom(0)
export const $previewServerRestart = atom<PreviewServerRestart | null>(null)
export const $previewServerRestartStatus = computed($previewServerRestart, restart => restart?.status ?? 'idle')
@ -72,18 +88,32 @@ function isSamePreviewTarget(a: PreviewTarget | null, b: PreviewTarget | null):
export function setPreviewTarget(target: PreviewTarget | null) {
if (isSamePreviewTarget($previewTarget.get(), target)) {
if (target) {
selectRightRailTab(RIGHT_RAIL_PREVIEW_TAB_ID)
}
return
}
$previewTarget.set(target)
if (target) {
selectRightRailTab(RIGHT_RAIL_PREVIEW_TAB_ID)
}
}
function setFilePreviewTarget(target: PreviewTarget | null) {
if (isSamePreviewTarget($filePreviewTarget.get(), target)) {
return
}
export function filePreviewTabId(target: PreviewTarget): `file:${string}` {
return `file:${target.url}`
}
$filePreviewTarget.set(target)
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)))
selectRightRailTab(id)
}
// Manual/file-browser opens are "peeking at a file" → source view in the file
@ -104,7 +134,8 @@ function tryOpenFilePreview(target: PreviewTarget, source: PreviewRecordSource):
if (target.kind !== 'file' || !isFilePreviewSource(source)) {
return false
}
setFilePreviewTarget(previewTargetForSource(target, source))
openFilePreviewTarget(previewTargetForSource(target, source))
return true
}
@ -113,6 +144,7 @@ function isPreviewTarget(value: unknown): value is PreviewTarget {
if (!value || typeof value !== 'object') {
return false
}
const r = value as Record<string, unknown>
return (
@ -127,6 +159,7 @@ function isPreviewRecord(value: unknown): value is SessionPreviewRecord {
if (!value || typeof value !== 'object') {
return false
}
const r = value as Record<string, unknown>
return (
@ -151,17 +184,20 @@ function loadSessionPreviewRegistry(): SessionPreviewRegistry {
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) {
@ -258,7 +294,6 @@ export function setSessionPreviewTarget(
const record = registerSessionPreview(sessionId, target, source, rawTarget)
setFilePreviewTarget(null)
setPreviewTarget(record?.normalized ?? previewTargetForSource(target, source))
return record
@ -288,12 +323,14 @@ export function dismissSessionPreview(sessionId: string | null | undefined, url?
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
@ -325,16 +362,58 @@ export function dismissPreviewTarget() {
}
$previewTarget.set(null)
if ($rightRailActiveTabId.get() === RIGHT_RAIL_PREVIEW_TAB_ID) {
selectRightRailTab($filePreviewTabs.get()[0]?.id ?? RIGHT_RAIL_PREVIEW_TAB_ID)
}
}
export function closeFilePreviewTab(tabId: RightRailTabId) {
if (!tabId.startsWith('file:')) {
return
}
const current = $filePreviewTabs.get()
const index = current.findIndex(tab => tab.id === tabId)
if (index === -1) {
return
}
const next = current.filter(tab => tab.id !== tabId)
$filePreviewTabs.set(next)
if ($rightRailActiveTabId.get() === tabId) {
selectRightRailTab(next[Math.min(index, next.length - 1)]?.id ?? RIGHT_RAIL_PREVIEW_TAB_ID)
}
}
export function dismissFilePreviewTarget() {
setFilePreviewTarget(null)
closeFilePreviewTab($rightRailActiveTabId.get())
}
export function closeActiveRightRailTab() {
const activeTabId = $rightRailActiveTabId.get()
if (activeTabId === RIGHT_RAIL_PREVIEW_TAB_ID) {
if ($previewTarget.get()) {
dismissPreviewTarget()
}
return
}
if (activeTabId.startsWith('file:')) {
closeFilePreviewTab(activeTabId)
}
}
export function clearSessionPreviewRegistry() {
$sessionPreviewRegistry.set({})
setPreviewTarget(null)
setFilePreviewTarget(null)
$filePreviewTabs.set([])
selectRightRailTab(RIGHT_RAIL_PREVIEW_TAB_ID)
}
export function requestPreviewReload() {

View file

@ -33,6 +33,7 @@ function loadToolDisclosureStates(): ToolDisclosureStates {
if (!raw) {
return {}
}
const parsed = JSON.parse(raw) as unknown
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
@ -67,6 +68,7 @@ export function setToolDisclosureOpen(id: string, open: boolean) {
if (!id) {
return
}
const current = $toolDisclosureStates.get()
if (current[id] === open) {

View file

@ -38,6 +38,7 @@ export function useSkinCommand() {
const normalized = arg.toLowerCase()
const targetName = ALIASES[normalized] || normalized
const target = availableThemes.find(
t => t.name.toLowerCase() === targetName || t.label.toLowerCase() === normalized
)

View file

@ -6,6 +6,16 @@ import path from 'path'
export default defineConfig({
base: './',
plugins: [react(), tailwindcss()],
build: {
rollupOptions: {
output: {
manualChunks: {
shiki: ['react-shiki', 'shiki'],
streamdown: ['@assistant-ui/react-streamdown', '@streamdown/code', 'streamdown']
}
}
}
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),