Onboarding overlay subsumes the boot card now that it mounts from frame 1 and renders boot progress inline. The standalone DesktopBootOverlay is unreachable in every flow (yields whenever onboarding has not confirmed configured, dismisses once it has).
562 lines
17 KiB
TypeScript
562 lines
17 KiB
TypeScript
import { useStore } from '@nanostores/react'
|
|
import { useQueryClient } from '@tanstack/react-query'
|
|
import { lazy, Suspense, useCallback, useEffect, useRef } from 'react'
|
|
import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from 'react-router-dom'
|
|
|
|
import { DesktopOnboardingOverlay } from '@/components/desktop-onboarding-overlay'
|
|
import { Pane, PaneMain } from '@/components/pane-shell'
|
|
import { useSkinCommand } from '@/themes/use-skin-command'
|
|
|
|
import { formatRefValue } from '../components/assistant-ui/directive-text'
|
|
import { getSessionMessages, listSessions } from '../hermes'
|
|
import { toChatMessages } from '../lib/chat-messages'
|
|
import {
|
|
$pinnedSessionIds,
|
|
FILE_BROWSER_DEFAULT_WIDTH,
|
|
FILE_BROWSER_MAX_WIDTH,
|
|
FILE_BROWSER_MIN_WIDTH,
|
|
pinSession,
|
|
SIDEBAR_DEFAULT_WIDTH,
|
|
SIDEBAR_MAX_WIDTH,
|
|
unpinSession
|
|
} from '../store/layout'
|
|
import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '../store/preview'
|
|
import {
|
|
$activeSessionId,
|
|
$currentCwd,
|
|
$freshDraftReady,
|
|
$gatewayState,
|
|
$selectedStoredSessionId,
|
|
setAwaitingResponse,
|
|
setBusy,
|
|
setCurrentModel,
|
|
setCurrentProvider,
|
|
setMessages,
|
|
setSessions,
|
|
setSessionsLoading
|
|
} from '../store/session'
|
|
|
|
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 { ChatSidebar } from './chat/sidebar'
|
|
import { FileBrowserPane } from './file-browser'
|
|
import { useGatewayBoot } from './gateway/hooks/use-gateway-boot'
|
|
import { useGatewayRequest } from './gateway/hooks/use-gateway-request'
|
|
import { ModelPickerOverlay } from './model-picker-overlay'
|
|
import { NEW_CHAT_ROUTE, routeSessionId, sessionRoute } from './routes'
|
|
import { useContextSuggestions } from './session/hooks/use-context-suggestions'
|
|
import { useCwdActions } from './session/hooks/use-cwd-actions'
|
|
import { useHermesConfig } from './session/hooks/use-hermes-config'
|
|
import { useMessageStream } from './session/hooks/use-message-stream'
|
|
import { useModelControls } from './session/hooks/use-model-controls'
|
|
import { usePreviewRouting } from './session/hooks/use-preview-routing'
|
|
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 { AppShell } from './shell/app-shell'
|
|
import { useOverlayRouting } from './shell/hooks/use-overlay-routing'
|
|
import { useStatusSnapshot } from './shell/hooks/use-status-snapshot'
|
|
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'
|
|
|
|
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()
|
|
const location = useLocation()
|
|
const navigate = useNavigate()
|
|
|
|
const busyRef = useRef(false)
|
|
const creatingSessionRef = useRef(false)
|
|
const refreshSessionsRequestRef = useRef(0)
|
|
|
|
const gatewayState = useStore($gatewayState)
|
|
const activeSessionId = useStore($activeSessionId)
|
|
const currentCwd = useStore($currentCwd)
|
|
const freshDraftReady = useStore($freshDraftReady)
|
|
const filePreviewTarget = useStore($filePreviewTarget)
|
|
const previewTarget = useStore($previewTarget)
|
|
const selectedStoredSessionId = useStore($selectedStoredSessionId)
|
|
|
|
const routedSessionId = routeSessionId(location.pathname)
|
|
const routeToken = `${location.pathname}:${location.search}:${location.hash}`
|
|
const routeTokenRef = useRef(routeToken)
|
|
routeTokenRef.current = routeToken
|
|
const getRouteToken = useCallback(() => routeTokenRef.current, [])
|
|
|
|
const {
|
|
agentsOpen,
|
|
chatOpen,
|
|
closeOverlayToPreviousRoute,
|
|
commandCenterInitialSection,
|
|
commandCenterOpen,
|
|
currentView,
|
|
openAgents,
|
|
openCommandCenterSection,
|
|
settingsOpen,
|
|
toggleCommandCenter
|
|
} = useOverlayRouting()
|
|
|
|
const titlebarToolGroups = useGroupRegistry<TitlebarTool>()
|
|
const statusbarItemGroups = useGroupRegistry<StatusbarItem>()
|
|
const setTitlebarToolGroup = titlebarToolGroups.set
|
|
const setStatusbarItemGroup = statusbarItemGroups.set
|
|
|
|
const {
|
|
activeSessionIdRef,
|
|
ensureSessionState,
|
|
runtimeIdByStoredSessionIdRef,
|
|
selectedStoredSessionIdRef,
|
|
sessionStateByRuntimeIdRef,
|
|
syncSessionStateToView,
|
|
updateSessionState
|
|
} = useSessionStateCache({
|
|
activeSessionId,
|
|
busyRef,
|
|
selectedStoredSessionId,
|
|
setAwaitingResponse,
|
|
setBusy,
|
|
setMessages
|
|
})
|
|
|
|
const { connectionRef, gatewayRef, requestGateway } = useGatewayRequest()
|
|
|
|
useEffect(() => {
|
|
window.hermesDesktop?.setPreviewShortcutActive?.(Boolean(chatOpen && (filePreviewTarget || previewTarget)))
|
|
}, [chatOpen, filePreviewTarget, previewTarget])
|
|
|
|
useEffect(() => {
|
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
if (!$filePreviewTarget.get() && !$previewTarget.get()) {
|
|
return
|
|
}
|
|
|
|
if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'w') {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
closeActiveRightRailTab()
|
|
}
|
|
}
|
|
|
|
const unsubscribe = window.hermesDesktop?.onClosePreviewRequested?.(closeActiveRightRailTab)
|
|
|
|
window.addEventListener('keydown', onKeyDown, { capture: true })
|
|
|
|
return () => {
|
|
unsubscribe?.()
|
|
window.removeEventListener('keydown', onKeyDown, { capture: true })
|
|
}
|
|
}, [])
|
|
|
|
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 toggleSelectedPin = useCallback(() => {
|
|
const sessionId = $selectedStoredSessionId.get()
|
|
|
|
if (!sessionId) {
|
|
return
|
|
}
|
|
|
|
if ($pinnedSessionIds.get().includes(sessionId)) {
|
|
unpinSession(sessionId)
|
|
} else {
|
|
pinSession(sessionId)
|
|
}
|
|
}, [])
|
|
|
|
const { gatewayLogLines, statusSnapshot } = useStatusSnapshot(gatewayState)
|
|
|
|
const { browseSessionCwd, changeSessionCwd, refreshProjectBranch } = useCwdActions({
|
|
activeSessionId,
|
|
activeSessionIdRef,
|
|
currentCwd,
|
|
requestGateway
|
|
})
|
|
|
|
const { refreshHermesConfig, sttEnabled, voiceMaxRecordingSeconds } = useHermesConfig({
|
|
activeSessionIdRef,
|
|
refreshProjectBranch
|
|
})
|
|
|
|
const { refreshCurrentModel, selectModel, updateModelOptionsCache } = useModelControls({
|
|
activeSessionId,
|
|
queryClient,
|
|
requestGateway
|
|
})
|
|
|
|
useContextSuggestions({
|
|
activeSessionId,
|
|
activeSessionIdRef,
|
|
currentCwd,
|
|
gatewayState,
|
|
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 { handleDesktopGatewayEvent, restartPreviewServer } = usePreviewRouting({
|
|
activeSessionIdRef,
|
|
baseHandleGatewayEvent: handleGatewayEvent,
|
|
currentCwd,
|
|
currentView,
|
|
requestGateway,
|
|
routedSessionId,
|
|
selectedStoredSessionId
|
|
})
|
|
|
|
const {
|
|
branchCurrentSession,
|
|
createBackendSessionForSend,
|
|
openSettings,
|
|
removeSession,
|
|
resumeSession,
|
|
selectSidebarItem,
|
|
startFreshSessionDraft
|
|
} = useSessionActions({
|
|
activeSessionId,
|
|
activeSessionIdRef,
|
|
busyRef,
|
|
creatingSessionRef,
|
|
ensureSessionState,
|
|
getRouteToken,
|
|
navigate,
|
|
requestGateway,
|
|
runtimeIdByStoredSessionIdRef,
|
|
selectedStoredSessionId,
|
|
selectedStoredSessionIdRef,
|
|
sessionStateByRuntimeIdRef,
|
|
syncSessionStateToView,
|
|
updateSessionState
|
|
})
|
|
|
|
const composer = useComposerActions({
|
|
activeSessionId,
|
|
currentCwd,
|
|
requestGateway
|
|
})
|
|
|
|
const branchInNewChat = useCallback(
|
|
async (messageId?: string) => {
|
|
const branched = await branchCurrentSession(messageId)
|
|
|
|
if (branched) {
|
|
await refreshSessions().catch(() => undefined)
|
|
}
|
|
|
|
return branched
|
|
},
|
|
[branchCurrentSession, refreshSessions]
|
|
)
|
|
|
|
const handleSkinCommand = useSkinCommand()
|
|
|
|
const { cancelRun, editMessage, handleThreadMessagesChange, reloadFromMessage, submitText, transcribeVoiceAudio } =
|
|
usePromptActions({
|
|
activeSessionId,
|
|
activeSessionIdRef,
|
|
branchCurrentSession: branchInNewChat,
|
|
busyRef,
|
|
createBackendSessionForSend,
|
|
handleSkinCommand,
|
|
requestGateway,
|
|
selectedStoredSessionIdRef,
|
|
startFreshSessionDraft,
|
|
sttEnabled,
|
|
updateSessionState
|
|
})
|
|
|
|
useGatewayBoot({
|
|
handleGatewayEvent: handleDesktopGatewayEvent,
|
|
onConnectionReady: c => {
|
|
connectionRef.current = c
|
|
},
|
|
onGatewayReady: g => {
|
|
gatewayRef.current = g
|
|
},
|
|
refreshHermesConfig,
|
|
refreshSessions
|
|
})
|
|
|
|
useEffect(() => {
|
|
if (gatewayState === 'open') {
|
|
void refreshCurrentModel()
|
|
void refreshSessions().catch(() => undefined)
|
|
}
|
|
}, [gatewayState, refreshCurrentModel, refreshSessions])
|
|
|
|
useRouteResume({
|
|
activeSessionId,
|
|
activeSessionIdRef,
|
|
creatingSessionRef,
|
|
currentView,
|
|
freshDraftReady,
|
|
gatewayState,
|
|
locationPathname: location.pathname,
|
|
resumeSession,
|
|
routedSessionId,
|
|
runtimeIdByStoredSessionIdRef,
|
|
selectedStoredSessionId,
|
|
selectedStoredSessionIdRef,
|
|
startFreshSessionDraft
|
|
})
|
|
|
|
const { leftStatusbarItems, statusbarItems } = useStatusbarItems({
|
|
agentsOpen,
|
|
browseSessionCwd,
|
|
commandCenterOpen,
|
|
extraLeftItems: statusbarItemGroups.flat.left,
|
|
extraRightItems: statusbarItemGroups.flat.right,
|
|
gatewayLogLines,
|
|
openAgents,
|
|
openCommandCenterSection,
|
|
statusSnapshot,
|
|
toggleCommandCenter
|
|
})
|
|
|
|
const sidebar = (
|
|
<ChatSidebar
|
|
currentView={currentView}
|
|
onDeleteSession={sessionId => void removeSession(sessionId)}
|
|
onNavigate={selectSidebarItem}
|
|
onRefreshSessions={() => void refreshSessions()}
|
|
onResumeSession={sessionId => navigate(sessionRoute(sessionId))}
|
|
/>
|
|
)
|
|
|
|
const overlays = (
|
|
<>
|
|
<DesktopOnboardingOverlay
|
|
enabled={gatewayState === 'open'}
|
|
onCompleted={() => {
|
|
void refreshHermesConfig()
|
|
void refreshCurrentModel()
|
|
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
|
|
}}
|
|
requestGateway={requestGateway}
|
|
/>
|
|
<ModelPickerOverlay gateway={gatewayRef.current || undefined} onSelect={selectModel} />
|
|
|
|
{settingsOpen && (
|
|
<Suspense fallback={null}>
|
|
<SettingsView
|
|
onClose={closeOverlayToPreviousRoute}
|
|
onConfigSaved={() => {
|
|
void refreshHermesConfig()
|
|
void refreshCurrentModel()
|
|
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
|
|
}}
|
|
/>
|
|
</Suspense>
|
|
)}
|
|
|
|
{commandCenterOpen && (
|
|
<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 && (
|
|
<Suspense fallback={null}>
|
|
<AgentsView onClose={closeOverlayToPreviousRoute} />
|
|
</Suspense>
|
|
)}
|
|
</>
|
|
)
|
|
|
|
const chatView = (
|
|
<ChatView
|
|
gateway={gatewayRef.current}
|
|
maxVoiceRecordingSeconds={voiceMaxRecordingSeconds}
|
|
onAddContextRef={composer.addContextRefAttachment}
|
|
onAddUrl={url => composer.addContextRefAttachment(`@url:${formatRefValue(url)}`, url)}
|
|
onAttachDroppedItems={composer.attachDroppedItems}
|
|
onAttachImageBlob={composer.attachImageBlob}
|
|
onBranchInNewChat={messageId => void branchInNewChat(messageId)}
|
|
onCancel={() => void cancelRun()}
|
|
onDeleteSelectedSession={() => {
|
|
if (selectedStoredSessionId) {
|
|
void removeSession(selectedStoredSessionId)
|
|
}
|
|
}}
|
|
onEdit={editMessage}
|
|
onPasteClipboardImage={() => void composer.pasteClipboardImage()}
|
|
onPickFiles={() => void composer.pickContextPaths('file')}
|
|
onPickFolders={() => void composer.pickContextPaths('folder')}
|
|
onPickImages={() => void composer.pickImages()}
|
|
onReload={reloadFromMessage}
|
|
onRemoveAttachment={id => void composer.removeAttachment(id)}
|
|
onSubmit={submitText}
|
|
onThreadMessagesChange={handleThreadMessagesChange}
|
|
onToggleSelectedPin={toggleSelectedPin}
|
|
onTranscribeAudio={transcribeVoiceAudio}
|
|
/>
|
|
)
|
|
|
|
return (
|
|
<AppShell
|
|
leftStatusbarItems={leftStatusbarItems}
|
|
leftTitlebarTools={titlebarToolGroups.flat.left}
|
|
onOpenSettings={openSettings}
|
|
overlays={overlays}
|
|
statusbarItems={statusbarItems}
|
|
titlebarTools={titlebarToolGroups.flat.right}
|
|
>
|
|
<Pane
|
|
id="chat-sidebar"
|
|
maxWidth={SIDEBAR_MAX_WIDTH}
|
|
minWidth={SIDEBAR_DEFAULT_WIDTH}
|
|
resizable
|
|
side="left"
|
|
width={`${SIDEBAR_DEFAULT_WIDTH}px`}
|
|
>
|
|
{sidebar}
|
|
</Pane>
|
|
<PaneMain>
|
|
<Routes>
|
|
<Route element={chatView} index />
|
|
<Route element={chatView} path=":sessionId" />
|
|
<Route
|
|
element={
|
|
<Suspense fallback={null}>
|
|
<SkillsView setStatusbarItemGroup={setStatusbarItemGroup} setTitlebarToolGroup={setTitlebarToolGroup} />
|
|
</Suspense>
|
|
}
|
|
path="skills"
|
|
/>
|
|
<Route
|
|
element={
|
|
<Suspense fallback={null}>
|
|
<ArtifactsView
|
|
setStatusbarItemGroup={setStatusbarItemGroup}
|
|
setTitlebarToolGroup={setTitlebarToolGroup}
|
|
/>
|
|
</Suspense>
|
|
}
|
|
path="artifacts"
|
|
/>
|
|
<Route element={null} path="settings" />
|
|
<Route element={null} path="command-center" />
|
|
<Route element={null} path="agents" />
|
|
<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>
|
|
</PaneMain>
|
|
<Pane
|
|
disabled={!chatOpen || (!previewTarget && !filePreviewTarget)}
|
|
id="preview"
|
|
maxWidth={PREVIEW_RAIL_MAX_WIDTH}
|
|
minWidth={PREVIEW_RAIL_MIN_WIDTH}
|
|
resizable
|
|
side="right"
|
|
width={PREVIEW_RAIL_PANE_WIDTH}
|
|
>
|
|
{chatOpen ? (
|
|
<ChatPreviewRail onRestartServer={restartPreviewServer} setTitlebarToolGroup={setTitlebarToolGroup} />
|
|
) : null}
|
|
</Pane>
|
|
<Pane
|
|
defaultOpen={false}
|
|
id="file-browser"
|
|
maxWidth={FILE_BROWSER_MAX_WIDTH}
|
|
minWidth={FILE_BROWSER_MIN_WIDTH}
|
|
resizable
|
|
side="right"
|
|
width={FILE_BROWSER_DEFAULT_WIDTH}
|
|
>
|
|
<FileBrowserPane onActivateFile={composer.attachContextFilePath} onChangeCwd={changeSessionCwd} />
|
|
</Pane>
|
|
</AppShell>
|
|
)
|
|
}
|
|
|
|
function LegacySessionRedirect() {
|
|
const { sessionId } = useParams()
|
|
|
|
return <Navigate replace to={sessionId ? sessionRoute(sessionId) : NEW_CHAT_ROUTE} />
|
|
}
|