mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-12 08:51:53 +00:00
feat: better tool parsing ui
This commit is contained in:
parent
d1d0ed4016
commit
5f334e86fd
24 changed files with 1865 additions and 244 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
187
apps/desktop/src/app/shell/statusbar-controls.tsx
Normal file
187
apps/desktop/src/app/shell/statusbar-controls.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
]
|
||||
|
||||
|
|
|
|||
39
apps/desktop/src/app/shell/use-group-registry.ts
Normal file
39
apps/desktop/src/app/shell/use-group-registry.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
91
apps/desktop/src/lib/statusbar.ts
Normal file
91
apps/desktop/src/lib/statusbar.ts
Normal 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
|
||||
}
|
||||
99
apps/desktop/src/store/activity.ts
Normal file
99
apps/desktop/src/store/activity.ts
Normal 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)
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
15
apps/desktop/src/store/tool-view.ts
Normal file
15
apps/desktop/src/store/tool-view.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue