feat: better tool parsing ui

This commit is contained in:
Brooklyn Nicholson
2026-05-04 16:08:44 -05:00
parent d1d0ed4016
commit 5f334e86fd
24 changed files with 1865 additions and 244 deletions
+283 -41
View File
@@ -3,24 +3,30 @@ 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 type { ModelOptionsResponse, SessionRuntimeInfo, StatusResponse } from '@/types/hermes'
import { formatRefValue } from '../components/assistant-ui/directive-text'
import {
getGlobalModelInfo,
getHermesConfig,
getHermesConfigDefaults,
getLogs,
getSessionMessages,
getStatus,
type HermesGateway,
listSessions,
setGlobalModel
} from '../hermes'
import { chatMessageText, toChatMessages } from '../lib/chat-messages'
import { BUILTIN_PERSONALITIES, normalizePersonalityValue, personalityNamesFromConfig } from '../lib/chat-runtime'
import { Activity, AlertCircle, Command, Cpu, FolderOpen, GitBranch, Loader2 } from '../lib/icons'
import { extractPreviewCandidates } from '../lib/preview-targets'
import { compactPath, contextBarLabel, LiveDuration, usageContextLabel } from '../lib/statusbar'
import { $desktopActionTasks } from '../store/activity'
import { $pinnedSessionIds, pinSession, unpinSession } from '../store/layout'
import { notify, notifyError } from '../store/notifications'
import {
$previewServerRestart,
$previewTarget,
beginPreviewServerRestart,
completePreviewServerRestart,
@@ -30,14 +36,23 @@ import {
} from '../store/preview'
import {
$activeSessionId,
$busy,
$currentBranch,
$currentCwd,
$currentFastMode,
$currentModel,
$currentProvider,
$currentReasoningEffort,
$currentServiceTier,
$currentUsage,
$freshDraftReady,
$gatewayState,
$messages,
$selectedStoredSessionId,
$sessions,
$sessionStartedAt,
$turnStartedAt,
$workingSessionIds,
setAvailablePersonalities,
setAwaitingResponse,
setBusy,
@@ -62,7 +77,7 @@ 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 { type CommandCenterSection, 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'
@@ -72,7 +87,8 @@ import {
isNewChatRoute,
NEW_CHAT_ROUTE,
routeSessionId,
sessionRoute
sessionRoute,
SKILLS_ROUTE
} from './routes'
import { useMessageStream } from './session/hooks/use-message-stream'
import { usePromptActions } from './session/hooks/use-prompt-actions'
@@ -80,11 +96,16 @@ 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 type { StatusbarItem, StatusbarMenuItem } from './shell/statusbar-controls'
import type { TitlebarTool } from './shell/titlebar-controls'
import { useGroupRegistry } from './shell/use-group-registry'
import { SkillsView } from './skills'
import type { ContextSuggestion } from './types'
const DEFAULT_VOICE_RECORDING_SECONDS = 120
const COMMAND_CENTER_SECTIONS = ['models', 'sessions', 'system'] as const
const STATUS_REFRESH_MS = 15_000
const GATEWAY_LOG_TAIL = 5
function normalizeRecordingLimit(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : DEFAULT_VOICE_RECORDING_SECONDS
@@ -100,6 +121,26 @@ function gatewayEventPreviewText(event: { payload?: unknown; type?: string }): s
.join('\n')
}
function buildGatewayLogItems(lines: readonly string[]): readonly StatusbarMenuItem[] {
if (lines.length === 0) {
return [
{
className: 'text-muted-foreground',
disabled: true,
id: 'gateway-log-empty',
label: 'No recent gateway log lines'
}
]
}
return lines.slice(-GATEWAY_LOG_TAIL).map((line, index) => ({
className: 'font-mono text-[0.68rem] text-muted-foreground',
disabled: true,
id: `gateway-log:${index}`,
label: line.trim().slice(0, 120) || '(blank log line)'
}))
}
function gatewayEventCompletedFileDiff(event: { payload?: unknown; type?: string }): boolean {
if (event.type !== 'tool.complete' || !event.payload || typeof event.payload !== 'object') {
return false
@@ -119,9 +160,20 @@ export function DesktopController() {
const gatewayState = useStore($gatewayState)
const { availableThemes, setTheme, themeName } = useTheme()
const activeSessionId = useStore($activeSessionId)
const busy = useStore($busy)
const currentBranch = useStore($currentBranch)
const currentUsage = useStore($currentUsage)
const messages = useStore($messages)
const currentModel = useStore($currentModel)
const currentProvider = useStore($currentProvider)
const desktopActionTasks = useStore($desktopActionTasks)
const previewServerRestart = useStore($previewServerRestart)
const selectedStoredSessionId = useStore($selectedStoredSessionId)
const sessionStartedAt = useStore($sessionStartedAt)
const sessions = useStore($sessions)
const turnStartedAt = useStore($turnStartedAt)
const currentCwd = useStore($currentCwd)
const workingSessionIds = useStore($workingSessionIds)
const freshDraftReady = useStore($freshDraftReady)
const routedSessionId = routeSessionId(location.pathname)
const currentView = appViewForPath(location.pathname)
@@ -132,15 +184,25 @@ export function DesktopController() {
const settingsOpen = currentView === 'settings'
const commandCenterOpen = currentView === 'command-center'
const chatOpen = currentView === 'chat'
const commandCenterInitialSection = useMemo<CommandCenterSection | undefined>(() => {
const section = new URLSearchParams(location.search).get('section')
return COMMAND_CENTER_SECTIONS.find(value => value === section)
}, [location.search])
const overlayReturnPathRef = useRef(NEW_CHAT_ROUTE)
const refreshSessionsRequestRef = useRef(0)
const [titlebarToolGroups, setTitlebarToolGroups] = useState<
Record<TitlebarToolSide, Record<string, readonly TitlebarTool[]>>
>({ left: {}, right: {} })
const titlebarToolGroups = useGroupRegistry<TitlebarTool>()
const statusbarItemGroups = useGroupRegistry<StatusbarItem>()
const setTitlebarToolGroup = titlebarToolGroups.set
const setStatusbarItemGroup = statusbarItemGroups.set
const [voiceMaxRecordingSeconds, setVoiceMaxRecordingSeconds] = useState(DEFAULT_VOICE_RECORDING_SECONDS)
const [sttEnabled, setSttEnabled] = useState(true)
const [gatewayLogLines, setGatewayLogLines] = useState<string[]>([])
const [statusSnapshot, setStatusSnapshot] = useState<StatusResponse | null>(null)
const {
activeSessionIdRef,
@@ -159,23 +221,112 @@ export function DesktopController() {
setMessages
})
const setTitlebarToolGroup = useCallback<SetTitlebarToolGroup>((id, tools, side = 'right') => {
setTitlebarToolGroups(current => {
const next = { ...current, [side]: { ...current[side] } }
const openCommandCenterSection = useCallback(
(section: CommandCenterSection) => {
navigate(`${COMMAND_CENTER_ROUTE}?section=${section}`)
},
[navigate]
)
if (tools.length === 0) {
delete next[side][id]
} else {
next[side][id] = tools
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 contextUsage = useMemo(() => usageContextLabel(currentUsage), [currentUsage])
const contextBar = useMemo(() => contextBarLabel(currentUsage), [currentUsage])
const platformMenuItems = useMemo<readonly StatusbarMenuItem[]>(
() =>
Object.entries(statusSnapshot?.gateway_platforms || {})
.sort(([left], [right]) => left.localeCompare(right))
.map(([name, platform]) => ({
id: `platform:${name}`,
label: `${name} · ${platform.state}`,
disabled: true
})),
[statusSnapshot?.gateway_platforms]
)
const gatewayMenuItems = useMemo<readonly StatusbarMenuItem[]>(
() => [
{
id: 'gateway:open-system',
label: 'Open system panel',
onSelect: () => openCommandCenterSection('system')
},
...buildGatewayLogItems(gatewayLogLines),
...platformMenuItems
],
[gatewayLogLines, openCommandCenterSection, platformMenuItems]
)
const backgroundSummary = useMemo(() => {
const actions = Object.values(desktopActionTasks)
const runningActions = actions.filter(task => task.status.running).length
const failedActions = actions.filter(task => !task.status.running && (task.status.exit_code ?? 0) !== 0).length
const runningPreview = previewServerRestart?.status === 'running' ? 1 : 0
const failedPreview = previewServerRestart?.status === 'error' ? 1 : 0
return {
running: workingSessionIds.length + runningActions + runningPreview,
failed: failedActions + failedPreview
}
}, [desktopActionTasks, previewServerRestart, workingSessionIds])
const gatewayUp = Boolean(statusSnapshot?.gateway_running)
const bgRunning = backgroundSummary.running
const bgFailed = backgroundSummary.failed
const coreLeftStatusbarItems = useMemo<readonly StatusbarItem[]>(
() => [
{
className: `h-6 w-6 justify-center px-0${commandCenterOpen ? ' bg-accent/55 text-foreground' : ''}`,
icon: <Command className="size-3.5" />,
id: 'command-center',
onSelect: toggleCommandCenter,
title: commandCenterOpen ? 'Close Command Center' : 'Open Command Center',
variant: 'action'
},
{
className: gatewayUp ? undefined : 'text-destructive hover:text-destructive',
detail: gatewayUp ? statusSnapshot?.gateway_state || 'online' : 'offline',
icon: gatewayUp ? <Activity className="size-3" /> : <AlertCircle className="size-3" />,
id: 'gateway-health',
label: 'Gateway',
menuClassName: 'w-96',
menuItems: gatewayMenuItems,
title: 'Gateway and platform health',
variant: 'menu'
},
{
className: bgFailed > 0 ? 'text-destructive hover:text-destructive' : undefined,
detail: bgFailed > 0 ? `${bgFailed} failed` : `${bgRunning} running`,
hidden: bgRunning === 0 && bgFailed === 0,
icon: bgFailed > 0 ? <AlertCircle className="size-3" /> : <Loader2 className="size-3 animate-spin" />,
id: 'background-summary',
label: 'Background',
onSelect: () => openCommandCenterSection('system'),
title: 'Open background task details',
variant: 'action'
}
],
[bgFailed, bgRunning, commandCenterOpen, gatewayMenuItems, gatewayUp, openCommandCenterSection, statusSnapshot?.gateway_state, toggleCommandCenter]
)
return next
})
}, [])
const leftTitlebarTools = useMemo(() => Object.values(titlebarToolGroups.left).flat(), [titlebarToolGroups.left])
const titlebarTools = useMemo(() => Object.values(titlebarToolGroups.right).flat(), [titlebarToolGroups.right])
const leftStatusbarItems = useMemo(
() => [...coreLeftStatusbarItems, ...statusbarItemGroups.flat.left],
[coreLeftStatusbarItems, statusbarItemGroups.flat.left]
)
const toggleSelectedPin = useCallback(() => {
const sessionId = $selectedStoredSessionId.get()
@@ -225,6 +376,36 @@ export function DesktopController() {
[connectionRef]
)
useEffect(() => {
let cancelled = false
const refreshStatus = async () => {
try {
const [next, logs] = await Promise.all([
getStatus(),
getLogs({ file: 'gateway', lines: 12 }).catch(() => ({ lines: [] }))
])
if (cancelled) {
return
}
setStatusSnapshot(next)
setGatewayLogLines(logs.lines.map(line => line.trim()).filter(Boolean))
} catch {
// Keep the last successful snapshot.
}
}
void refreshStatus()
const timer = window.setInterval(() => void refreshStatus(), STATUS_REFRESH_MS)
return () => {
cancelled = true
window.clearInterval(timer)
}
}, [gatewayState])
const updateModelOptionsCache = useCallback(
(provider: string, model: string, includeGlobal: boolean) => {
const patch = (prev: ModelOptionsResponse | undefined) => ({
@@ -388,6 +569,67 @@ export function DesktopController() {
}
}, [changeSessionCwd, currentCwd])
const coreRightStatusbarItems = useMemo<readonly StatusbarItem[]>(
() => [
{
hidden: !busy || !turnStartedAt,
icon: <Loader2 className="size-3 animate-spin" />,
id: 'running-timer',
label: 'Running',
detail: <LiveDuration since={turnStartedAt} />,
title: 'Current turn elapsed',
variant: 'text'
},
{
detail: contextBar || undefined,
hidden: !contextUsage,
id: 'context-usage',
label: contextUsage,
title: 'Context usage',
variant: 'text'
},
{
hidden: !sessionStartedAt,
id: 'session-timer',
label: 'Session',
detail: <LiveDuration since={sessionStartedAt} />,
title: 'Runtime session elapsed',
variant: 'text'
},
{
detail: currentProvider || '',
icon: <Cpu className="size-3" />,
id: 'model-summary',
label: currentModel || 'No model selected',
onSelect: () => setModelPickerOpen(true),
title: currentProvider ? `Switch model · ${currentProvider}: ${currentModel || ''}` : 'Open model picker',
variant: 'action'
},
{
id: 'cwd',
icon: <FolderOpen className="size-3" />,
label: currentCwd ? compactPath(currentCwd) : 'No project cwd',
onSelect: () => void browseSessionCwd(),
title: currentCwd ? `Change working directory · ${currentCwd}` : 'Choose working directory',
variant: 'action'
},
{
hidden: !currentBranch,
id: 'branch',
icon: <GitBranch className="size-3" />,
label: currentBranch,
title: currentBranch ? `Current branch: ${currentBranch}` : undefined,
variant: 'text'
}
],
[browseSessionCwd, busy, contextBar, contextUsage, currentBranch, currentCwd, currentModel, currentProvider, sessionStartedAt, turnStartedAt]
)
const statusbarItems = useMemo(
() => [...statusbarItemGroups.flat.right, ...coreRightStatusbarItems],
[coreRightStatusbarItems, statusbarItemGroups.flat.right]
)
const selectModel = useCallback(
(selection: { provider: string; model: string; persistGlobal: boolean }) => {
setCurrentModel(selection.model)
@@ -662,6 +904,7 @@ export function DesktopController() {
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) {
@@ -672,6 +915,7 @@ export function DesktopController() {
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) {
@@ -761,20 +1005,6 @@ export function DesktopController() {
}
}, [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)
@@ -967,6 +1197,7 @@ export function DesktopController() {
{commandCenterOpen && (
<CommandCenterView
initialSection={commandCenterInitialSection}
onClose={closeOverlayToPreviousRoute}
onDeleteSession={removeSession}
onMainModelChanged={(provider, model) => {
@@ -1001,7 +1232,9 @@ export function DesktopController() {
}
}}
onEdit={editMessage}
onOpenCommandCenterSystem={() => openCommandCenterSection('system')}
onOpenModelPicker={() => setModelPickerOpen(true)}
onOpenSkills={() => navigate(SKILLS_ROUTE)}
onPasteClipboardImage={() => void pasteClipboardImage()}
onPickFiles={() => void pickContextPaths('file')}
onPickFolders={() => void pickContextPaths('folder')}
@@ -1016,28 +1249,37 @@ export function DesktopController() {
onThreadMessagesChange={handleThreadMessagesChange}
onToggleSelectedPin={toggleSelectedPin}
onTranscribeAudio={transcribeVoiceAudio}
setStatusbarItemGroup={setStatusbarItemGroup}
setTitlebarToolGroup={setTitlebarToolGroup}
/>
)
return (
<AppShell
commandCenterOpen={commandCenterOpen}
inspectorWidth={SESSION_INSPECTOR_WIDTH}
leftTitlebarTools={leftTitlebarTools}
leftStatusbarItems={leftStatusbarItems}
leftTitlebarTools={titlebarToolGroups.flat.left}
onOpenSettings={openSettings}
onToggleCommandCenter={toggleCommandCenter}
overlays={overlays}
previewWidth={PREVIEW_RAIL_WIDTH}
rightRailOpen={chatOpen}
sidebar={sidebar}
titlebarTools={titlebarTools}
statusbarItems={statusbarItems}
titlebarTools={titlebarToolGroups.flat.right}
>
<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={<SkillsView setStatusbarItemGroup={setStatusbarItemGroup} setTitlebarToolGroup={setTitlebarToolGroup} />}
path="skills"
/>
<Route
element={
<ArtifactsView setStatusbarItemGroup={setStatusbarItemGroup} 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" />