Files
hermes-agent/apps/desktop/src/app/desktop-controller.tsx
T

1056 lines
32 KiB
TypeScript

import { useStore } from '@nanostores/react'
import { useQueryClient } from '@tanstack/react-query'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from 'react-router-dom'
import type { ModelOptionsResponse, SessionRuntimeInfo } from '@/types/hermes'
import { formatRefValue } from '../components/assistant-ui/directive-text'
import {
getGlobalModelInfo,
getHermesConfig,
getHermesConfigDefaults,
getSessionMessages,
type HermesGateway,
listSessions,
setGlobalModel
} from '../hermes'
import { chatMessageText, toChatMessages } from '../lib/chat-messages'
import { BUILTIN_PERSONALITIES, normalizePersonalityValue, personalityNamesFromConfig } from '../lib/chat-runtime'
import { extractPreviewCandidates } from '../lib/preview-targets'
import { $pinnedSessionIds, pinSession, unpinSession } from '../store/layout'
import { notify, notifyError } from '../store/notifications'
import {
$previewTarget,
beginPreviewServerRestart,
completePreviewServerRestart,
progressPreviewServerRestart,
requestPreviewReload,
setPreviewTarget
} from '../store/preview'
import {
$activeSessionId,
$currentCwd,
$currentFastMode,
$currentReasoningEffort,
$currentServiceTier,
$freshDraftReady,
$gatewayState,
$messages,
$selectedStoredSessionId,
setAvailablePersonalities,
setAwaitingResponse,
setBusy,
setContextSuggestions,
setCurrentBranch,
setCurrentCwd,
setCurrentFastMode,
setCurrentModel,
setCurrentPersonality,
setCurrentProvider,
setCurrentReasoningEffort,
setCurrentServiceTier,
setIntroPersonality,
setMessages,
setModelPickerOpen,
setSessions,
setSessionsLoading
} from '../store/session'
import { useTheme } from '../themes/context'
import { ArtifactsView } from './artifacts'
import { ChatView, PREVIEW_RAIL_WIDTH, SESSION_INSPECTOR_WIDTH } from './chat'
import { useComposerActions } from './chat/hooks/use-composer-actions'
import { ChatSidebar } from './chat/sidebar'
import { CommandCenterView } from './command-center'
import { useGatewayBoot } from './gateway/hooks/use-gateway-boot'
import { useGatewayRequest } from './gateway/hooks/use-gateway-request'
import { ModelPickerOverlay } from './model-picker-overlay'
import {
appViewForPath,
COMMAND_CENTER_ROUTE,
isNewChatRoute,
NEW_CHAT_ROUTE,
routeSessionId,
sessionRoute
} from './routes'
import { useMessageStream } from './session/hooks/use-message-stream'
import { usePromptActions } from './session/hooks/use-prompt-actions'
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 type { SetTitlebarToolGroup, TitlebarTool, TitlebarToolSide } from './shell/titlebar-controls'
import { SkillsView } from './skills'
import type { ContextSuggestion } from './types'
const DEFAULT_VOICE_RECORDING_SECONDS = 120
function normalizeRecordingLimit(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : DEFAULT_VOICE_RECORDING_SECONDS
}
function gatewayEventPreviewText(event: { payload?: unknown; type?: string }): string {
const payload = event.payload && typeof event.payload === 'object' ? (event.payload as Record<string, unknown>) : {}
const fields = event.type?.startsWith('message.') ? ['text', 'rendered', 'preview'] : ['preview']
return fields
.map(key => payload[key])
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
.join('\n')
}
function gatewayEventCompletedFileDiff(event: { payload?: unknown; type?: string }): boolean {
if (event.type !== 'tool.complete' || !event.payload || typeof event.payload !== 'object') {
return false
}
const inlineDiff = (event.payload as Record<string, unknown>).inline_diff
return typeof inlineDiff === 'string' && inlineDiff.trim().length > 0
}
export function DesktopController() {
const queryClient = useQueryClient()
const location = useLocation()
const navigate = useNavigate()
const busyRef = useRef(false)
const creatingSessionRef = useRef(false)
const gatewayState = useStore($gatewayState)
const { availableThemes, setTheme, themeName } = useTheme()
const activeSessionId = useStore($activeSessionId)
const messages = useStore($messages)
const selectedStoredSessionId = useStore($selectedStoredSessionId)
const currentCwd = useStore($currentCwd)
const freshDraftReady = useStore($freshDraftReady)
const routedSessionId = routeSessionId(location.pathname)
const currentView = appViewForPath(location.pathname)
const routeToken = `${currentView}:${routedSessionId || ''}:${location.pathname}:${location.search}:${location.hash}`
const routeTokenRef = useRef(routeToken)
routeTokenRef.current = routeToken
const getRouteToken = useCallback(() => routeTokenRef.current, [])
const settingsOpen = currentView === 'settings'
const commandCenterOpen = currentView === 'command-center'
const chatOpen = currentView === 'chat'
const overlayReturnPathRef = useRef(NEW_CHAT_ROUTE)
const refreshSessionsRequestRef = useRef(0)
const [titlebarToolGroups, setTitlebarToolGroups] = useState<
Record<TitlebarToolSide, Record<string, readonly TitlebarTool[]>>
>({ left: {}, right: {} })
const [voiceMaxRecordingSeconds, setVoiceMaxRecordingSeconds] = useState(DEFAULT_VOICE_RECORDING_SECONDS)
const [sttEnabled, setSttEnabled] = useState(true)
const {
activeSessionIdRef,
ensureSessionState,
runtimeIdByStoredSessionIdRef,
selectedStoredSessionIdRef,
sessionStateByRuntimeIdRef,
syncSessionStateToView,
updateSessionState
} = useSessionStateCache({
activeSessionId,
busyRef,
selectedStoredSessionId,
setAwaitingResponse,
setBusy,
setMessages
})
const setTitlebarToolGroup = useCallback<SetTitlebarToolGroup>((id, tools, side = 'right') => {
setTitlebarToolGroups(current => {
const next = { ...current, [side]: { ...current[side] } }
if (tools.length === 0) {
delete next[side][id]
} else {
next[side][id] = tools
}
return next
})
}, [])
const leftTitlebarTools = useMemo(() => Object.values(titlebarToolGroups.left).flat(), [titlebarToolGroups.left])
const titlebarTools = useMemo(() => Object.values(titlebarToolGroups.right).flat(), [titlebarToolGroups.right])
const toggleSelectedPin = useCallback(() => {
const sessionId = $selectedStoredSessionId.get()
if (!sessionId) {
return
}
if ($pinnedSessionIds.get().includes(sessionId)) {
unpinSession(sessionId)
} else {
pinSession(sessionId)
}
}, [])
const refreshSessions = useCallback(async () => {
const requestId = refreshSessionsRequestRef.current + 1
refreshSessionsRequestRef.current = requestId
setSessionsLoading(true)
try {
const result = await listSessions(50)
if (refreshSessionsRequestRef.current === requestId) {
setSessions(result.sessions)
}
} finally {
if (refreshSessionsRequestRef.current === requestId) {
setSessionsLoading(false)
}
}
}, [])
const { connectionRef, gatewayRef, requestGateway } = useGatewayRequest()
const setBootGateway = useCallback(
(gateway: HermesGateway | null) => {
gatewayRef.current = gateway
},
[gatewayRef]
)
const setBootConnection = useCallback(
(connection: Awaited<ReturnType<NonNullable<typeof window.hermesDesktop>['getConnection']>> | null) => {
connectionRef.current = connection
},
[connectionRef]
)
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 refreshContextSuggestions = useCallback(async () => {
if (!activeSessionId) {
setContextSuggestions([])
return
}
const sessionId = activeSessionId
const cwd = currentCwd || ''
try {
const result = await requestGateway<{ items?: ContextSuggestion[] }>('complete.path', {
session_id: sessionId,
word: '@file:',
cwd: cwd || undefined
})
if (activeSessionIdRef.current === sessionId && $currentCwd.get() === cwd) {
setContextSuggestions((result.items || []).filter(item => item.text))
}
} catch {
if (activeSessionIdRef.current === sessionId && $currentCwd.get() === cwd) {
setContextSuggestions([])
}
}
}, [activeSessionId, activeSessionIdRef, currentCwd, requestGateway])
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 can still update this once the agent is ready.
}
}, [])
const refreshProjectBranch = useCallback(
async (cwd: string) => {
const targetCwd = cwd.trim()
if (!targetCwd || activeSessionIdRef.current) {
return
}
try {
const info = await requestGateway<{ branch?: string; cwd?: string }>('config.get', {
key: 'project',
cwd: targetCwd
})
if (!activeSessionIdRef.current && ($currentCwd.get() || targetCwd) === (info.cwd || targetCwd)) {
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
})
const nextCwd = info.cwd || info.value || trimmed
setCurrentCwd(nextCwd)
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)
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])
const selectModel = useCallback(
(selection: { provider: string; model: string; persistGlobal: boolean }) => {
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]
)
const refreshHermesConfig = useCallback(async () => {
try {
const [config, defaults] = await Promise.all([getHermesConfig(), getHermesConfigDefaults().catch(() => ({}))])
const configPersonality = normalizePersonalityValue(
typeof config.display?.personality === 'string' ? config.display.personality : ''
)
setIntroPersonality(configPersonality)
setCurrentPersonality(prev => (activeSessionIdRef.current ? prev || configPersonality : configPersonality))
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 reasoningEffort = (config.agent?.reasoning_effort ?? '').trim()
const serviceTier = (config.agent?.service_tier ?? '').trim()
setCurrentReasoningEffort(prev => (activeSessionIdRef.current ? prev : reasoningEffort))
setCurrentServiceTier(prev => (activeSessionIdRef.current ? prev : serviceTier))
setCurrentFastMode(prev =>
activeSessionIdRef.current ? prev : ['fast', 'priority', 'on'].includes(serviceTier.toLowerCase())
)
setVoiceMaxRecordingSeconds(normalizeRecordingLimit(config.voice?.max_recording_seconds))
setSttEnabled(config.stt?.enabled !== false)
} catch {
// Config is nice-to-have for the empty-state copy; the chat still works.
}
}, [activeSessionIdRef, refreshProjectBranch])
const selectPersonality = useCallback(
async (name: string) => {
const trimmed = (name || '').trim() || 'none'
const normalized = normalizePersonalityValue(trimmed)
setCurrentPersonality(normalized)
if (!activeSessionId) {
setIntroPersonality(normalized)
}
try {
await (activeSessionId
? requestGateway('slash.exec', {
session_id: activeSessionId,
command: `/personality ${trimmed}`
})
: requestGateway('config.set', {
key: 'personality',
value: trimmed
}))
if (!activeSessionId) {
void refreshHermesConfig()
}
} catch (err) {
void refreshHermesConfig()
notifyError(err, 'Personality change failed')
}
},
[activeSessionId, refreshHermesConfig, requestGateway]
)
const setReasoningEffort = useCallback(
async (effort: string) => {
const value = effort.trim().toLowerCase()
const previous = $currentReasoningEffort.get()
setCurrentReasoningEffort(value)
try {
await requestGateway('config.set', {
...(activeSessionId && { session_id: activeSessionId }),
key: 'reasoning',
value
})
} catch (err) {
setCurrentReasoningEffort(previous)
void refreshHermesConfig()
notifyError(err, 'Reasoning change failed')
}
},
[activeSessionId, refreshHermesConfig, requestGateway]
)
const setFastMode = useCallback(
async (enabled: boolean) => {
const previousFast = $currentFastMode.get()
const previousTier = $currentServiceTier.get()
setCurrentFastMode(enabled)
setCurrentServiceTier(enabled ? 'priority' : '')
try {
await requestGateway('config.set', {
...(activeSessionId && { session_id: activeSessionId }),
key: 'fast',
value: enabled ? 'fast' : 'normal'
})
} catch (err) {
setCurrentFastMode(previousFast)
setCurrentServiceTier(previousTier)
void refreshHermesConfig()
notifyError(err, 'Fast mode change failed')
}
},
[activeSessionId, refreshHermesConfig, requestGateway]
)
const hydrateFromStoredSession = useCallback(
async (
attempts = 1,
storedSessionId = selectedStoredSessionIdRef.current,
runtimeSessionId = activeSessionIdRef.current
) => {
if (!storedSessionId || !runtimeSessionId) {
return
}
for (let index = 0; index < Math.max(1, attempts); index += 1) {
try {
const latest = await getSessionMessages(storedSessionId)
updateSessionState(
runtimeSessionId,
state => ({
...state,
messages: toChatMessages(latest.messages)
}),
storedSessionId
)
return
} catch {
// Best-effort fallback when live stream payloads are empty.
}
if (index < attempts - 1) {
await new Promise(resolve => window.setTimeout(resolve, 250))
}
}
},
[activeSessionIdRef, selectedStoredSessionIdRef, updateSessionState]
)
const { handleGatewayEvent } = useMessageStream({
activeSessionIdRef,
hydrateFromStoredSession,
queryClient,
refreshHermesConfig,
refreshSessions,
updateSessionState
})
const lastPreviewUrlRef = useRef<string>('')
const openDetectedPreview = useCallback(
async (text: string) => {
const desktop = window.hermesDesktop
const routeKey = lastPreviewRouteRef.current
const sessionId = activeSessionIdRef.current
const cwd = currentCwd || ''
if (!desktop?.normalizePreviewTarget) {
return
}
for (const candidate of extractPreviewCandidates(text)) {
const target = await desktop.normalizePreviewTarget(candidate, cwd || undefined).catch(() => null)
if (
lastPreviewRouteRef.current !== routeKey ||
activeSessionIdRef.current !== sessionId ||
$currentCwd.get() !== cwd
) {
return
}
if (!target || target.url === lastPreviewUrlRef.current) {
continue
}
lastPreviewUrlRef.current = target.url
setPreviewTarget(target)
return
}
},
[activeSessionIdRef, currentCwd]
)
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(
(event: Parameters<typeof handleGatewayEvent>[0]) => {
handleGatewayEvent(event)
if (event.type === 'preview.restart.complete') {
const payload =
event.payload && typeof event.payload === 'object' ? (event.payload as Record<string, unknown>) : {}
const taskId = typeof payload.task_id === 'string' ? payload.task_id : ''
if (taskId) {
completePreviewServerRestart(taskId, typeof payload.text === 'string' ? payload.text : '')
}
}
if (event.type === 'preview.restart.progress') {
const payload =
event.payload && typeof event.payload === 'object' ? (event.payload as Record<string, unknown>) : {}
const taskId = typeof payload.task_id === 'string' ? payload.task_id : ''
if (taskId) {
progressPreviewServerRestart(taskId, typeof payload.text === 'string' ? payload.text : '')
}
}
if (event.session_id && event.session_id !== activeSessionIdRef.current) {
return
}
const previewText = gatewayEventPreviewText(event)
if (previewText) {
void openDetectedPreview(previewText)
}
if ($previewTarget.get()?.kind === 'url' && gatewayEventCompletedFileDiff(event)) {
requestPreviewReload()
}
},
[activeSessionIdRef, handleGatewayEvent, openDetectedPreview]
)
useEffect(() => {
const latestAssistant = [...messages].reverse().find(message => message.role === 'assistant' && !message.pending)
const text = latestAssistant ? chatMessageText(latestAssistant) : ''
if (text) {
void openDetectedPreview(text)
}
}, [messages, openDetectedPreview])
const {
branchCurrentSession,
createBackendSessionForSend,
openSettings,
removeSession,
resumeSession,
selectSidebarItem,
startFreshSessionDraft
} = useSessionActions({
activeSessionId,
activeSessionIdRef,
busyRef,
creatingSessionRef,
ensureSessionState,
getRouteToken,
navigate,
requestGateway,
runtimeIdByStoredSessionIdRef,
selectedStoredSessionId,
selectedStoredSessionIdRef,
sessionStateByRuntimeIdRef,
syncSessionStateToView,
updateSessionState
})
const {
addContextRefAttachment,
attachDroppedItems,
attachImageBlob,
pasteClipboardImage,
pickContextPaths,
pickImages,
removeAttachment
} = useComposerActions({
activeSessionId,
currentCwd,
requestGateway
})
useEffect(() => {
if (currentView !== 'settings' && currentView !== 'command-center') {
overlayReturnPathRef.current = `${location.pathname}${location.search}${location.hash}`
}
}, [currentView, location.hash, location.pathname, location.search])
const previewRouteKey = `${currentView}:${routedSessionId || ''}:${selectedStoredSessionId || ''}`
const lastPreviewRouteRef = useRef(previewRouteKey)
useEffect(() => {
if (lastPreviewRouteRef.current !== previewRouteKey) {
lastPreviewRouteRef.current = previewRouteKey
lastPreviewUrlRef.current = ''
setPreviewTarget(null)
}
}, [previewRouteKey])
const closeOverlayToPreviousRoute = useCallback(() => {
navigate(overlayReturnPathRef.current || NEW_CHAT_ROUTE, { replace: true })
}, [navigate])
const toggleCommandCenter = useCallback(() => {
if (commandCenterOpen) {
closeOverlayToPreviousRoute()
return
}
navigate(COMMAND_CENTER_ROUTE)
}, [closeOverlayToPreviousRoute, commandCenterOpen, navigate])
const branchInNewChat = useCallback(
async (messageId?: string) => {
const branched = await branchCurrentSession(messageId)
if (branched) {
await refreshSessions().catch(() => undefined)
}
return branched
},
[branchCurrentSession, refreshSessions]
)
const handleSkinCommand = useCallback(
(rawArg: string) => {
const arg = rawArg.trim()
const names = availableThemes.map(theme => theme.name)
if (!availableThemes.length) {
return 'No desktop themes are available.'
}
const activeIndex = Math.max(
0,
availableThemes.findIndex(theme => theme.name === themeName)
)
if (!arg || arg === 'next') {
const next = availableThemes[(activeIndex + 1) % availableThemes.length]
setTheme(next.name)
return `Desktop theme switched to ${next.label}.`
}
if (arg === 'list' || arg === 'ls' || arg === 'status') {
const rows = availableThemes.map(theme => {
const marker = theme.name === themeName ? '*' : ' '
return `${marker} ${theme.name.padEnd(10)} ${theme.label}`
})
return [`Desktop themes:`, ...rows, '', 'Use /skin <name>, or /skin to cycle.'].join('\n')
}
const normalized = arg.toLowerCase()
const aliases: Record<string, string> = {
ares: 'ember',
hermes: 'default'
}
const targetName = aliases[normalized] || normalized
const target = availableThemes.find(
theme => theme.name.toLowerCase() === targetName || theme.label.toLowerCase() === normalized
)
if (!target) {
return `Unknown desktop theme: ${arg}\nAvailable: ${names.join(', ')}`
}
setTheme(target.name)
return `Desktop theme switched to ${target.label}.`
},
[availableThemes, setTheme, themeName]
)
const { cancelRun, editMessage, handleThreadMessagesChange, reloadFromMessage, submitText, transcribeVoiceAudio } =
usePromptActions({
activeSessionId,
activeSessionIdRef,
branchCurrentSession: branchInNewChat,
busyRef,
createBackendSessionForSend,
handleSkinCommand,
requestGateway,
selectedStoredSessionIdRef,
startFreshSessionDraft,
sttEnabled,
updateSessionState
})
useGatewayBoot({
handleGatewayEvent: handleDesktopGatewayEvent,
onConnectionReady: setBootConnection,
onGatewayReady: setBootGateway,
refreshHermesConfig,
refreshSessions
})
useEffect(() => {
if (gatewayState === 'open') {
void refreshCurrentModel()
void refreshSessions().catch(() => undefined)
}
}, [gatewayState, refreshCurrentModel, refreshSessions])
useEffect(() => {
if (gatewayState === 'open' && activeSessionId) {
void refreshContextSuggestions()
}
}, [activeSessionId, gatewayState, refreshContextSuggestions])
useEffect(() => {
if (currentView !== 'chat' || gatewayState !== 'open') {
return
}
if (routedSessionId) {
const cachedRuntimeId = runtimeIdByStoredSessionIdRef.current.get(routedSessionId)
const alreadyActive =
routedSessionId === selectedStoredSessionIdRef.current &&
Boolean(cachedRuntimeId) &&
cachedRuntimeId === activeSessionIdRef.current
if (!alreadyActive) {
void resumeSession(routedSessionId, true)
}
} else if (
isNewChatRoute(location.pathname) &&
!creatingSessionRef.current &&
(selectedStoredSessionId || activeSessionId || !freshDraftReady)
) {
// Guard: during HashRouter boot the `location.pathname` can read `/`
// briefly before the hash-portion (which holds the real route) is
// parsed. If the window hash clearly references a session, defer —
// `routedSessionId` will update in a tick and the routedSessionId
// branch above will handle resume. Without this guard, a ctrl+R on
// `#/:sessionId` calls startFreshSessionDraft → navigates to `/` →
// wipes messages → races the real resume, producing the visible
// "5 loading states" flash chain.
if (typeof window !== 'undefined') {
const rawHash = window.location.hash.replace(/^#/, '')
if (
rawHash &&
rawHash !== '/' &&
!rawHash.startsWith('/settings') &&
!rawHash.startsWith('/skills') &&
!rawHash.startsWith('/artifacts')
) {
return
}
}
startFreshSessionDraft(true)
}
}, [
activeSessionIdRef,
activeSessionId,
currentView,
freshDraftReady,
gatewayState,
location.pathname,
resumeSession,
routedSessionId,
runtimeIdByStoredSessionIdRef,
selectedStoredSessionId,
selectedStoredSessionIdRef,
startFreshSessionDraft
])
const sidebar = (
<ChatSidebar
currentView={currentView}
onDeleteSession={sessionId => void removeSession(sessionId)}
onNavigate={selectSidebarItem}
onRefreshSessions={() => void refreshSessions()}
onResumeSession={sessionId => navigate(sessionRoute(sessionId))}
/>
)
const overlays = (
<>
<ModelPickerOverlay gateway={gatewayRef.current || undefined} onSelect={selectModel} />
{settingsOpen && (
<SettingsView
onClose={closeOverlayToPreviousRoute}
onConfigSaved={() => {
void refreshHermesConfig()
void refreshCurrentModel()
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
}}
/>
)}
{commandCenterOpen && (
<CommandCenterView
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))}
/>
)}
</>
)
const chatView = (
<ChatView
gateway={gatewayRef.current}
maxVoiceRecordingSeconds={voiceMaxRecordingSeconds}
onAddContextRef={addContextRefAttachment}
onAddUrl={url => addContextRefAttachment(`@url:${formatRefValue(url)}`, url)}
onAttachDroppedItems={attachDroppedItems}
onAttachImageBlob={attachImageBlob}
onBranchInNewChat={messageId => void branchInNewChat(messageId)}
onBrowseCwd={() => void browseSessionCwd()}
onCancel={() => void cancelRun()}
onChangeCwd={cwd => void changeSessionCwd(cwd)}
onDeleteSelectedSession={() => {
if (selectedStoredSessionId) {
void removeSession(selectedStoredSessionId)
}
}}
onEdit={editMessage}
onOpenModelPicker={() => setModelPickerOpen(true)}
onPasteClipboardImage={() => void pasteClipboardImage()}
onPickFiles={() => void pickContextPaths('file')}
onPickFolders={() => void pickContextPaths('folder')}
onPickImages={() => void pickImages()}
onReload={reloadFromMessage}
onRemoveAttachment={id => void removeAttachment(id)}
onRestartPreviewServer={restartPreviewServer}
onSelectPersonality={name => void selectPersonality(name)}
onSetFastMode={enabled => void setFastMode(enabled)}
onSetReasoningEffort={effort => void setReasoningEffort(effort)}
onSubmit={submitText}
onThreadMessagesChange={handleThreadMessagesChange}
onToggleSelectedPin={toggleSelectedPin}
onTranscribeAudio={transcribeVoiceAudio}
setTitlebarToolGroup={setTitlebarToolGroup}
/>
)
return (
<AppShell
commandCenterOpen={commandCenterOpen}
inspectorWidth={SESSION_INSPECTOR_WIDTH}
leftTitlebarTools={leftTitlebarTools}
onOpenSettings={openSettings}
onToggleCommandCenter={toggleCommandCenter}
overlays={overlays}
previewWidth={PREVIEW_RAIL_WIDTH}
rightRailOpen={chatOpen}
sidebar={sidebar}
titlebarTools={titlebarTools}
>
<Routes>
<Route element={chatView} index />
<Route element={chatView} path=":sessionId" />
<Route element={<SkillsView setTitlebarToolGroup={setTitlebarToolGroup} />} path="skills" />
<Route element={<ArtifactsView setTitlebarToolGroup={setTitlebarToolGroup} />} path="artifacts" />
<Route element={null} path="settings" />
<Route element={null} path="command-center" />
<Route element={<Navigate replace to={NEW_CHAT_ROUTE} />} path="new" />
<Route element={<LegacySessionRedirect />} path="sessions/:sessionId" />
<Route element={<Navigate replace to={NEW_CHAT_ROUTE} />} path="*" />
</Routes>
</AppShell>
)
}
function LegacySessionRedirect() {
const { sessionId } = useParams()
return <Navigate replace to={sessionId ? sessionRoute(sessionId) : NEW_CHAT_ROUTE} />
}