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

View file

@ -23,6 +23,7 @@ import { notify, notifyError } from '@/store/notifications'
import type { SessionInfo, SessionMessage } from '@/types/hermes'
import { sessionRoute } from '../routes'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
import { titlebarHeaderBaseClass } from '../shell/titlebar'
import type { SetTitlebarToolGroup } from '../shell/titlebar-controls'
@ -341,10 +342,11 @@ function paginationItems(page: number, pageCount: number): Array<number | 'ellip
}
interface ArtifactsViewProps extends React.ComponentProps<'section'> {
setStatusbarItemGroup?: SetStatusbarItemGroup
setTitlebarToolGroup?: SetTitlebarToolGroup
}
export function ArtifactsView({ setTitlebarToolGroup, ...props }: ArtifactsViewProps) {
export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, setTitlebarToolGroup, ...props }: ArtifactsViewProps) {
const navigate = useNavigate()
const [artifacts, setArtifacts] = useState<ArtifactRecord[] | null>(null)
const [query, setQuery] = useState('')
@ -510,7 +512,7 @@ export function ArtifactsView({ setTitlebarToolGroup, ...props }: ArtifactsViewP
return (
<section
{...props}
className="flex h-[calc(100vh-0.375rem)] min-w-0 flex-col overflow-hidden rounded-[0.9375rem] bg-background"
className="flex h-full min-w-0 flex-col overflow-hidden rounded-[0.9375rem] bg-background"
>
<header className={titlebarHeaderBaseClass}>
<h2 className="pointer-events-auto text-base font-semibold leading-none tracking-tight">Artifacts</h2>

View file

@ -39,6 +39,7 @@ import {
import type { ModelOptionsResponse } from '@/types/hermes'
import { routeSessionId } from '../routes'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
import { titlebarHeaderBaseClass, titlebarHeaderShadowClass } from '../shell/titlebar'
import type { SetTitlebarToolGroup } from '../shell/titlebar-controls'
@ -72,10 +73,13 @@ interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
onSetFastMode: (enabled: boolean) => void
onSetReasoningEffort: (effort: string) => void
onSelectPersonality: (name: string) => void
onOpenCommandCenterSystem: () => void
onOpenSkills: () => void
onThreadMessagesChange: (messages: readonly ThreadMessage[]) => void
onEdit: (message: AppendMessage) => Promise<void>
onReload: (parentId: string | null) => Promise<void>
onTranscribeAudio?: (audio: Blob) => Promise<string>
setStatusbarItemGroup?: SetStatusbarItemGroup
setTitlebarToolGroup?: SetTitlebarToolGroup
}
@ -121,17 +125,20 @@ export function ChatView({
onPickImages,
onRemoveAttachment,
onSubmit,
onChangeCwd,
onBrowseCwd,
onOpenModelPicker,
onChangeCwd: _onChangeCwd,
onBrowseCwd: _onBrowseCwd,
onOpenModelPicker: _onOpenModelPicker,
onRestartPreviewServer,
onSetFastMode,
onSetReasoningEffort,
onSelectPersonality,
onSetFastMode: _onSetFastMode,
onSetReasoningEffort: _onSetReasoningEffort,
onSelectPersonality: _onSelectPersonality,
onOpenCommandCenterSystem,
onOpenSkills,
onThreadMessagesChange,
onEdit,
onReload,
onTranscribeAudio,
setStatusbarItemGroup: _setStatusbarItemGroup,
setTitlebarToolGroup
}: ChatViewProps) {
const location = useLocation()
@ -266,7 +273,7 @@ export function ChatView({
<>
<div
className={cn(
'relative col-start-2 col-end-3 row-start-1 flex h-[calc(100vh-0.375rem)] min-w-0 flex-col overflow-hidden rounded-[0.9375rem] bg-transparent',
'relative col-start-2 col-end-3 row-start-1 flex h-full min-w-0 flex-col overflow-hidden rounded-[0.9375rem] bg-transparent',
className
)}
>
@ -337,12 +344,8 @@ export function ChatView({
<ChatPreviewRail onRestartServer={onRestartPreviewServer} setTitlebarToolGroup={setTitlebarToolGroup} />
<ChatRightRail
onBrowseCwd={onBrowseCwd}
onChangeCwd={onChangeCwd}
onOpenModelPicker={onOpenModelPicker}
onSelectPersonality={onSelectPersonality}
onSetFastMode={onSetFastMode}
onSetReasoningEffort={onSetReasoningEffort}
onOpenCommandCenterSystem={onOpenCommandCenterSystem}
onOpenSkills={onOpenSkills}
/>
</>
)

View file

@ -1,57 +1,44 @@
import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { type ReactNode, useMemo } from 'react'
import type { SetTitlebarToolGroup } from '@/app/shell/titlebar-controls'
import { SESSION_INSPECTOR_WIDTH, SessionInspector } from '@/components/session-inspector'
import { Button } from '@/components/ui/button'
import { AlertCircle, Loader2, Sparkles } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $desktopActionTasks, buildRailTasks, type RailTask, type RailTaskStatus } from '@/store/activity'
import { $inspectorOpen } from '@/store/layout'
import { $previewReloadRequest, $previewTarget } from '@/store/preview'
import {
$availablePersonalities,
$busy,
$currentBranch,
$currentCwd,
$currentFastMode,
$currentModel,
$currentPersonality,
$currentProvider,
$currentReasoningEffort,
$currentServiceTier,
$gatewayState
} from '@/store/session'
import { $previewReloadRequest, $previewServerRestart, $previewTarget } from '@/store/preview'
import { $sessions, $workingSessionIds } from '@/store/session'
import { PreviewPane } from './preview-pane'
interface ChatRightRailProps extends Pick<
React.ComponentProps<typeof SessionInspector>,
'onBrowseCwd' | 'onChangeCwd'
> {
onOpenModelPicker: () => void
onSetFastMode: (enabled: boolean) => void
onSetReasoningEffort: (effort: string) => void
onSelectPersonality: (name: string) => void
export const SESSION_INSPECTOR_WIDTH = 'clamp(13.5rem, 21vw, 20rem)'
export const PREVIEW_RAIL_WIDTH = 'clamp(18rem, 36vw, 38rem)'
const RAIL_TASK_LIMIT = 6
const TASK_ICONS: Record<RailTaskStatus, ReactNode> = {
error: <AlertCircle className="size-3 text-destructive" />,
running: <Loader2 className="size-3 animate-spin text-muted-foreground" />,
success: <Sparkles className="size-3 text-emerald-500" />
}
export function ChatRightRail({
onBrowseCwd,
onChangeCwd,
onOpenModelPicker,
onSetFastMode,
onSetReasoningEffort,
onSelectPersonality
}: ChatRightRailProps) {
interface ChatRightRailProps {
onOpenCommandCenterSystem: () => void
onOpenSkills: () => void
}
export function ChatRightRail({ onOpenCommandCenterSystem, onOpenSkills }: ChatRightRailProps) {
const inspectorOpen = useStore($inspectorOpen)
const gatewayOpen = useStore($gatewayState) === 'open'
const busy = useStore($busy)
const cwd = useStore($currentCwd)
const branch = useStore($currentBranch)
const model = useStore($currentModel)
const provider = useStore($currentProvider)
const reasoningEffort = useStore($currentReasoningEffort)
const serviceTier = useStore($currentServiceTier)
const fastMode = useStore($currentFastMode)
const personality = useStore($currentPersonality)
const personalities = useStore($availablePersonalities)
const sessions = useStore($sessions)
const workingSessionIds = useStore($workingSessionIds)
const previewRestart = useStore($previewServerRestart)
const desktopActionTasks = useStore($desktopActionTasks)
const tasks = useMemo(
() => buildRailTasks(workingSessionIds, sessions, previewRestart, desktopActionTasks),
[desktopActionTasks, previewRestart, sessions, workingSessionIds]
)
return (
<div
@ -60,26 +47,21 @@ export function ChatRightRail({
inspectorOpen && 'border-l border-border/60'
)}
>
<SessionInspector
branch={branch}
busy={busy}
cwd={cwd}
fastMode={fastMode}
modelLabel={model ? model.split('/').pop() || model : ''}
modelTitle={provider ? `${provider}: ${model || ''}` : model}
onBrowseCwd={onBrowseCwd}
onChangeCwd={onChangeCwd}
onOpenModelPicker={gatewayOpen ? onOpenModelPicker : undefined}
onSelectPersonality={gatewayOpen ? onSelectPersonality : undefined}
onSetFastMode={gatewayOpen ? onSetFastMode : undefined}
onSetReasoningEffort={gatewayOpen ? onSetReasoningEffort : undefined}
open={inspectorOpen}
personalities={personalities}
personality={personality}
providerName={provider}
reasoningEffort={reasoningEffort}
serviceTier={serviceTier}
/>
<aside
aria-hidden={!inspectorOpen}
className={cn(
'relative flex h-full w-full min-w-0 flex-col overflow-hidden bg-transparent pb-2 pl-2 pr-3 pt-[calc(var(--titlebar-height)+0.25rem)] text-muted-foreground transition-none',
inspectorOpen ? 'opacity-100' : 'pointer-events-none opacity-0'
)}
>
<RailHeader onOpenAll={onOpenCommandCenterSystem} />
<div className="flex min-h-0 flex-1 flex-col gap-1.5 overflow-y-auto px-1.5">
{tasks.length === 0 ? <EmptyRail /> : tasks.slice(0, RAIL_TASK_LIMIT).map(task => <RailRow key={task.id} task={task} />)}
</div>
<RailFooter onOpenSkills={onOpenSkills} onOpenSystem={onOpenCommandCenterSystem} />
</aside>
</div>
)
}
@ -110,5 +92,46 @@ export function ChatPreviewRail({
)
}
export { SESSION_INSPECTOR_WIDTH }
export const PREVIEW_RAIL_WIDTH = 'clamp(18rem, 36vw, 38rem)'
function RailHeader({ onOpenAll }: { onOpenAll: () => void }) {
return (
<div className="mb-2 flex items-center justify-between gap-1 px-1.5">
<span className="text-[0.68rem] font-semibold uppercase tracking-[0.07em] text-muted-foreground/85">Background</span>
<Button className="h-6 px-2 text-[0.68rem]" onClick={onOpenAll} size="sm" variant="ghost">
View all
</Button>
</div>
)
}
function RailFooter({ onOpenSkills, onOpenSystem }: { onOpenSkills: () => void; onOpenSystem: () => void }) {
return (
<div className="mt-2 flex items-center gap-1 px-1.5">
<Button className="h-6 flex-1 justify-start px-2 text-[0.68rem]" onClick={onOpenSkills} size="sm" variant="ghost">
Agents
</Button>
<Button className="h-6 flex-1 justify-start px-2 text-[0.68rem]" onClick={onOpenSystem} size="sm" variant="ghost">
System
</Button>
</div>
)
}
function EmptyRail() {
return (
<div className="rounded-md border border-border/45 bg-background/55 px-2.5 py-2 text-[0.68rem] text-muted-foreground/80">
No background activity.
</div>
)
}
function RailRow({ task }: { task: RailTask }) {
return (
<div className="rounded-md border border-border/45 bg-background/58 px-2 py-1.5">
<div className="flex items-center gap-1.5">
{TASK_ICONS[task.status]}
<span className="truncate text-[0.72rem] font-medium text-foreground/90">{task.label}</span>
</div>
<div className="mt-0.5 truncate pl-4.5 text-[0.66rem] text-muted-foreground/80">{task.detail}</div>
</div>
)
}

View file

@ -769,7 +769,7 @@ export function PreviewPane({ onRestartServer, reloadRequest = 0, setTitlebarToo
}, [appendConsoleEntry, target.url])
return (
<aside className="relative flex h-screen w-full min-w-0 flex-col overflow-hidden border-l border-border/60 bg-background text-muted-foreground">
<aside className="relative flex h-full w-full min-w-0 flex-col overflow-hidden border-l border-border/60 bg-background text-muted-foreground">
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="pointer-events-none flex min-h-(--titlebar-height) items-center gap-1.5 border-b border-border/60 bg-background px-2 py-1">
<div className="min-w-0 flex-1">

View file

@ -100,7 +100,7 @@ export function ChatSidebar({
return (
<Sidebar
className={cn(
'relative h-screen min-w-0 overflow-hidden border-r border-t-0 border-b-0 border-l-0 text-foreground transition-none [backdrop-filter:blur(1.5rem)_saturate(1.08)]',
'relative h-full min-w-0 overflow-hidden border-r border-t-0 border-b-0 border-l-0 text-foreground transition-none [backdrop-filter:blur(1.5rem)_saturate(1.08)]',
sidebarOpen
? 'border-(--sidebar-edge-border) bg-[color-mix(in_srgb,var(--dt-sidebar-bg)_97%,transparent)] opacity-100'
: 'pointer-events-none border-transparent bg-transparent opacity-0'

View file

@ -35,6 +35,7 @@ import { triggerHaptic } from '@/lib/haptics'
import { Activity, AlertCircle, Cpu, Pin } from '@/lib/icons'
import { exportSession } from '@/lib/session-export'
import { cn } from '@/lib/utils'
import { upsertDesktopActionTask } from '@/store/activity'
import { $pinnedSessionIds, pinSession, unpinSession } from '@/store/layout'
import { $sessions } from '@/store/session'
@ -44,9 +45,10 @@ import { OverlayMain, OverlayNavItem, OverlaySidebar, OverlaySplitLayout } from
import { OverlayView } from '../overlays/overlay-view'
import { ARTIFACTS_ROUTE, NEW_CHAT_ROUTE, SETTINGS_ROUTE, SKILLS_ROUTE } from '../routes'
type CommandCenterSection = 'models' | 'sessions' | 'system'
export type CommandCenterSection = 'models' | 'sessions' | 'system'
interface CommandCenterViewProps {
initialSection?: CommandCenterSection
onClose: () => void
onDeleteSession: (sessionId: string) => Promise<void>
onMainModelChanged?: (provider: string, model: string) => void
@ -174,6 +176,7 @@ function useDebouncedValue<T>(value: T, delayMs: number): T {
}
export function CommandCenterView({
initialSection,
onClose,
onDeleteSession,
onMainModelChanged,
@ -182,7 +185,7 @@ export function CommandCenterView({
}: CommandCenterViewProps) {
const sessions = useStore($sessions)
const pinnedSessionIds = useStore($pinnedSessionIds)
const [section, setSection] = useState<CommandCenterSection>('sessions')
const [section, setSection] = useState<CommandCenterSection>(initialSection ?? 'sessions')
const [query, setQuery] = useState('')
const [searchLoading, setSearchLoading] = useState(false)
const [searchGroups, setSearchGroups] = useState<CommandCenterSearchGroup[]>([])
@ -316,6 +319,12 @@ export function CommandCenterView({
}
}, [])
useEffect(() => {
if (initialSection && initialSection !== section) {
setSection(initialSection)
}
}, [initialSection, section])
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
@ -405,6 +414,7 @@ export function CommandCenterView({
const polled = await getActionStatus(started.name, 180)
nextStatus = polled
setSystemAction(polled)
upsertDesktopActionTask(polled)
if (!polled.running) {
break
@ -412,13 +422,16 @@ export function CommandCenterView({
}
if (!nextStatus) {
setSystemAction({
const pendingStatus = {
exit_code: null,
lines: ['Action started, waiting for status...'],
name: started.name,
pid: started.pid,
running: true
})
}
setSystemAction(pendingStatus)
upsertDesktopActionTask(pendingStatus)
}
} catch (error) {
setSystemError(error instanceof Error ? error.message : String(error))

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" />

View file

@ -25,7 +25,9 @@ import {
setCurrentPersonality,
setCurrentProvider,
setCurrentReasoningEffort,
setCurrentServiceTier
setCurrentServiceTier,
setCurrentUsage,
setTurnStartedAt
} from '@/store/session'
import { recordToolDiff } from '@/store/tool-diffs'
import type { RpcEvent } from '@/types/hermes'
@ -366,6 +368,10 @@ export function useMessageStream({
}
}
if (payload?.usage && (!explicitSid || isActiveEvent)) {
setCurrentUsage(current => ({ ...current, ...payload.usage }))
}
void refreshHermesConfig()
if (modelChanged || providerChanged) {
@ -389,6 +395,10 @@ export function useMessageStream({
sawAssistantPayload: false,
interrupted: false
}))
if (isActiveEvent) {
setTurnStartedAt(Date.now())
}
} else if (event.type === 'message.delta') {
if (sessionId) {
appendAssistantDelta(sessionId, coerceGatewayText(payload?.text))
@ -417,6 +427,14 @@ export function useMessageStream({
const finalText = coerceGatewayText(payload?.text) || coerceGatewayText(payload?.rendered)
completeAssistantMessage(sessionId, finalText)
if (isActiveEvent) {
setTurnStartedAt(null)
}
if (payload?.usage) {
setCurrentUsage(current => ({ ...current, ...payload.usage }))
}
} else if (event.type === 'tool.start' || event.type === 'tool.progress' || event.type === 'tool.generating') {
if (!sessionId) {
return
@ -466,6 +484,10 @@ export function useMessageStream({
busy: false
}))
}
if (isActiveEvent) {
setTurnStartedAt(null)
}
}
},
[

View file

@ -23,13 +23,16 @@ import {
setCurrentProvider,
setCurrentReasoningEffort,
setCurrentServiceTier,
setCurrentUsage,
setFreshDraftReady,
setIntroSeed,
setMessages,
setSelectedStoredSessionId,
setSessions
setSessionStartedAt,
setSessions,
setTurnStartedAt
} from '@/store/session'
import type { SessionCreateResponse, SessionInfo, SessionResumeResponse } from '@/types/hermes'
import type { SessionCreateResponse, SessionInfo, SessionResumeResponse, UsageStats } from '@/types/hermes'
import { NEW_CHAT_ROUTE, sessionRoute, SETTINGS_ROUTE } from '../../routes'
import type { ClientSessionState, SidebarNavItem } from '../../types'
@ -215,6 +218,10 @@ function applyRuntimeInfo(info: SessionCreateResponse['info'] | undefined) {
if (typeof info.fast === 'boolean') {
setCurrentFastMode(info.fast)
}
if (info.usage) {
setCurrentUsage(current => ({ ...current, ...info.usage }))
}
}
export function useSessionActions({
@ -248,6 +255,14 @@ export function useSessionActions({
setSelectedStoredSessionId(null)
selectedStoredSessionIdRef.current = null
setMessages([])
setCurrentUsage({
calls: 0,
input: 0,
output: 0,
total: 0
})
setSessionStartedAt(null)
setTurnStartedAt(null)
clearComposerDraft()
clearComposerAttachments()
setFreshDraftReady(true)
@ -288,6 +303,7 @@ export function useSessionActions({
setFreshDraftReady(false)
setActiveSessionId(created.session_id)
setSelectedStoredSessionId(stored)
setSessionStartedAt(Date.now())
applyRuntimeInfo(created.info)
return created.session_id
@ -354,9 +370,18 @@ export function useSessionActions({
setActiveSessionId(cachedRuntimeId)
activeSessionIdRef.current = cachedRuntimeId
syncSessionStateToView(cachedRuntimeId, cachedState)
setSessionStartedAt(Date.now())
clearComposerDraft()
clearComposerAttachments()
void requestGateway<UsageStats>('session.usage', { session_id: cachedRuntimeId })
.then(usage => {
if (isCurrentResume() && usage) {
setCurrentUsage(current => ({ ...current, ...usage }))
}
})
.catch(() => undefined)
return
}
@ -369,6 +394,17 @@ export function useSessionActions({
clearNotifications()
setSelectedStoredSessionId(storedSessionId)
selectedStoredSessionIdRef.current = storedSessionId
setSessionStartedAt(Date.now())
const stored = $sessions.get().find(session => session.id === storedSessionId)
if (stored) {
setCurrentUsage(current => ({
...current,
input: stored.input_tokens || 0,
output: stored.output_tokens || 0,
total: (stored.input_tokens || 0) + (stored.output_tokens || 0)
}))
}
try {
// Load the local snapshot first, then ask the gateway to resume.
@ -619,6 +655,17 @@ export function useSessionActions({
setFreshDraftReady(false)
setSelectedStoredSessionId(storedSessionId)
selectedStoredSessionIdRef.current = storedSessionId
const stored = $sessions.get().find(session => session.id === storedSessionId)
if (stored) {
setCurrentUsage(current => ({
...current,
input: stored.input_tokens || 0,
output: stored.output_tokens || 0,
total: (stored.input_tokens || 0) + (stored.output_tokens || 0)
}))
}
setMessages(previousMessages)
navigate(sessionRoute(storedSessionId), { replace: true })

View file

@ -1,6 +1,9 @@
import { useStore } from '@nanostores/react'
import { triggerHaptic } from '@/lib/haptics'
import { Check, Palette } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $toolViewMode, setToolViewMode } from '@/store/tool-view'
import { useTheme } from '@/themes/context'
import { BUILTIN_THEMES } from '@/themes/presets'
@ -50,6 +53,7 @@ function ThemePreview({ name }: { name: string }) {
export function AppearanceSettings() {
const { themeName, mode, availableThemes, setTheme, setMode } = useTheme()
const toolViewMode = useStore($toolViewMode)
const activeTheme = availableThemes.find(t => t.name === themeName)
return (
@ -108,6 +112,61 @@ export function AppearanceSettings() {
</div>
</section>
<section className="rounded-2xl border border-border/50 bg-card/55 p-4 shadow-sm">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium">Tool Call Display</div>
<div className="mt-1 text-xs text-muted-foreground">
Product hides raw tool payloads; Technical shows full input/output.
</div>
</div>
<Pill>{toolViewMode === 'technical' ? 'Technical' : 'Product'}</Pill>
</div>
<div className="grid gap-2 sm:grid-cols-2">
{(
[
{
id: 'product',
label: 'Product',
description: 'Human-friendly tool activity with concise summaries.'
},
{
id: 'technical',
label: 'Technical',
description: 'Include raw tool args/results and low-level details.'
}
] as const
).map(option => {
const active = toolViewMode === option.id
return (
<button
className={cn(
'group rounded-xl border border-border/45 bg-background/55 p-3 text-left transition hover:border-primary/35 hover:bg-accent/45',
active && 'border-primary/65 bg-primary/8 ring-2 ring-primary/25'
)}
key={option.id}
onClick={() => {
triggerHaptic('selection')
setToolViewMode(option.id)
}}
type="button"
>
<div className="flex items-start justify-between gap-3">
<div className="text-sm font-medium">{option.label}</div>
{active && (
<span className="grid size-5 place-items-center rounded-full bg-primary text-primary-foreground">
<Check className="size-3.5" />
</span>
)}
</div>
<div className="mt-1 text-xs leading-5 text-muted-foreground">{option.description}</div>
</button>
)
})}
</div>
</section>
<section className="rounded-2xl border border-border/50 bg-card/55 p-4 shadow-sm">
<div className="mb-3 flex items-center justify-between gap-3">
<div>

View file

@ -15,33 +15,34 @@ import {
import { $previewTarget } from '@/store/preview'
import { $connection } from '@/store/session'
import { StatusbarControls, type StatusbarItem } from './statusbar-controls'
import { TITLEBAR_HEIGHT, titlebarControlsPosition } from './titlebar'
import { TitlebarControls, type TitlebarTool } from './titlebar-controls'
interface AppShellProps {
commandCenterOpen: boolean
children: ReactNode
inspectorWidth: string
leftTitlebarTools?: readonly TitlebarTool[]
leftStatusbarItems?: readonly StatusbarItem[]
previewWidth: string
rightRailOpen: boolean
sidebar: ReactNode
statusbarItems?: readonly StatusbarItem[]
titlebarTools?: readonly TitlebarTool[]
onToggleCommandCenter: () => void
onOpenSettings: () => void
overlays?: ReactNode
}
export function AppShell({
commandCenterOpen,
children,
inspectorWidth,
leftTitlebarTools,
leftStatusbarItems,
previewWidth,
rightRailOpen,
sidebar,
statusbarItems,
titlebarTools,
onToggleCommandCenter,
onOpenSettings,
overlays
}: AppShellProps) {
@ -135,10 +136,8 @@ export function AppShell({
}
>
<TitlebarControls
commandCenterOpen={commandCenterOpen}
leftTools={leftTitlebarTools}
onOpenSettings={onOpenSettings}
onToggleCommandCenter={onToggleCommandCenter}
showInspectorToggle={rightRailOpen}
tools={titlebarTools}
/>
@ -149,7 +148,8 @@ export function AppShell({
{
'--inspector-col': inspectorColumn,
'--preview-col': previewColumn,
gridTemplateColumns: shellGridColumns
gridTemplateColumns: shellGridColumns,
gridTemplateRows: 'minmax(0,1fr) auto'
} as CSSProperties
}
>
@ -162,7 +162,7 @@ export function AppShell({
className="pointer-events-none absolute top-0 z-1 h-(--titlebar-height) left-[calc(var(--titlebar-controls-left)+(var(--titlebar-control-size)*2)+0.75rem)] right-[calc(var(--titlebar-tools-right)+var(--titlebar-tools-width)+0.75rem)] [-webkit-app-region:drag]"
/>
{sidebar}
<div className="col-start-1 col-end-2 row-start-1 min-h-0 overflow-hidden">{sidebar}</div>
{sidebarOpen && (
<div
@ -178,6 +178,8 @@ export function AppShell({
)}
{children}
<StatusbarControls items={statusbarItems} leftItems={leftStatusbarItems} />
</main>
{overlays}

View file

@ -0,0 +1,187 @@
import type { ComponentProps, ReactNode } from 'react'
import { useNavigate } from 'react-router-dom'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { cn } from '@/lib/utils'
export interface StatusbarMenuItem {
id: string
icon?: ReactNode
label: string
className?: string
disabled?: boolean
hidden?: boolean
href?: string
onSelect?: () => void
title?: string
to?: string
}
export interface StatusbarItem {
id: string
label?: ReactNode
detail?: ReactNode
icon?: ReactNode
className?: string
disabled?: boolean
hidden?: boolean
href?: string
menuClassName?: string
menuItems?: readonly StatusbarMenuItem[]
onSelect?: () => void
title?: string
to?: string
variant?: 'action' | 'link' | 'menu' | 'text'
}
export type StatusbarItemSide = 'left' | 'right'
export type SetStatusbarItemGroup = (id: string, items: readonly StatusbarItem[], side?: StatusbarItemSide) => void
interface StatusbarControlsProps extends ComponentProps<'footer'> {
leftItems?: readonly StatusbarItem[]
items?: readonly StatusbarItem[]
}
const statusbarItemClass =
'inline-flex h-6 items-center gap-1.5 rounded-md px-2 text-[0.69rem] text-muted-foreground/95 transition-colors hover:bg-accent/55 hover:text-foreground disabled:cursor-default disabled:opacity-45'
export function StatusbarControls({ className, leftItems = [], items = [], ...props }: StatusbarControlsProps) {
const navigate = useNavigate()
return (
<footer
className={cn(
'col-span-4 row-start-2 row-end-3 flex h-7 items-center justify-between gap-2 border-t border-border/55 bg-[color-mix(in_srgb,var(--dt-muted)_45%,var(--dt-card))] px-2.5 py-1 text-muted-foreground/95 [-webkit-app-region:no-drag]',
className
)}
{...props}
>
<div className="flex min-w-0 items-center gap-1 overflow-x-auto">
{leftItems.filter(item => !item.hidden).map(item => (
<StatusbarItemView item={item} key={`left:${item.id}`} navigate={navigate} />
))}
</div>
<div className="flex min-w-0 items-center gap-1 overflow-x-auto">
{items.filter(item => !item.hidden).map(item => (
<StatusbarItemView item={item} key={`right:${item.id}`} navigate={navigate} />
))}
</div>
</footer>
)
}
function StatusbarItemView({
item,
navigate
}: {
item: StatusbarItem
navigate: ReturnType<typeof useNavigate>
}) {
const content = (
<>
{item.icon}
{item.label && <span className="truncate">{item.label}</span>}
{item.detail && <span className="truncate text-muted-foreground/80">{item.detail}</span>}
</>
)
const title = item.title ?? (typeof item.label === 'string' ? item.label : undefined)
if (item.variant === 'menu' && item.menuItems && item.menuItems.length > 0) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className={cn(statusbarItemClass, item.className)}
disabled={item.disabled}
title={title}
type="button"
>
{content}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className={cn('w-56', item.menuClassName)} side="top" sideOffset={8}>
{item.menuItems
.filter(menuItem => !menuItem.hidden)
.map(menuItem => (
<DropdownMenuItem
className={cn('gap-2 text-foreground focus:bg-accent [&_svg]:size-4', menuItem.className)}
disabled={menuItem.disabled}
key={menuItem.id}
onSelect={() => {
if (menuItem.to) {
navigate(menuItem.to)
}
menuItem.onSelect?.()
}}
>
{menuItem.href ? (
<a
className="inline-flex w-full items-center gap-2"
href={menuItem.href}
rel="noreferrer"
target="_blank"
title={menuItem.title ?? menuItem.label}
>
{menuItem.icon}
<span className="truncate">{menuItem.label}</span>
</a>
) : (
<>
{menuItem.icon}
<span className="truncate">{menuItem.label}</span>
</>
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
if (item.variant === 'text' && !item.onSelect && !item.to && !item.href) {
return (
<div className={cn('inline-flex items-center gap-1.5 px-1 text-[0.69rem] text-muted-foreground/90', item.className)}>
{content}
</div>
)
}
if (item.href || item.variant === 'link') {
return (
<a
className={cn(statusbarItemClass, item.className)}
href={item.href}
rel="noreferrer"
target="_blank"
title={title}
>
{content}
</a>
)
}
return (
<button
className={cn(statusbarItemClass, item.className)}
disabled={item.disabled}
onClick={() => {
if (item.to) {
navigate(item.to)
}
item.onSelect?.()
}}
title={title}
type="button"
>
{content}
</button>
)
}

View file

@ -3,12 +3,12 @@ import type { ComponentProps, ReactNode } from 'react'
import { useNavigate } from 'react-router-dom'
import { triggerHaptic } from '@/lib/haptics'
import { Command, NotebookTabs, Settings, SlidersHorizontal, Volume2, VolumeX } from '@/lib/icons'
import { NotebookTabs, Settings, SlidersHorizontal, Volume2, VolumeX } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $hapticsMuted, toggleHapticsMuted } from '@/store/haptics'
import { $inspectorOpen, $sidebarOpen, toggleInspectorOpen, toggleSidebarOpen } from '@/store/layout'
import { TITLEBAR_ICON_SIZE, titlebarButtonClass } from './titlebar'
import { titlebarButtonClass } from './titlebar'
export interface TitlebarTool {
id: string
@ -28,20 +28,16 @@ export type TitlebarToolSide = 'left' | 'right'
export type SetTitlebarToolGroup = (id: string, tools: readonly TitlebarTool[], side?: TitlebarToolSide) => void
interface TitlebarControlsProps extends ComponentProps<'div'> {
commandCenterOpen: boolean
leftTools?: readonly TitlebarTool[]
showInspectorToggle: boolean
tools?: readonly TitlebarTool[]
onToggleCommandCenter: () => void
onOpenSettings: () => void
}
export function TitlebarControls({
commandCenterOpen,
leftTools = [],
showInspectorToggle,
tools = [],
onToggleCommandCenter,
onOpenSettings
}: TitlebarControlsProps) {
const navigate = useNavigate()
@ -71,17 +67,6 @@ export function TitlebarControls({
toggleSidebarOpen()
}
},
{
active: commandCenterOpen,
icon: <Command size={TITLEBAR_ICON_SIZE} />,
id: 'command-center',
label: commandCenterOpen ? 'Close command center' : 'Open command center',
title: commandCenterOpen ? 'Close command center' : 'Open command center',
onSelect: () => {
triggerHaptic('tap')
onToggleCommandCenter()
}
},
...leftTools
]

View file

@ -0,0 +1,39 @@
import { useCallback, useMemo, useState } from 'react'
type Side = 'left' | 'right'
type Groups<T> = Record<Side, Record<string, readonly T[]>>
export type GroupSetter<T> = (id: string, items: readonly T[], side?: Side) => void
interface GroupRegistry<T> {
flat: { left: T[]; right: T[] }
set: GroupSetter<T>
}
export function useGroupRegistry<T>(): GroupRegistry<T> {
const [groups, setGroups] = useState<Groups<T>>({ left: {}, right: {} })
const set = useCallback<GroupSetter<T>>((id, items, side = 'right') => {
setGroups(current => {
const next = { ...current, [side]: { ...current[side] } }
if (items.length === 0) {
delete next[side][id]
} else {
next[side][id] = items
}
return next
})
}, [])
const flat = useMemo(
() => ({
left: Object.values(groups.left).flat(),
right: Object.values(groups.right).flat()
}),
[groups]
)
return { flat, set }
}

View file

@ -13,6 +13,7 @@ import { notify, notifyError } from '@/store/notifications'
import type { SkillInfo, ToolsetInfo } from '@/types/hermes'
import { asText, includesQuery, prettyName, toolNames } from '../settings/helpers'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
import { titlebarHeaderBaseClass } from '../shell/titlebar'
import type { SetTitlebarToolGroup } from '../shell/titlebar-controls'
@ -60,10 +61,11 @@ function filteredToolsets(toolsets: ToolsetInfo[], query: string): ToolsetInfo[]
}
interface SkillsViewProps extends React.ComponentProps<'section'> {
setStatusbarItemGroup?: SetStatusbarItemGroup
setTitlebarToolGroup?: SetTitlebarToolGroup
}
export function SkillsView({ setTitlebarToolGroup, ...props }: SkillsViewProps) {
export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, setTitlebarToolGroup, ...props }: SkillsViewProps) {
const [mode, setMode] = useState<SkillsMode>('skills')
const [query, setQuery] = useState('')
const [skills, setSkills] = useState<SkillInfo[] | null>(null)
@ -168,7 +170,7 @@ export function SkillsView({ setTitlebarToolGroup, ...props }: SkillsViewProps)
return (
<section
{...props}
className="flex h-[calc(100vh-0.375rem)] min-w-0 flex-col overflow-hidden rounded-[0.9375rem] bg-background"
className="flex h-full min-w-0 flex-col overflow-hidden rounded-[0.9375rem] bg-background"
>
<header className={titlebarHeaderBaseClass}>
<h2 className="pointer-events-auto text-base font-semibold leading-none tracking-tight">Skills</h2>

View file

@ -484,9 +484,11 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
</ErrorPrimitive.Root>
</MessagePrimitive.Error>
</div>
<div className="min-h-6">
<AssistantFooter messageId={messageId} messageText={messageText} onBranchInNewChat={onBranchInNewChat} />
</div>
{messageText.trim().length > 0 && (
<div className="min-h-6">
<AssistantFooter messageId={messageId} messageText={messageText} onBranchInNewChat={onBranchInNewChat} />
</div>
)}
</MessagePrimitive.Root>
)
}

File diff suppressed because it is too large Load diff

View file

@ -51,7 +51,7 @@ export const SessionInspector: FC<SessionInspectorProps> = ({
<aside
aria-hidden={!open}
className={cn(
'relative flex h-screen w-full min-w-0 flex-col overflow-hidden bg-transparent pb-2 pl-2 pr-3 pt-[calc(var(--titlebar-height)+0.25rem)] text-muted-foreground transition-none',
'relative flex h-full w-full min-w-0 flex-col overflow-hidden bg-transparent pb-2 pl-2 pr-3 pt-[calc(var(--titlebar-height)+0.25rem)] text-muted-foreground transition-none',
open ? 'opacity-100' : 'pointer-events-none opacity-0'
)}
data-open={open}

View file

@ -1,7 +1,7 @@
import type { ThreadMessageLike } from '@assistant-ui/react'
import { mediaDisplayLabel, mediaMarkdownHref } from '@/lib/media'
import type { SessionMessage } from '@/types/hermes'
import type { SessionMessage, UsageStats } from '@/types/hermes'
export type ChatMessagePart = Exclude<ThreadMessageLike['content'], string>[number]
@ -38,6 +38,7 @@ export type GatewayEventPayload = {
cwd?: string
branch?: string
personality?: string
usage?: Partial<UsageStats>
// clarify.request
request_id?: string
question?: string

View file

@ -0,0 +1,91 @@
import { useEffect, useState } from 'react'
import type { UsageStats } from '@/types/hermes'
export function formatK(value: number): string {
if (!Number.isFinite(value) || value <= 0) {
return '0'
}
if (value >= 1_000_000) {
return `${(value / 1_000_000).toFixed(1)}M`
}
if (value >= 1_000) {
return `${(value / 1_000).toFixed(1)}k`
}
return `${Math.round(value)}`
}
export function formatDuration(elapsedMs: number): string {
const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1000))
const seconds = totalSeconds % 60
const minutes = Math.floor(totalSeconds / 60) % 60
const hours = Math.floor(totalSeconds / 3600)
const ss = String(seconds).padStart(2, '0')
const mm = String(minutes).padStart(2, '0')
return hours > 0 ? `${hours}:${mm}:${ss}` : `${minutes}:${ss}`
}
export function compactPath(path: string, max = 44): string {
const trimmed = path.trim()
if (trimmed.length <= max) {
return trimmed
}
const segments = trimmed.split('/').filter(Boolean)
if (segments.length < 2) {
return `${trimmed.slice(-(max - 1))}`
}
const tail = segments.slice(-2).join('/')
return tail.length + 2 >= max ? `${tail.slice(-(max - 1))}` : `…/${tail}`
}
export function contextBar(percent: number | undefined, width = 10): string {
const bounded = Math.max(0, Math.min(100, percent ?? 0))
const filled = Math.round((bounded / 100) * width)
return `${'█'.repeat(filled)}${'░'.repeat(width - filled)}`
}
export function usageContextLabel(usage: UsageStats): string {
if (usage.context_max) {
return `${formatK(usage.context_used ?? 0)}/${formatK(usage.context_max)}`
}
return usage.total > 0 ? `${formatK(usage.total)} tok` : ''
}
export function contextBarLabel(usage: UsageStats): string {
if (!usage.context_max) {
return ''
}
const pct = Math.max(0, Math.min(100, Math.round(usage.context_percent ?? 0)))
return `[${contextBar(usage.context_percent)}] ${pct}%`
}
export function LiveDuration({ since }: { since: number | null | undefined }) {
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
if (!since) {
return
}
const tick = () => setNow(Date.now())
tick()
const timer = window.setInterval(tick, 1000)
return () => window.clearInterval(timer)
}, [since])
return since ? formatDuration(now - since) : null
}

View file

@ -0,0 +1,99 @@
import { atom } from 'nanostores'
import { sessionTitle } from '@/lib/chat-runtime'
import type { PreviewServerRestart } from '@/store/preview'
import type { ActionStatusResponse, SessionInfo } from '@/types/hermes'
const HISTORY_LIMIT = 8
const COMPLETED_TTL_MS = 5 * 60 * 1000
export type RailTaskStatus = 'error' | 'running' | 'success'
export interface RailTask {
id: string
label: string
detail: string
status: RailTaskStatus
updatedAt: number
}
export interface DesktopActionTask {
status: ActionStatusResponse
updatedAt: number
}
export const $desktopActionTasks = atom<Record<string, DesktopActionTask>>({})
export function upsertDesktopActionTask(status: ActionStatusResponse): void {
$desktopActionTasks.set(prune({ ...$desktopActionTasks.get(), [status.name]: { status, updatedAt: Date.now() } }))
}
export function buildRailTasks(
workingSessionIds: readonly string[],
sessions: readonly SessionInfo[],
previewRestart: PreviewServerRestart | null,
actionTasks: Record<string, DesktopActionTask>
): RailTask[] {
const sessionsById = new Map(sessions.map(session => [session.id, session]))
const sessionTasks: RailTask[] = workingSessionIds.map((id, index) => {
const session = sessionsById.get(id)
return {
id: `session:${id}`,
label: session ? sessionTitle(session) : 'Session task',
detail: 'Agent task running',
status: 'running',
updatedAt: session?.last_active || Date.now() - index
}
})
const previewTasks: RailTask[] = previewRestart
? [
{
id: `preview:${previewRestart.taskId}`,
label: 'Preview restart',
detail: previewRestart.message || previewRestart.url,
status: previewRestart.status === 'error' ? 'error' : previewRestart.status === 'running' ? 'running' : 'success',
updatedAt: Date.now()
}
]
: []
const actions: RailTask[] = Object.values(actionTasks).map(({ status, updatedAt }) => ({
id: `action:${status.name}`,
label: status.name,
detail: actionDetail(status),
status: actionStatus(status),
updatedAt
}))
return [...sessionTasks, ...previewTasks, ...actions].sort((left, right) => right.updatedAt - left.updatedAt)
}
function actionStatus(status: ActionStatusResponse): RailTaskStatus {
if (status.running) {
return 'running'
}
return status.exit_code === 0 ? 'success' : 'error'
}
function actionDetail(status: ActionStatusResponse): string {
if (status.running) {
return 'Running'
}
return status.exit_code === 0 ? 'Completed' : `Failed (${status.exit_code ?? 'unknown'})`
}
function prune(tasks: Record<string, DesktopActionTask>): Record<string, DesktopActionTask> {
const now = Date.now()
return Object.fromEntries(
Object.entries(tasks)
.filter(([, task]) => task.status.running || now - task.updatedAt <= COMPLETED_TTL_MS)
.sort(([, left], [, right]) => right.updatedAt - left.updatedAt)
.slice(0, HISTORY_LIMIT)
)
}

View file

@ -3,7 +3,7 @@ import { atom } from 'nanostores'
import type { ContextSuggestion } from '@/app/types'
import type { HermesConnection } from '@/global'
import type { ChatMessage } from '@/lib/chat-messages'
import type { SessionInfo } from '@/types/hermes'
import type { SessionInfo, UsageStats } from '@/types/hermes'
type Updater<T> = T | ((current: T) => T)
@ -34,6 +34,14 @@ export const $currentServiceTier = atom('')
export const $currentFastMode = atom(false)
export const $currentCwd = atom('')
export const $currentBranch = atom('')
export const $currentUsage = atom<UsageStats>({
calls: 0,
input: 0,
output: 0,
total: 0
})
export const $sessionStartedAt = atom<number | null>(null)
export const $turnStartedAt = atom<number | null>(null)
export const $introPersonality = atom('')
export const $currentPersonality = atom('')
export const $availablePersonalities = atom<string[]>([])
@ -59,6 +67,9 @@ export const setCurrentServiceTier = (next: Updater<string>) => updateAtom($curr
export const setCurrentFastMode = (next: Updater<boolean>) => updateAtom($currentFastMode, next)
export const setCurrentCwd = (next: Updater<string>) => updateAtom($currentCwd, next)
export const setCurrentBranch = (next: Updater<string>) => updateAtom($currentBranch, next)
export const setCurrentUsage = (next: Updater<UsageStats>) => updateAtom($currentUsage, next)
export const setSessionStartedAt = (next: Updater<number | null>) => updateAtom($sessionStartedAt, next)
export const setTurnStartedAt = (next: Updater<number | null>) => updateAtom($turnStartedAt, next)
export const setIntroPersonality = (next: Updater<string>) => updateAtom($introPersonality, next)
export const setCurrentPersonality = (next: Updater<string>) => updateAtom($currentPersonality, next)
export const setAvailablePersonalities = (next: Updater<string[]>) => updateAtom($availablePersonalities, next)

View file

@ -0,0 +1,15 @@
import { atom } from 'nanostores'
import { persistBoolean, storedBoolean } from '@/lib/storage'
export type ToolViewMode = 'product' | 'technical'
const TOOL_VIEW_TECHNICAL_STORAGE_KEY = 'hermes.desktop.toolView.technical'
export const $toolViewMode = atom<ToolViewMode>(storedBoolean(TOOL_VIEW_TECHNICAL_STORAGE_KEY, false) ? 'technical' : 'product')
$toolViewMode.subscribe(mode => persistBoolean(TOOL_VIEW_TECHNICAL_STORAGE_KEY, mode === 'technical'))
export function setToolViewMode(mode: ToolViewMode) {
$toolViewMode.set(mode)
}

View file

@ -174,9 +174,21 @@ export interface SessionRuntimeInfo {
service_tier?: string
skills?: Record<string, string[]> | string[]
tools?: Record<string, string[]>
usage?: Partial<UsageStats>
version?: string
}
export interface UsageStats {
calls: number
context_max?: number
context_percent?: number
context_used?: number
cost_usd?: number
input: number
output: number
total: number
}
export interface SkillInfo {
category: string
description: string