import { useStore } from '@nanostores/react' import { useQueryClient } from '@tanstack/react-query' import { lazy, Suspense, useCallback, useEffect, useMemo, useRef } from 'react' import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from 'react-router-dom' import { BootFailureOverlay } from '@/components/boot-failure-overlay' import { DesktopInstallOverlay } from '@/components/desktop-install-overlay' import { DesktopOnboardingOverlay } from '@/components/desktop-onboarding-overlay' import { GatewayConnectingOverlay } from '@/components/gateway-connecting-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, listAllProfileSessions, type SessionInfo } from '../hermes' import { preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages' import { toggleCommandPalette } from '../store/command-palette' import { $panesFlipped, $pinnedSessionIds, $sessionsLimit, bumpSessionsLimit, FILE_BROWSER_DEFAULT_WIDTH, FILE_BROWSER_MAX_WIDTH, FILE_BROWSER_MIN_WIDTH, pinSession, SIDEBAR_DEFAULT_WIDTH, SIDEBAR_MAX_WIDTH, SIDEBAR_SESSIONS_PAGE_SIZE, unpinSession } from '../store/layout' import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '../store/preview' import { $freshSessionRequest, normalizeProfileKey, refreshActiveProfile } from '../store/profile' import { $activeSessionId, $currentCwd, $freshDraftReady, $gatewayState, $selectedStoredSessionId, $sessions, $workingSessionIds, mergeSessionPage, sessionPinId, setAwaitingResponse, setBusy, setCurrentBranch, setCurrentCwd, setCurrentModel, setCurrentProvider, setMessages, setSessionProfileTotals, setSessions, setSessionsLoading, setSessionsTotal } from '../store/session' import { openUpdatesWindow, startUpdatePoller, stopUpdatePoller } from '../store/updates' 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 { CommandPalette } from './command-palette' import { useGatewayBoot } from './gateway/hooks/use-gateway-boot' import { useGatewayRequest } from './gateway/hooks/use-gateway-request' import { ModelPickerOverlay } from './model-picker-overlay' import { ModelVisibilityOverlay } from './model-visibility-overlay' import { RightSidebarPane } from './right-sidebar' import { $terminalTakeover } from './right-sidebar/store' import { PersistentTerminal, TerminalSlot } from './right-sidebar/terminal/persistent' import { NEW_CHAT_ROUTE, routeSessionId, sessionRoute, SETTINGS_ROUTE } 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 { ModelMenuPanel } from './shell/model-menu-panel' import type { StatusbarItem } from './shell/statusbar-controls' import type { TitlebarTool } from './shell/titlebar-controls' import { useGroupRegistry } from './shell/use-group-registry' import { UpdatesOverlay } from './updates-overlay' 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 CronView = lazy(async () => ({ default: (await import('./cron')).CronView })) const MessagingView = lazy(async () => ({ default: (await import('./messaging')).MessagingView })) const ProfilesView = lazy(async () => ({ default: (await import('./profiles')).ProfilesView })) const SettingsView = lazy(async () => ({ default: (await import('./settings')).SettingsView })) const SkillsView = lazy(async () => ({ default: (await import('./skills')).SkillsView })) // Rows a session refresh must preserve even if the aggregator omits them: // in-flight first turns (message_count 0), pinned rows aged off the page, and // the actively-viewed chat (its "working" flag clears a beat before the // aggregator sees the persisted row). Pass `scope` to only keep the active row // when it belongs to the profile being paged. function sessionsToKeep(scope?: string): Set { const keep = new Set([...$workingSessionIds.get(), ...$pinnedSessionIds.get()]) const active = $selectedStoredSessionId.get() if (active) { const session = scope ? $sessions.get().find(s => s.id === active) : null if (!scope || !session || normalizeProfileKey(session.profile) === scope) { keep.add(active) } } return keep } 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 terminalTakeover = useStore($terminalTakeover) const panesFlipped = useStore($panesFlipped) 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, cronOpen, currentView, openAgents, openCommandCenterSection, profilesOpen, settingsOpen, toggleCommandCenter } = useOverlayRouting() const terminalTakeoverActive = chatOpen && terminalTakeover const titlebarToolGroups = useGroupRegistry() const statusbarItemGroups = useGroupRegistry() 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(() => { startUpdatePoller() const unsubscribe = window.hermesDesktop?.onOpenUpdatesRequested?.(() => openUpdatesWindow()) return () => { unsubscribe?.() stopUpdatePoller() } }, []) 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 }) } }, []) // Global chrome shortcuts (plain Cmd/Ctrl, no alt/shift): Cmd+K / Cmd+P → // command palette (the composer's "drain next queued" moved to Cmd+Shift+K), // Cmd+. → command center (sessions / system / usage). useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) { return } const key = event.key.toLowerCase() if (key === 'k' || key === 'p') { event.preventDefault() toggleCommandPalette() } else if (key === '.') { event.preventDefault() toggleCommandCenter() } } window.addEventListener('keydown', onKeyDown) return () => window.removeEventListener('keydown', onKeyDown) }, [toggleCommandCenter]) const refreshSessions = useCallback(async () => { const requestId = refreshSessionsRequestRef.current + 1 refreshSessionsRequestRef.current = requestId setSessionsLoading(true) try { const limit = $sessionsLimit.get() // Require at least one message so abandoned/empty "Untitled" drafts (one // was created per TUI/desktop launch before the lazy-create fix) don't // clutter the sidebar. // Unified cross-profile list (served read-only off each profile's // state.db; no per-profile backend is spawned). Single-profile users get // the same rows tagged profile="default". const result = await listAllProfileSessions(limit, 1) if (refreshSessionsRequestRef.current === requestId) { setSessions(prev => mergeSessionPage(prev, result.sessions, sessionsToKeep())) setSessionsTotal(typeof result.total === 'number' ? result.total : result.sessions.length) setSessionProfileTotals(result.profile_totals ?? {}) } } finally { if (refreshSessionsRequestRef.current === requestId) { setSessionsLoading(false) } } }, []) const loadMoreSessions = useCallback(() => { bumpSessionsLimit() void refreshSessions() }, [refreshSessions]) // ALL-profiles view pages one profile at a time: fetch that profile's next // page and merge it in place, leaving every other profile's rows untouched. const loadMoreSessionsForProfile = useCallback(async (profile: string) => { const key = normalizeProfileKey(profile) const inKey = (s: SessionInfo) => normalizeProfileKey(s.profile) === key const loaded = $sessions.get().filter(inKey).length const result = await listAllProfileSessions(loaded + SIDEBAR_SESSIONS_PAGE_SIZE, 1, 'exclude', 'recent', key) const keep = sessionsToKeep(key) setSessions(prev => [...prev.filter(s => !inKey(s)), ...mergeSessionPage(prev.filter(inKey), result.sessions, keep)]) const total = result.profile_totals?.[key] ?? result.total ?? result.sessions.length setSessionProfileTotals(prev => ({ ...prev, [key]: Math.max(total, result.sessions.length) })) }, []) const toggleSelectedPin = useCallback(() => { const sessionId = $selectedStoredSessionId.get() if (!sessionId) { return } // Pin on the durable lineage-root id so the pin survives auto-compression. const session = $sessions.get().find(s => s.id === sessionId || s._lineage_root_id === sessionId) const pinId = session ? sessionPinId(session) : sessionId if ($pinnedSessionIds.get().includes(pinId)) { unpinSession(pinId) } else { pinSession(pinId) } }, []) const { gatewayLogLines, inferenceStatus, statusSnapshot } = useStatusSnapshot(gatewayState, requestGateway) const updateActiveSessionRuntimeInfo = useCallback( (info: { branch?: string; cwd?: string }) => { const sessionId = activeSessionIdRef.current if (!sessionId) { return } updateSessionState(sessionId, state => ({ ...state, branch: info.branch ?? state.branch, cwd: info.cwd ?? state.cwd })) }, [activeSessionIdRef, updateSessionState] ) const { changeSessionCwd, refreshProjectBranch } = useCwdActions({ activeSessionId, activeSessionIdRef, onSessionRuntimeInfo: updateActiveSessionRuntimeInfo, requestGateway }) const { refreshHermesConfig, sttEnabled, voiceMaxRecordingSeconds } = useHermesConfig({ activeSessionIdRef, refreshProjectBranch }) const { refreshCurrentModel, selectModel, updateModelOptionsCache } = useModelControls({ activeSessionId, queryClient, requestGateway }) const openProviderSettings = useCallback(() => { navigate(`${SETTINGS_ROUTE}?tab=providers`) }, [navigate]) const modelMenuContent = useMemo( () => gatewayState === 'open' ? ( ) : null, [gatewayRef, gatewayState, requestGateway, selectModel] ) useContextSuggestions({ activeSessionId, activeSessionIdRef, currentCwd, gatewayState, requestGateway }) const hydrateFromStoredSession = useCallback( async ( attempts = 1, storedSessionId = selectedStoredSessionIdRef.current, runtimeSessionId = activeSessionIdRef.current ) => { if (!storedSessionId || !runtimeSessionId) { return } const storedProfile = $sessions.get().find(session => session.id === storedSessionId)?.profile for (let index = 0; index < Math.max(1, attempts); index += 1) { try { const latest = await getSessionMessages(storedSessionId, storedProfile) updateSessionState( runtimeSessionId, state => ({ ...state, messages: preserveLocalAssistantErrors(toChatMessages(latest.messages), state.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 { archiveSession, branchCurrentSession, createBackendSessionForSend, openSettings, removeSession, resumeSession, selectSidebarItem, startFreshSessionDraft } = useSessionActions({ activeSessionId, activeSessionIdRef, busyRef, creatingSessionRef, ensureSessionState, getRouteToken, navigate, requestGateway, runtimeIdByStoredSessionIdRef, selectedStoredSessionId, selectedStoredSessionIdRef, sessionStateByRuntimeIdRef, syncSessionStateToView, updateSessionState }) useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { const target = event.target as HTMLElement | null const editing = target?.isContentEditable || target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement if (event.defaultPrevented || event.repeat || event.altKey || event.code !== 'KeyN') { return } // Two accelerators for "new session": // - Cmd/Ctrl+N (browser-like, works while typing in any input) // - Shift+N (single-key, only when no input is focused) const accelerator = event.metaKey || event.ctrlKey const singleKey = !accelerator && !editing && event.shiftKey if (!accelerator && !singleKey) { return } event.preventDefault() startFreshSessionDraft() // Briefly light up the sidebar's ⌘N hint so the shortcut is discoverable. window.dispatchEvent(new CustomEvent('hermes:new-session-shortcut')) } window.addEventListener('keydown', onKeyDown) return () => window.removeEventListener('keydown', onKeyDown) }, [startFreshSessionDraft]) // A profile switch/create drops to a fresh new-session draft so the previously // open session doesn't bleed across contexts. Skip the initial value. const freshSessionRequest = useStore($freshSessionRequest) const lastFreshRef = useRef(freshSessionRequest) useEffect(() => { if (freshSessionRequest === lastFreshRef.current) { return } lastFreshRef.current = freshSessionRequest startFreshSessionDraft() }, [freshSessionRequest, startFreshSessionDraft]) 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 startSessionInWorkspace = useCallback( (path: null | string) => { startFreshSessionDraft() const target = path?.trim() if (!target) { return } // The next message creates the backend session in $currentCwd, so seed // it (and the branch) from the workspace the user clicked the + on. setCurrentCwd(target) void requestGateway<{ branch?: string; cwd?: string }>('config.get', { key: 'project', cwd: target }) .then(info => { setCurrentCwd(info.cwd || target) setCurrentBranch(info.branch || '') }) .catch(() => undefined) }, [requestGateway, startFreshSessionDraft] ) const handleSkinCommand = useSkinCommand() const { cancelRun, editMessage, handleThreadMessagesChange, reloadFromMessage, submitText, transcribeVoiceAudio } = usePromptActions({ activeSessionId, activeSessionIdRef, branchCurrentSession: branchInNewChat, busyRef, createBackendSessionForSend, handleSkinCommand, refreshSessions, 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 refreshActiveProfile() 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, commandCenterOpen, extraLeftItems: statusbarItemGroups.flat.left, extraRightItems: statusbarItemGroups.flat.right, gatewayLogLines, gatewayState, inferenceStatus, modelMenuContent, openAgents, freshDraftReady, openCommandCenterSection, requestGateway, statusSnapshot, toggleCommandCenter }) const sidebar = ( void archiveSession(sessionId)} onDeleteSession={sessionId => void removeSession(sessionId)} onLoadMoreProfileSessions={loadMoreSessionsForProfile} onLoadMoreSessions={loadMoreSessions} onNavigate={selectSidebarItem} onNewSessionInWorkspace={startSessionInWorkspace} onResumeSession={sessionId => navigate(sessionRoute(sessionId))} /> ) const overlays = ( <> {/* One PTY-backed terminal mounted forever; placeholders decide where it shows. Toggling fullscreen never rebuilds the shell. */} { void refreshHermesConfig() void refreshCurrentModel() void queryClient.invalidateQueries({ queryKey: ['model-options'] }) }} requestGateway={requestGateway} /> {settingsOpen && ( { void refreshHermesConfig() void refreshCurrentModel() void queryClient.invalidateQueries({ queryKey: ['model-options'] }) }} onMainModelChanged={(provider, model) => { setCurrentProvider(provider) setCurrentModel(model) updateModelOptionsCache(provider, model, true) void refreshCurrentModel() void queryClient.invalidateQueries({ queryKey: ['model-options'] }) }} /> )} {commandCenterOpen && ( navigate(sessionRoute(sessionId))} /> )} {agentsOpen && ( )} {cronOpen && ( )} {profilesOpen && ( )} ) const chatView = ( composer.addContextRefAttachment(`@url:${formatRefValue(url)}`, url)} onAttachDroppedItems={composer.attachDroppedItems} onAttachImageBlob={composer.attachImageBlob} onBranchInNewChat={branchInNewChat} onCancel={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} /> ) const takeoverTerminalView = (
) // Flipped layout mirrors the default: sessions sidebar → right, file // browser + preview rail → left. Same panes, swapped sides. const sidebarSide = panesFlipped ? 'right' : 'left' const railSide = panesFlipped ? 'left' : 'right' const previewPane = ( {chatOpen ? ( ) : null} ) const fileBrowserPane = ( ) return ( {sidebar} } path="skills" /> } path="messaging" /> } path="artifacts" /> } path="new" /> } path="sessions/:sessionId" /> } path="*" /> {/* Order within a side maps to column order. Default (rail on the right): main | preview | file-browser. Flipped (rail on the left): mirror it to file-browser | preview | main so preview stays adjacent to the chat. */} {panesFlipped ? fileBrowserPane : previewPane} {panesFlipped ? previewPane : fileBrowserPane} ) } function LegacySessionRedirect() { const { sessionId } = useParams() return }