mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-11 08:42:11 +00:00
Resolve conflicts in desktop settings/cron/messaging/sidebar: adopt main's ListRow + actions-menu refactors for credential rows; keep our profileColor import on the sidebar. Drop the now-orphaned Tip-based helpers.
859 lines
28 KiB
TypeScript
859 lines
28 KiB
TypeScript
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<string> {
|
|
const keep = new Set<string>([...$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<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(() => {
|
|
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' ? (
|
|
<ModelMenuPanel
|
|
gateway={gatewayRef.current || undefined}
|
|
onSelectModel={selectModel}
|
|
requestGateway={requestGateway}
|
|
/>
|
|
) : 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 = (
|
|
<ChatSidebar
|
|
currentView={currentView}
|
|
onArchiveSession={sessionId => void archiveSession(sessionId)}
|
|
onDeleteSession={sessionId => void removeSession(sessionId)}
|
|
onLoadMoreProfileSessions={loadMoreSessionsForProfile}
|
|
onLoadMoreSessions={loadMoreSessions}
|
|
onNavigate={selectSidebarItem}
|
|
onNewSessionInWorkspace={startSessionInWorkspace}
|
|
onResumeSession={sessionId => navigate(sessionRoute(sessionId))}
|
|
/>
|
|
)
|
|
|
|
const overlays = (
|
|
<>
|
|
<DesktopInstallOverlay />
|
|
{/* One PTY-backed terminal mounted forever; <TerminalSlot /> placeholders
|
|
decide where it shows. Toggling fullscreen never rebuilds the shell. */}
|
|
<PersistentTerminal cwd={currentCwd} onAddSelectionToChat={composer.addTerminalSelectionAttachment} />
|
|
<DesktopOnboardingOverlay
|
|
enabled={gatewayState === 'open'}
|
|
onCompleted={() => {
|
|
void refreshHermesConfig()
|
|
void refreshCurrentModel()
|
|
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
|
|
}}
|
|
requestGateway={requestGateway}
|
|
/>
|
|
<ModelPickerOverlay gateway={gatewayRef.current || undefined} onSelect={selectModel} />
|
|
<ModelVisibilityOverlay gateway={gatewayRef.current || undefined} onOpenProviders={openProviderSettings} />
|
|
<UpdatesOverlay />
|
|
<GatewayConnectingOverlay />
|
|
<BootFailureOverlay />
|
|
<CommandPalette />
|
|
|
|
{settingsOpen && (
|
|
<Suspense fallback={null}>
|
|
<SettingsView
|
|
gateway={gatewayRef.current}
|
|
onClose={closeOverlayToPreviousRoute}
|
|
onConfigSaved={() => {
|
|
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'] })
|
|
}}
|
|
/>
|
|
</Suspense>
|
|
)}
|
|
|
|
{commandCenterOpen && (
|
|
<Suspense fallback={null}>
|
|
<CommandCenterView
|
|
initialSection={commandCenterInitialSection}
|
|
onClose={closeOverlayToPreviousRoute}
|
|
onDeleteSession={removeSession}
|
|
onOpenSession={sessionId => navigate(sessionRoute(sessionId))}
|
|
/>
|
|
</Suspense>
|
|
)}
|
|
|
|
{agentsOpen && (
|
|
<Suspense fallback={null}>
|
|
<AgentsView onClose={closeOverlayToPreviousRoute} />
|
|
</Suspense>
|
|
)}
|
|
|
|
{cronOpen && (
|
|
<Suspense fallback={null}>
|
|
<CronView onClose={closeOverlayToPreviousRoute} />
|
|
</Suspense>
|
|
)}
|
|
|
|
{profilesOpen && (
|
|
<Suspense fallback={null}>
|
|
<ProfilesView 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={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 = (
|
|
<div className="relative flex h-full min-h-0 min-w-0 flex-col overflow-hidden bg-(--ui-chat-surface-background) pt-(--titlebar-height)">
|
|
<TerminalSlot />
|
|
</div>
|
|
)
|
|
|
|
// 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 = (
|
|
<Pane
|
|
disabled={!chatOpen || (!previewTarget && !filePreviewTarget)}
|
|
id="preview"
|
|
key="preview"
|
|
maxWidth={PREVIEW_RAIL_MAX_WIDTH}
|
|
minWidth={PREVIEW_RAIL_MIN_WIDTH}
|
|
resizable
|
|
side={railSide}
|
|
width={PREVIEW_RAIL_PANE_WIDTH}
|
|
>
|
|
{chatOpen ? (
|
|
<ChatPreviewRail onRestartServer={restartPreviewServer} setTitlebarToolGroup={setTitlebarToolGroup} />
|
|
) : null}
|
|
</Pane>
|
|
)
|
|
|
|
const fileBrowserPane = (
|
|
<Pane
|
|
defaultOpen={false}
|
|
disabled={!chatOpen}
|
|
id="file-browser"
|
|
key="file-browser"
|
|
maxWidth={FILE_BROWSER_MAX_WIDTH}
|
|
minWidth={FILE_BROWSER_MIN_WIDTH}
|
|
resizable
|
|
side={railSide}
|
|
width={FILE_BROWSER_DEFAULT_WIDTH}
|
|
>
|
|
<RightSidebarPane
|
|
onActivateFile={composer.attachContextFilePath}
|
|
onActivateFolder={composer.attachContextFolderPath}
|
|
onChangeCwd={changeSessionCwd}
|
|
/>
|
|
</Pane>
|
|
)
|
|
|
|
return (
|
|
<AppShell
|
|
leftStatusbarItems={leftStatusbarItems}
|
|
leftTitlebarTools={titlebarToolGroups.flat.left}
|
|
onOpenSettings={openSettings}
|
|
overlays={overlays}
|
|
statusbarItems={statusbarItems}
|
|
titlebarTools={titlebarToolGroups.flat.right}
|
|
>
|
|
<Pane
|
|
disabled={terminalTakeoverActive}
|
|
id="chat-sidebar"
|
|
maxWidth={SIDEBAR_MAX_WIDTH}
|
|
minWidth={SIDEBAR_DEFAULT_WIDTH}
|
|
resizable
|
|
side={sidebarSide}
|
|
width={`${SIDEBAR_DEFAULT_WIDTH}px`}
|
|
>
|
|
{sidebar}
|
|
</Pane>
|
|
<PaneMain>
|
|
<Routes>
|
|
<Route element={terminalTakeoverActive ? takeoverTerminalView : chatView} index />
|
|
<Route element={terminalTakeoverActive ? takeoverTerminalView : chatView} path=":sessionId" />
|
|
<Route
|
|
element={
|
|
<Suspense fallback={null}>
|
|
<SkillsView setStatusbarItemGroup={setStatusbarItemGroup} />
|
|
</Suspense>
|
|
}
|
|
path="skills"
|
|
/>
|
|
<Route
|
|
element={
|
|
<Suspense fallback={null}>
|
|
<MessagingView setStatusbarItemGroup={setStatusbarItemGroup} />
|
|
</Suspense>
|
|
}
|
|
path="messaging"
|
|
/>
|
|
<Route
|
|
element={
|
|
<Suspense fallback={null}>
|
|
<ArtifactsView setStatusbarItemGroup={setStatusbarItemGroup} />
|
|
</Suspense>
|
|
}
|
|
path="artifacts"
|
|
/>
|
|
<Route element={null} path="cron" />
|
|
<Route element={null} path="profiles" />
|
|
<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>
|
|
{/*
|
|
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}
|
|
</AppShell>
|
|
)
|
|
}
|
|
|
|
function LegacySessionRedirect() {
|
|
const { sessionId } = useParams()
|
|
|
|
return <Navigate replace to={sessionId ? sessionRoute(sessionId) : NEW_CHAT_ROUTE} />
|
|
}
|