feat: file preview and folder tree etc

This commit is contained in:
Brooklyn Nicholson
2026-05-04 21:47:15 -05:00
parent 74127e0c48
commit 42db075e10
96 changed files with 7951 additions and 3276 deletions
@@ -0,0 +1,52 @@
import { type MutableRefObject, useCallback, useEffect } from 'react'
import { $currentCwd, setContextSuggestions } from '@/store/session'
import type { ContextSuggestion } from '../../types'
interface ContextSuggestionsOptions {
activeSessionId: string | null
activeSessionIdRef: MutableRefObject<string | null>
currentCwd: string
gatewayState: string | undefined
requestGateway: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
}
export function useContextSuggestions({
activeSessionId,
activeSessionIdRef,
currentCwd,
gatewayState,
requestGateway
}: ContextSuggestionsOptions) {
const refresh = useCallback(async () => {
if (!activeSessionId) {
setContextSuggestions([])
return
}
const sessionId = activeSessionId
const cwd = currentCwd || ''
// Race guard: only commit if the session+cwd we sent for still match
// by the time the gateway responds.
const stillCurrent = () => activeSessionIdRef.current === sessionId && $currentCwd.get() === cwd
try {
const result = await requestGateway<{ items?: ContextSuggestion[] }>('complete.path', {
session_id: sessionId,
word: '@file:',
cwd: cwd || undefined
})
if (stillCurrent()) {setContextSuggestions((result.items || []).filter(i => i.text))}
} catch {
if (stillCurrent()) {setContextSuggestions([])}
}
}, [activeSessionId, activeSessionIdRef, currentCwd, requestGateway])
useEffect(() => {
if (gatewayState === 'open' && activeSessionId) {void refresh()}
}, [activeSessionId, gatewayState, refresh])
}
@@ -0,0 +1,108 @@
import { type MutableRefObject, useCallback } from 'react'
import { notify, notifyError } from '@/store/notifications'
import { $currentCwd, setCurrentBranch, setCurrentCwd } from '@/store/session'
import type { SessionRuntimeInfo } from '@/types/hermes'
interface CwdActionsOptions {
activeSessionId: string | null
activeSessionIdRef: MutableRefObject<string | null>
currentCwd: string
requestGateway: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
}
export function useCwdActions({ activeSessionId, activeSessionIdRef, currentCwd, requestGateway }: CwdActionsOptions) {
const refreshProjectBranch = useCallback(
async (cwd: string) => {
const target = cwd.trim()
if (!target || activeSessionIdRef.current) {return}
try {
const info = await requestGateway<{ branch?: string; cwd?: string }>('config.get', { key: 'project', cwd: target })
if (!activeSessionIdRef.current && ($currentCwd.get() || target) === (info.cwd || target)) {
setCurrentBranch(info.branch || '')
}
} catch {
setCurrentBranch('')
}
},
[activeSessionIdRef, requestGateway]
)
const changeSessionCwd = useCallback(
async (cwd: string) => {
const trimmed = cwd.trim()
if (!trimmed) {return}
const persistGlobal = async () => {
const info = await requestGateway<{ branch?: string; cwd?: string; value?: string }>('config.set', {
...(activeSessionId && { session_id: activeSessionId }),
key: 'terminal.cwd',
value: trimmed
})
setCurrentCwd(info.cwd || info.value || trimmed)
if (!activeSessionId) {setCurrentBranch(info.branch || '')}
}
if (!activeSessionId) {
try {
await persistGlobal()
} catch (err) {
notifyError(err, 'Working directory change failed')
}
return
}
try {
const info = await requestGateway<SessionRuntimeInfo>('session.cwd.set', {
session_id: activeSessionId,
cwd: trimmed
})
setCurrentCwd(info.cwd || trimmed)
setCurrentBranch(info.branch || '')
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
// Older gateways without `session.cwd.set` fall back to a global write —
// user has to restart the active session for it to take effect.
if (!message.includes('unknown method')) {
notifyError(err, 'Working directory change failed')
return
}
try {
await persistGlobal()
notify({
kind: 'warning',
title: 'Working directory saved',
message: 'Restart the desktop backend to apply cwd changes to this active session.'
})
} catch (fallbackErr) {
notifyError(fallbackErr, 'Working directory change failed')
}
}
},
[activeSessionId, requestGateway]
)
const browseSessionCwd = useCallback(async () => {
const paths = await window.hermesDesktop?.selectPaths({
title: 'Change working directory',
defaultPath: currentCwd || undefined,
directories: true,
multiple: false
})
if (paths?.[0]) {await changeSessionCwd(paths[0])}
}, [changeSessionCwd, currentCwd])
return { browseSessionCwd, changeSessionCwd, refreshProjectBranch }
}
@@ -0,0 +1,74 @@
import { type MutableRefObject, useCallback, useState } from 'react'
import { getHermesConfig, getHermesConfigDefaults } from '@/hermes'
import { BUILTIN_PERSONALITIES, normalizePersonalityValue, personalityNamesFromConfig } from '@/lib/chat-runtime'
import {
$currentCwd,
setAvailablePersonalities,
setCurrentCwd,
setCurrentFastMode,
setCurrentPersonality,
setCurrentReasoningEffort,
setCurrentServiceTier,
setIntroPersonality
} from '@/store/session'
const DEFAULT_VOICE_SECONDS = 120
const FAST_TIERS = new Set(['fast', 'priority', 'on'])
function recordingLimit(value: unknown) {
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : DEFAULT_VOICE_SECONDS
}
interface HermesConfigOptions {
activeSessionIdRef: MutableRefObject<string | null>
refreshProjectBranch: (cwd: string) => Promise<void>
}
export function useHermesConfig({ activeSessionIdRef, refreshProjectBranch }: HermesConfigOptions) {
const [voiceMaxRecordingSeconds, setVoiceMaxRecordingSeconds] = useState(DEFAULT_VOICE_SECONDS)
const [sttEnabled, setSttEnabled] = useState(true)
const refreshHermesConfig = useCallback(async () => {
try {
const [config, defaults] = await Promise.all([getHermesConfig(), getHermesConfigDefaults().catch(() => ({}))])
const personality = normalizePersonalityValue(
typeof config.display?.personality === 'string' ? config.display.personality : ''
)
setIntroPersonality(personality)
// Active sessions keep their per-session value; standalone falls back to config.
setCurrentPersonality(prev => (activeSessionIdRef.current ? prev || personality : personality))
setAvailablePersonalities([
...new Set([
'none',
...BUILTIN_PERSONALITIES,
...personalityNamesFromConfig(defaults),
...personalityNamesFromConfig(config)
])
])
const cwd = (config.terminal?.cwd ?? '').trim()
if (cwd && cwd !== '.') {
setCurrentCwd(prev => prev || cwd)
void refreshProjectBranch($currentCwd.get() || cwd)
}
const reasoning = (config.agent?.reasoning_effort ?? '').trim()
const tier = (config.agent?.service_tier ?? '').trim()
setCurrentReasoningEffort(prev => (activeSessionIdRef.current ? prev : reasoning))
setCurrentServiceTier(prev => (activeSessionIdRef.current ? prev : tier))
setCurrentFastMode(prev => (activeSessionIdRef.current ? prev : FAST_TIERS.has(tier.toLowerCase())))
setVoiceMaxRecordingSeconds(recordingLimit(config.voice?.max_recording_seconds))
setSttEnabled(config.stt?.enabled !== false)
} catch {
// Config is nice-to-have; chat still works without it.
}
}, [activeSessionIdRef, refreshProjectBranch])
return { refreshHermesConfig, sttEnabled, voiceMaxRecordingSeconds }
}
@@ -0,0 +1,79 @@
import { type QueryClient } from '@tanstack/react-query'
import { useCallback } from 'react'
import { getGlobalModelInfo, setGlobalModel } from '@/hermes'
import { notifyError } from '@/store/notifications'
import { setCurrentModel, setCurrentProvider } from '@/store/session'
import type { ModelOptionsResponse } from '@/types/hermes'
interface ModelSelection {
model: string
persistGlobal: boolean
provider: string
}
interface ModelControlsOptions {
activeSessionId: string | null
queryClient: QueryClient
requestGateway: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
}
export function useModelControls({ activeSessionId, queryClient, requestGateway }: ModelControlsOptions) {
const updateModelOptionsCache = useCallback(
(provider: string, model: string, includeGlobal: boolean) => {
const patch = (prev: ModelOptionsResponse | undefined) => ({ ...(prev ?? {}), provider, model })
queryClient.setQueryData<ModelOptionsResponse>(['model-options', activeSessionId || 'global'], patch)
if (includeGlobal) {queryClient.setQueryData<ModelOptionsResponse>(['model-options', 'global'], patch)}
},
[activeSessionId, queryClient]
)
const refreshCurrentModel = useCallback(async () => {
try {
const result = await getGlobalModelInfo()
if (typeof result.model === 'string') {setCurrentModel(result.model)}
if (typeof result.provider === 'string') {setCurrentProvider(result.provider)}
} catch {
// The delayed session.info event still updates this once the agent is ready.
}
}, [])
const selectModel = useCallback(
(selection: ModelSelection) => {
setCurrentModel(selection.model)
setCurrentProvider(selection.provider)
updateModelOptionsCache(selection.provider, selection.model, selection.persistGlobal || !activeSessionId)
void (async () => {
try {
if (activeSessionId) {
await requestGateway('slash.exec', {
session_id: activeSessionId,
command: `/model ${selection.model} --provider ${selection.provider}${selection.persistGlobal ? ' --global' : ''}`
})
if (selection.persistGlobal) {void refreshCurrentModel()}
void queryClient.invalidateQueries({
queryKey: selection.persistGlobal ? ['model-options'] : ['model-options', activeSessionId]
})
return
}
await setGlobalModel(selection.provider, selection.model)
void refreshCurrentModel()
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
} catch (err) {
notifyError(err, 'Model switch failed')
}
})()
},
[activeSessionId, queryClient, refreshCurrentModel, requestGateway, updateModelOptionsCache]
)
return { refreshCurrentModel, selectModel, updateModelOptionsCache }
}
@@ -0,0 +1,144 @@
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)
window.localStorage.clear()
clearSessionPreviewRegistry()
handleEvent = () => undefined
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: {
normalizePreviewTarget: vi.fn(async (target: string) => previewTarget(target))
}
})
})
afterEach(() => {
cleanup()
$messages.set([])
$previewTarget.set(null)
window.localStorage.clear()
clearSessionPreviewRegistry()
vi.restoreAllMocks()
})
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('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 () => {
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'
})
)
await waitFor(() => {
expect($previewTarget.get()?.source).toBe('preview-demo.html')
})
})
})
@@ -0,0 +1,195 @@
import { useStore } from '@nanostores/react'
import { type MutableRefObject, useCallback, useEffect } from 'react'
import { gatewayEventCompletedFileDiff } from '@/lib/gateway-events'
import {
$previewTarget,
$sessionPreviewRegistry,
beginPreviewServerRestart,
completePreviewServerRestart,
getSessionPreviewRecord,
progressPreviewServerRestart,
requestPreviewReload,
setPreviewTarget,
setSessionPreviewTarget
} from '@/store/preview'
import { $currentCwd } from '@/store/session'
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 || ''
}
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,
currentCwd,
currentView,
requestGateway,
routedSessionId,
selectedStoredSessionId
}: PreviewRoutingOptions) {
const previewRegistry = useStore($sessionPreviewRegistry)
const previewSessionId = activePreviewSessionId(activeSessionIdRef, routedSessionId, selectedStoredSessionId)
useEffect(() => {
if (currentView !== 'chat' || !previewSessionId) {
setPreviewTarget(null)
return
}
const record = getSessionPreviewRecord(previewSessionId)
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
if (!sessionId) {throw new Error('No active session for background restart')}
const cwd = $currentCwd.get() || currentCwd || ''
const result = await requestGateway<{ task_id?: string }>('preview.restart', {
context: context || undefined,
cwd: cwd || undefined,
session_id: sessionId,
url
})
const taskId = result.task_id || ''
if (!taskId) {throw new Error('Background restart did not return a task id')}
beginPreviewServerRestart(taskId, url)
return taskId
},
[activeSessionIdRef, currentCwd, requestGateway]
)
const handleDesktopGatewayEvent = useCallback<EventHandler>(
event => {
baseHandleGatewayEvent(event)
if (event.type === 'preview.restart.complete') {
const { task_id, text } = asRecord(event.payload)
if (typeof task_id === 'string' && task_id) {completePreviewServerRestart(task_id, typeof text === 'string' ? text : '')}
} else if (event.type === 'preview.restart.progress') {
const { task_id, text } = asRecord(event.payload)
if (typeof task_id === 'string' && task_id) {progressPreviewServerRestart(task_id, typeof text === 'string' ? text : '')}
}
if (event.session_id && event.session_id !== activeSessionIdRef.current) {return}
void registerStructuredPreview(event)
if ($previewTarget.get()?.kind === 'url' && gatewayEventCompletedFileDiff(event)) {requestPreviewReload()}
},
[activeSessionIdRef, baseHandleGatewayEvent, registerStructuredPreview]
)
return { handleDesktopGatewayEvent, restartPreviewServer }
}
@@ -188,7 +188,7 @@ export function usePromptActions({
.join('\n')
const hasImageAttachment = attachments.some(attachment => attachment.kind === 'image')
const displayRefs = attachments.map(attachmentDisplayText).filter(Boolean).join('\n')
const attachmentRefs = attachments.map(attachmentDisplayText).filter((ref): ref is string => Boolean(ref))
const text =
[contextRefs, visibleText].filter(Boolean).join('\n\n') ||
@@ -201,12 +201,8 @@ export function usePromptActions({
const userMessage: ChatMessage = {
id: `user-${Date.now()}`,
role: 'user',
parts: [
textPart(
[displayRefs, visibleText].filter(Boolean).join('\n\n') ||
attachments.map(attachment => attachment.label).join(', ')
)
]
parts: [textPart(visibleText || (attachmentRefs.length ? '' : attachments.map(a => a.label).join(', ')))],
attachmentRefs
}
const releaseBusy = () => {
@@ -0,0 +1,87 @@
import { type MutableRefObject, useEffect } from 'react'
import { isNewChatRoute } from '@/app/routes'
interface RouteResumeOptions {
activeSessionId: string | null
activeSessionIdRef: MutableRefObject<string | null>
creatingSessionRef: MutableRefObject<boolean>
currentView: string
freshDraftReady: boolean
gatewayState: string | undefined
locationPathname: string
resumeSession: (sessionId: string, focus: boolean) => Promise<unknown>
routedSessionId: string | null
runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>>
selectedStoredSessionId: string | null
selectedStoredSessionIdRef: MutableRefObject<string | null>
startFreshSessionDraft: (focus: boolean) => unknown
}
// HashRouter boot edge case: pathname briefly reads `/` before the hash is
// parsed. If the hash references a real session, defer; resume picks it up
// next tick. Without this, ctrl+R on `#/:sessionId` flashes 5 loading states.
function rawHashLooksLikeSession(): boolean {
if (typeof window === 'undefined') {return false}
const hash = window.location.hash.replace(/^#/, '')
if (!hash || hash === '/') {return false}
return !hash.startsWith('/settings') && !hash.startsWith('/skills') && !hash.startsWith('/artifacts')
}
export function useRouteResume({
activeSessionId,
activeSessionIdRef,
creatingSessionRef,
currentView,
freshDraftReady,
gatewayState,
locationPathname,
resumeSession,
routedSessionId,
runtimeIdByStoredSessionIdRef,
selectedStoredSessionId,
selectedStoredSessionIdRef,
startFreshSessionDraft
}: RouteResumeOptions) {
useEffect(() => {
if (currentView !== 'chat' || gatewayState !== 'open') {return}
if (routedSessionId) {
const cachedRuntime = runtimeIdByStoredSessionIdRef.current.get(routedSessionId)
const alreadyActive =
routedSessionId === selectedStoredSessionIdRef.current &&
Boolean(cachedRuntime) &&
cachedRuntime === activeSessionIdRef.current
if (!alreadyActive) {void resumeSession(routedSessionId, true)}
return
}
if (
isNewChatRoute(locationPathname) &&
!creatingSessionRef.current &&
(selectedStoredSessionId || activeSessionId || !freshDraftReady) &&
!rawHashLooksLikeSession()
) {
startFreshSessionDraft(true)
}
}, [
activeSessionId,
activeSessionIdRef,
creatingSessionRef,
currentView,
freshDraftReady,
gatewayState,
locationPathname,
resumeSession,
routedSessionId,
runtimeIdByStoredSessionIdRef,
selectedStoredSessionId,
selectedStoredSessionIdRef,
startFreshSessionDraft
])
}
@@ -28,8 +28,8 @@ import {
setIntroSeed,
setMessages,
setSelectedStoredSessionId,
setSessionStartedAt,
setSessions,
setSessionStartedAt,
setTurnStartedAt
} from '@/store/session'
import type { SessionCreateResponse, SessionInfo, SessionResumeResponse, UsageStats } from '@/types/hermes'