1056 lines
32 KiB
TypeScript
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} />
|
|
}
|