diff --git a/apps/desktop/src/app/artifacts/index.tsx b/apps/desktop/src/app/artifacts/index.tsx index a9401060346..816028a3370 100644 --- a/apps/desktop/src/app/artifacts/index.tsx +++ b/apps/desktop/src/app/artifacts/index.tsx @@ -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 { + 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(null) const [query, setQuery] = useState('') @@ -510,7 +512,7 @@ export function ArtifactsView({ setTitlebarToolGroup, ...props }: ArtifactsViewP return (

Artifacts

diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index d9231d1020f..e504d152a07 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -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, '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 onReload: (parentId: string | null) => Promise onTranscribeAudio?: (audio: Blob) => Promise + 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({ <>
@@ -337,12 +344,8 @@ export function ChatView({ ) diff --git a/apps/desktop/src/app/chat/right-rail/index.tsx b/apps/desktop/src/app/chat/right-rail/index.tsx index b11166f5f91..88dcecc96cb 100644 --- a/apps/desktop/src/app/chat/right-rail/index.tsx +++ b/apps/desktop/src/app/chat/right-rail/index.tsx @@ -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, - '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 = { + error: , + running: , + success: } -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 (
- +
) } @@ -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 ( +
+ Background + +
+ ) +} + +function RailFooter({ onOpenSkills, onOpenSystem }: { onOpenSkills: () => void; onOpenSystem: () => void }) { + return ( +
+ + +
+ ) +} + +function EmptyRail() { + return ( +
+ No background activity. +
+ ) +} + +function RailRow({ task }: { task: RailTask }) { + return ( +
+
+ {TASK_ICONS[task.status]} + {task.label} +
+
{task.detail}
+
+ ) +} diff --git a/apps/desktop/src/app/chat/right-rail/preview-pane.tsx b/apps/desktop/src/app/chat/right-rail/preview-pane.tsx index 48f8f0959f1..6a56f5beb8e 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-pane.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-pane.tsx @@ -769,7 +769,7 @@ export function PreviewPane({ onRestartServer, reloadRequest = 0, setTitlebarToo }, [appendConsoleEntry, target.url]) return ( -
+
+
+
+
Tool Call Display
+
+ Product hides raw tool payloads; Technical shows full input/output. +
+
+ {toolViewMode === 'technical' ? 'Technical' : 'Product'} +
+
+ {( + [ + { + 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 ( + + ) + })} +
+
+
diff --git a/apps/desktop/src/app/shell/app-shell.tsx b/apps/desktop/src/app/shell/app-shell.tsx index 7330c3adb07..d69b0c8e1ad 100644 --- a/apps/desktop/src/app/shell/app-shell.tsx +++ b/apps/desktop/src/app/shell/app-shell.tsx @@ -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({ } > @@ -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} +
{sidebar}
{sidebarOpen && (
{overlays} diff --git a/apps/desktop/src/app/shell/statusbar-controls.tsx b/apps/desktop/src/app/shell/statusbar-controls.tsx new file mode 100644 index 00000000000..1f53d55308e --- /dev/null +++ b/apps/desktop/src/app/shell/statusbar-controls.tsx @@ -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 ( +
+
+ {leftItems.filter(item => !item.hidden).map(item => ( + + ))} +
+
+ {items.filter(item => !item.hidden).map(item => ( + + ))} +
+
+ ) +} + +function StatusbarItemView({ + item, + navigate +}: { + item: StatusbarItem + navigate: ReturnType +}) { + const content = ( + <> + {item.icon} + {item.label && {item.label}} + {item.detail && {item.detail}} + + ) + + const title = item.title ?? (typeof item.label === 'string' ? item.label : undefined) + + if (item.variant === 'menu' && item.menuItems && item.menuItems.length > 0) { + return ( + + + + + + {item.menuItems + .filter(menuItem => !menuItem.hidden) + .map(menuItem => ( + { + if (menuItem.to) { + navigate(menuItem.to) + } + + menuItem.onSelect?.() + }} + > + {menuItem.href ? ( + + {menuItem.icon} + {menuItem.label} + + ) : ( + <> + {menuItem.icon} + {menuItem.label} + + )} + + ))} + + + ) + } + + if (item.variant === 'text' && !item.onSelect && !item.to && !item.href) { + return ( +
+ {content} +
+ ) + } + + if (item.href || item.variant === 'link') { + return ( + + {content} + + ) + } + + return ( + + ) +} diff --git a/apps/desktop/src/app/shell/titlebar-controls.tsx b/apps/desktop/src/app/shell/titlebar-controls.tsx index 364936d2fc0..142fedcd417 100644 --- a/apps/desktop/src/app/shell/titlebar-controls.tsx +++ b/apps/desktop/src/app/shell/titlebar-controls.tsx @@ -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: , - id: 'command-center', - label: commandCenterOpen ? 'Close command center' : 'Open command center', - title: commandCenterOpen ? 'Close command center' : 'Open command center', - onSelect: () => { - triggerHaptic('tap') - onToggleCommandCenter() - } - }, ...leftTools ] diff --git a/apps/desktop/src/app/shell/use-group-registry.ts b/apps/desktop/src/app/shell/use-group-registry.ts new file mode 100644 index 00000000000..ef78dfde0aa --- /dev/null +++ b/apps/desktop/src/app/shell/use-group-registry.ts @@ -0,0 +1,39 @@ +import { useCallback, useMemo, useState } from 'react' + +type Side = 'left' | 'right' +type Groups = Record> + +export type GroupSetter = (id: string, items: readonly T[], side?: Side) => void + +interface GroupRegistry { + flat: { left: T[]; right: T[] } + set: GroupSetter +} + +export function useGroupRegistry(): GroupRegistry { + const [groups, setGroups] = useState>({ left: {}, right: {} }) + + const set = useCallback>((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 } +} diff --git a/apps/desktop/src/app/skills/index.tsx b/apps/desktop/src/app/skills/index.tsx index dcf99d671cb..a02a11aa442 100644 --- a/apps/desktop/src/app/skills/index.tsx +++ b/apps/desktop/src/app/skills/index.tsx @@ -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('skills') const [query, setQuery] = useState('') const [skills, setSkills] = useState(null) @@ -168,7 +170,7 @@ export function SkillsView({ setTitlebarToolGroup, ...props }: SkillsViewProps) return (

Skills

diff --git a/apps/desktop/src/components/assistant-ui/thread.tsx b/apps/desktop/src/components/assistant-ui/thread.tsx index 83c9c231b27..227974bb826 100644 --- a/apps/desktop/src/components/assistant-ui/thread.tsx +++ b/apps/desktop/src/components/assistant-ui/thread.tsx @@ -484,9 +484,11 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
-
- -
+ {messageText.trim().length > 0 && ( +
+ +
+ )} ) } diff --git a/apps/desktop/src/components/assistant-ui/tool-fallback.tsx b/apps/desktop/src/components/assistant-ui/tool-fallback.tsx index a52cc7857e0..58f4597a4b9 100644 --- a/apps/desktop/src/components/assistant-ui/tool-fallback.tsx +++ b/apps/desktop/src/components/assistant-ui/tool-fallback.tsx @@ -1,22 +1,113 @@ 'use client' -import { type ToolCallMessagePartProps } from '@assistant-ui/react' +import { type ToolCallMessagePartProps, useAuiState } from '@assistant-ui/react' import { useStore } from '@nanostores/react' -import { useEffect, useState } from 'react' +import { type ReactNode, useEffect, useMemo, useState } from 'react' import { useElapsedSeconds } from '@/components/assistant-ui/activity-timer' import { ActivityTimerText } from '@/components/assistant-ui/activity-timer-text' -import { ChevronRight } from '@/lib/icons' +import { PreviewAttachment } from '@/components/assistant-ui/preview-attachment' +import { ZoomableImage } from '@/components/assistant-ui/zoomable-image' +import { + AlertCircle, + CheckCircle2, + ChevronRight, + Command, + FileText, + Globe, + LinkIcon, + Loader2, + Search, + Sparkles, + Wrench +} from '@/lib/icons' +import type { LucideIcon } from '@/lib/icons' import { cn } from '@/lib/utils' import { $toolInlineDiffs } from '@/store/tool-diffs' +import { $toolViewMode } from '@/store/tool-view' const TOOL_SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] const TOOL_SPINNER_INTERVAL_MS = 80 +const TOOL_DETAIL_INDENT_CLASS = 'ml-[3.25rem]' + +type ToolTone = 'agent' | 'browser' | 'default' | 'file' | 'image' | 'terminal' | 'web' +type ToolStatus = 'error' | 'running' | 'success' + +interface ToolPart { + args?: unknown + isError?: boolean + result?: unknown + toolCallId?: string + toolName: string + type: 'tool-call' +} + +interface SearchResultRow { + snippet: string + title: string + url: string +} + +interface ToolView { + detail: string + detailLabel: string + durationLabel?: string + icon: LucideIcon + imageUrl?: string + inlineDiff: string + previewTarget?: string + rawArgs: string + rawResult: string + status: ToolStatus + subtitle: string + title: string + tone: ToolTone +} + +interface ToolMeta { + done: string + icon: LucideIcon + pending: string + tone: ToolTone +} + +const TOOL_META: Record = { + browser_click: { done: 'Clicked page element', pending: 'Clicking page element', icon: Globe, tone: 'browser' }, + browser_fill: { done: 'Filled form field', pending: 'Filling form field', icon: Globe, tone: 'browser' }, + browser_navigate: { done: 'Opened page', pending: 'Opening page', icon: Globe, tone: 'browser' }, + browser_snapshot: { done: 'Captured page snapshot', pending: 'Capturing page snapshot', icon: Globe, tone: 'browser' }, + browser_take_screenshot: { done: 'Captured screenshot', pending: 'Capturing screenshot', icon: Sparkles, tone: 'browser' }, + browser_type: { done: 'Typed on page', pending: 'Typing on page', icon: Globe, tone: 'browser' }, + edit_file: { done: 'Edited file', pending: 'Editing file', icon: FileText, tone: 'file' }, + execute_code: { done: 'Ran code', pending: 'Running code', icon: Command, tone: 'terminal' }, + image_generate: { done: 'Generated image', pending: 'Generating image', icon: Sparkles, tone: 'image' }, + list_files: { done: 'Listed files', pending: 'Listing files', icon: FileText, tone: 'file' }, + read_file: { done: 'Read file', pending: 'Reading file', icon: FileText, tone: 'file' }, + search_files: { done: 'Searched files', pending: 'Searching files', icon: FileText, tone: 'file' }, + session_search_recall: { done: 'Searched session history', pending: 'Searching session history', icon: Search, tone: 'agent' }, + terminal: { done: 'Ran command', pending: 'Running command', icon: Command, tone: 'terminal' }, + todo: { done: 'Updated todos', pending: 'Updating todos', icon: Wrench, tone: 'agent' }, + web_extract: { done: 'Read webpage', pending: 'Reading webpage', icon: LinkIcon, tone: 'web' }, + web_search: { done: 'Searched web', pending: 'Searching web', icon: Search, tone: 'web' }, + write_file: { done: 'Edited file', pending: 'Editing file', icon: FileText, tone: 'file' } +} + +const TOOL_TONE_CLASS: Record = { + agent: 'bg-amber-500/12 text-amber-700 dark:text-amber-300', + browser: 'bg-sky-500/12 text-sky-700 dark:text-sky-300', + default: 'bg-muted text-muted-foreground', + file: 'bg-slate-500/12 text-slate-700 dark:text-slate-300', + image: 'bg-rose-500/12 text-rose-700 dark:text-rose-300', + terminal: 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-300', + web: 'bg-violet-500/12 text-violet-700 dark:text-violet-300' +} function titleForTool(name: string): string { + const normalized = name.replace(/^browser_/, '').replace(/^web_/, '') + return ( - name + normalized .split('_') .filter(Boolean) .map(part => `${part[0]?.toUpperCase() ?? ''}${part.slice(1)}`) @@ -24,27 +115,41 @@ function titleForTool(name: string): string { ) } -function toolLabel(name: string, isPending: boolean): string { - const labels: Record = { - edit_file: { done: 'Edited file', pending: 'Editing file' }, - execute_code: { done: 'Ran code', pending: 'Running code' }, - image_generate: { done: 'Generated image', pending: 'Generating image' }, - list_files: { done: 'Listed files', pending: 'Listing files' }, - read_file: { done: 'Read file', pending: 'Reading file' }, - search_files: { done: 'Searched files', pending: 'Searching files' }, - session_search_recall: { done: 'Searched session history', pending: 'Searching session history' }, - terminal: { done: 'Ran command', pending: 'Running command' }, - todo: { done: 'Updated todos', pending: 'Updating todos' }, - web_extract: { done: 'Read webpage', pending: 'Reading webpage' }, - web_search: { done: 'Searched the web', pending: 'Searching the web' }, - write_file: { done: 'Edited file', pending: 'Editing file' } +function toolMeta(name: string): ToolMeta { + const exact = TOOL_META[name] + + if (exact) { + return exact } - if (labels[name]) { - return isPending ? labels[name].pending : labels[name].done + if (name.startsWith('browser_')) { + const action = titleForTool(name) + + return { + done: `Browser ${action}`, + pending: `Running browser ${action.toLowerCase()}`, + icon: Globe, + tone: 'browser' + } } - return `${isPending ? 'Using' : 'Used'} ${titleForTool(name)}` + if (name.startsWith('web_')) { + const action = titleForTool(name) + + return { + done: `Web ${action}`, + pending: `Running web ${action.toLowerCase()}`, + icon: Search, + tone: 'web' + } + } + + return { + done: titleForTool(name), + pending: `Running ${titleForTool(name).toLowerCase()}`, + icon: Wrench, + tone: 'default' + } } function compactPreview(value: unknown, max = 72): string { @@ -60,28 +165,278 @@ function compactPreview(value: unknown, max = 72): string { return oneLine.length > max ? `${oneLine.slice(0, max - 1)}…` : oneLine } -function shouldShowInlinePreview(toolName: string): boolean { - return !['image_generate', 'terminal', 'execute_code'].includes(toolName) -} - function contextValue(value: unknown): string { - if (typeof value === 'string') { - return value + const row = parseMaybeObject(value) + + if (typeof row.context === 'string') { + return row.context } - if (value && typeof value === 'object' && 'context' in value) { - return String((value as { context?: unknown }).context ?? '') + if (typeof row.preview === 'string') { + return row.preview } - return '' + return typeof value === 'string' ? value : '' } function prettyJson(value: unknown): string { return typeof value === 'string' ? value : JSON.stringify(value, null, 2) } +function parseMaybeObject(value: unknown): Record { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value as Record + } + + if (typeof value !== 'string' || !value.trim()) { + return {} + } + + try { + const parsed = JSON.parse(value) + + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record) : {} + } catch { + return {} + } +} + function recordValue(value: unknown): Record { - return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : {} + return parseMaybeObject(value) +} + +function numberValue(value: unknown): null | number { + if (typeof value === 'number' && Number.isFinite(value)) { + return value + } + + if (typeof value === 'string') { + const parsed = Number(value) + + return Number.isFinite(parsed) ? parsed : null + } + + return null +} + +function looksLikeUrl(value: string): boolean { + return /^https?:\/\//i.test(value) +} + +function looksLikePreviewPath(value: string): boolean { + return /^file:\/\//i.test(value) || /^(?:\/|\.{1,2}\/|~\/).+/.test(value) +} + +function isPreviewableTarget(target: string): boolean { + if (!target) { + return false + } + + if (/^file:\/\//i.test(target)) { + return true + } + + if (/^(?:\/|\.{1,2}\/|~\/).+\.html?$/i.test(target)) { + return true + } + + if (/^https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])/i.test(target)) { + return true + } + + return false +} + +const URL_PATTERN = /https?:\/\/[^\s'"<>)\]]+/i + +function findFirstUrl(...sources: unknown[]): string { + for (const source of sources) { + if (typeof source === 'string') { + const match = source.match(URL_PATTERN) + + if (match) { + return match[0] + } + + continue + } + + if (source && typeof source === 'object') { + for (const value of Object.values(source as Record)) { + const nested = findFirstUrl(value) + + if (nested) { + return nested + } + } + } + } + + return '' +} + +function hostnameOf(value: string): string { + try { + const url = new URL(value) + + return `${url.hostname}${url.pathname && url.pathname !== '/' ? url.pathname : ''}` + } catch { + return value + } +} + +function looksRedundant(title: string, detail: string): boolean { + if (!detail) { + return true + } + + const norm = (input: string) => input.toLowerCase().replace(/\s+/g, ' ').trim() + + return norm(title) === norm(detail) +} + +function summarizeBrowserSnapshot(snapshot: string): string { + const buttons = snapshot.match(/button\s+"[^"]+"/g)?.length ?? 0 + const links = snapshot.match(/link\s+"[^"]+"/g)?.length ?? 0 + const inputs = snapshot.match(/(?:textbox|combobox|searchbox)\s+"[^"]+"/g)?.length ?? 0 + + const labels = Array.from(snapshot.matchAll(/(?:button|link|combobox|textbox)\s+"([^"]+)"/g)) + .map(match => match[1].trim()) + .filter(Boolean) + .slice(0, 4) + + const stats = [`${buttons} buttons`, `${links} links`, `${inputs} inputs`].join(' · ') + + if (!labels.length) { + return stats + } + + return `${stats}\nTop controls: ${labels.join(', ')}` +} + +function firstStringField(record: Record, keys: readonly string[]): string { + for (const key of keys) { + const value = record[key] + + if (typeof value === 'string' && value.trim()) { + return value.trim() + } + } + + return '' +} + +function extractSearchResults(result: unknown): SearchResultRow[] { + const row = parseMaybeObject(result) + + const list = Array.isArray(row.results) + ? row.results + : Array.isArray(row.items) + ? row.items + : Array.isArray(row.data) + ? row.data + : [] + + return list + .map(item => parseMaybeObject(item)) + .map(item => ({ + title: firstStringField(item, ['title', 'name']), + url: firstStringField(item, ['url', 'href', 'link']), + snippet: firstStringField(item, ['snippet', 'description', 'body']) + })) + .filter(item => item.title || item.url) + .slice(0, 3) +} + +function toolErrorText(part: ToolPart, resultRecord: Record): string { + if (part.isError) { + return 'Tool returned an error.' + } + + if (typeof resultRecord.error === 'string' && resultRecord.error.trim()) { + return resultRecord.error.trim() + } + + if (resultRecord.success === false) { + return firstStringField(resultRecord, ['message', 'reason']) || 'Tool returned success=false.' + } + + const exitCode = numberValue(resultRecord.exit_code) + + if (exitCode !== null && exitCode !== 0) { + return `Command failed with exit code ${exitCode}.` + } + + return '' +} + +function toolStatus(part: ToolPart, resultRecord: Record): ToolStatus { + if (part.result === undefined) { + return 'running' + } + + return toolErrorText(part, resultRecord) ? 'error' : 'success' +} + +function durationLabel(resultRecord: Record): string | undefined { + const seconds = numberValue(resultRecord.duration_s) + + if (seconds === null || seconds < 0) { + return undefined + } + + return `${seconds.toFixed(seconds >= 10 ? 0 : 1)}s` +} + +function toolPreviewTarget(toolName: string, argsRecord: Record, resultRecord: Record): string { + const direct = [ + firstStringField(resultRecord, ['preview', 'url', 'target']), + firstStringField(argsRecord, ['preview', 'url', 'target', 'path', 'file', 'filepath']), + firstStringField(resultRecord, ['path', 'file', 'filepath']) + ].find(Boolean) + + if (direct && (looksLikeUrl(direct) || looksLikePreviewPath(direct))) { + return direct + } + + if (toolName === 'browser_navigate' || toolName === 'web_extract' || toolName === 'web_search') { + const direct = firstStringField(argsRecord, ['url', 'search_term', 'query']) || firstStringField(resultRecord, ['url']) + + if (looksLikeUrl(direct)) { + return direct + } + + const scanned = findFirstUrl(argsRecord, resultRecord) + + if (scanned) { + return scanned + } + } + + return '' +} + +function toolImageUrl(argsRecord: Record, resultRecord: Record): string { + const candidate = [ + firstStringField(resultRecord, ['image_url', 'url', 'path', 'image_path']), + firstStringField(argsRecord, ['image_url', 'url', 'path']) + ].find(Boolean) + + if (!candidate) { + return '' + } + + const lower = candidate.toLowerCase() + + if (lower.startsWith('data:image/')) { + return candidate + } + + if (!/\.(png|jpe?g|gif|webp|bmp|svg)(\?|#|$)/i.test(lower)) { + return '' + } + + return candidate } function stripAnsi(value: string): string { @@ -102,31 +457,7 @@ function inlineDiffFromResult(result: unknown): string { return typeof value === 'string' ? stripInlineDiffChrome(value) : '' } -function detailLabel(toolName: string): string { - if (toolName === 'image_generate') { - return 'Prompt' - } - - if (toolName === 'web_search') { - return 'Query' - } - - if (toolName === 'web_extract') { - return 'URL' - } - - if (toolName === 'terminal') { - return 'Command' - } - - if (toolName === 'execute_code') { - return 'Code' - } - - return 'Input' -} - -function detailText(args: unknown, result: unknown): string { +function fallbackDetailText(args: unknown, result: unknown): string { const argContext = contextValue(args) const resultContext = contextValue(result) @@ -145,17 +476,323 @@ function detailText(args: unknown, result: unknown): string { return prettyJson(args) } -export const ToolFallback = ({ toolCallId, toolName, args, result }: ToolCallMessagePartProps) => { +function toolSubtitle(part: ToolPart, argsRecord: Record, resultRecord: Record): string { + const toolName = part.toolName + + if (toolName === 'browser_navigate') { + const url = + firstStringField(argsRecord, ['url', 'target']) || + firstStringField(resultRecord, ['url']) || + findFirstUrl(argsRecord, resultRecord) + + return url ? hostnameOf(url) : 'Navigated in browser' + } + + if (toolName === 'browser_snapshot') { + const snapshot = firstStringField(resultRecord, ['snapshot']) + + return snapshot ? summarizeBrowserSnapshot(snapshot) : 'Captured a browser accessibility snapshot' + } + + if (toolName === 'browser_click') { + const clicked = firstStringField(resultRecord, ['clicked']) || firstStringField(argsRecord, ['ref', 'target']) + + if (!clicked) { + return 'Clicked on page' + } + + return clicked.startsWith('@') ? `Clicked page element (internal ref ${clicked})` : `Clicked ${clicked}` + } + + if (toolName === 'browser_fill' || toolName === 'browser_type') { + const field = firstStringField(argsRecord, ['label', 'field', 'ref', 'target']) + const value = firstStringField(argsRecord, ['value', 'text']) + + return [field && `Field: ${field}`, value && `Value: ${compactPreview(value, 42)}`].filter(Boolean).join(' · ') || 'Filled page input' + } + + if (toolName === 'web_search') { + const query = firstStringField(argsRecord, ['search_term', 'query']) || contextValue(argsRecord) + + return query ? `Query: ${query}` : 'Queried web sources' + } + + if (toolName === 'terminal' || toolName === 'execute_code') { + const command = firstStringField(argsRecord, ['command', 'code']) || contextValue(argsRecord) + + return command ? compactPreview(command, 120) : 'Executed command' + } + + if (toolName === 'read_file' || toolName === 'write_file' || toolName === 'edit_file') { + const path = firstStringField(argsRecord, ['path', 'file', 'filepath']) + + return path || fallbackDetailText(argsRecord, resultRecord) + } + + if (toolName === 'web_extract') { + const url = + firstStringField(argsRecord, ['url']) || + firstStringField(resultRecord, ['url']) || + findFirstUrl(argsRecord, resultRecord) + + return url ? hostnameOf(url) : 'Fetched webpage' + } + + return compactPreview(resultRecord, 120) || compactPreview(argsRecord, 120) || fallbackDetailText(argsRecord, resultRecord) +} + +function toolDetailLabel(toolName: string): string { + if (toolName === 'web_search') { + return 'Search results' + } + + if (toolName === 'browser_snapshot') { + return 'Snapshot summary' + } + + if (toolName === 'terminal' || toolName === 'execute_code') { + return 'Command output' + } + + return '' +} + +function toolDetailText(part: ToolPart, argsRecord: Record, resultRecord: Record): string { + if (part.toolName === 'browser_snapshot') { + const snapshot = firstStringField(resultRecord, ['snapshot']) + + return snapshot ? summarizeBrowserSnapshot(snapshot) : fallbackDetailText(argsRecord, resultRecord) + } + + if (part.toolName === 'web_search') { + const hits = extractSearchResults(part.result) + + if (hits.length) { + return hits + .map(hit => [hit.title, hit.url, hit.snippet].filter(Boolean).join('\n')) + .join('\n\n') + } + } + + if (part.toolName === 'terminal' || part.toolName === 'execute_code') { + const output = firstStringField(resultRecord, ['output', 'stdout', 'stderr']) + + const lines = Array.isArray(resultRecord.lines) + ? resultRecord.lines.filter((line): line is string => typeof line === 'string').join('\n') + : '' + + if (output || lines) { + return [output, lines].filter(Boolean).join('\n') + } + } + + if (part.toolName === 'web_extract') { + const summary = firstStringField(resultRecord, ['summary', 'message']) + + if (summary) { + return summary.replace(/\s*in\s+\d+(?:\.\d+)?s\s*$/i, '').trim() + } + } + + return fallbackDetailText(argsRecord, resultRecord) +} + +function dynamicTitle( + part: ToolPart, + argsRecord: Record, + resultRecord: Record, + fallback: string +): string { + const isPending = part.result === undefined + + if (part.toolName === 'web_extract') { + const url = findFirstUrl(argsRecord, resultRecord) + + if (url) { + const host = hostnameOf(url) + + return isPending ? `Reading ${host}` : `Read ${host}` + } + } + + if (part.toolName === 'browser_navigate') { + const url = findFirstUrl(argsRecord, resultRecord) + + if (url) { + const host = hostnameOf(url) + + return isPending ? `Opening ${host}` : `Opened ${host}` + } + } + + if (part.toolName === 'web_search') { + const query = firstStringField(argsRecord, ['search_term', 'query']) || contextValue(argsRecord) + + if (query) { + return isPending ? `Searching “${compactPreview(query, 48)}”` : `Searched “${compactPreview(query, 48)}”` + } + } + + return fallback +} + +function buildToolView(part: ToolPart, inlineDiff: string): ToolView { + const argsRecord = parseMaybeObject(part.args) + const resultRecord = parseMaybeObject(part.result) + const meta = toolMeta(part.toolName) + const status = toolStatus(part, resultRecord) + const error = toolErrorText(part, resultRecord) + const baseTitle = part.result === undefined ? meta.pending : meta.done + const title = dynamicTitle(part, argsRecord, resultRecord, baseTitle) + const titleEnriched = title !== baseTitle + const baseSubtitle = error || toolSubtitle(part, argsRecord, resultRecord) + const subtitle = titleEnriched && !error ? '' : baseSubtitle + + return { + detail: error || toolDetailText(part, argsRecord, resultRecord), + detailLabel: error ? 'Error' : toolDetailLabel(part.toolName), + durationLabel: durationLabel(resultRecord), + icon: meta.icon, + imageUrl: toolImageUrl(argsRecord, resultRecord), + inlineDiff, + previewTarget: toolPreviewTarget(part.toolName, argsRecord, resultRecord), + rawArgs: prettyJson(part.args), + rawResult: prettyJson(part.result), + status, + subtitle, + title, + tone: meta.tone + } +} + +function isToolPart(part: unknown): part is ToolPart { + if (!part || typeof part !== 'object') { + return false + } + + const row = part as Record + + return row.type === 'tool-call' && typeof row.toolName === 'string' +} + +function groupToolParts(content: unknown): ToolPart[][] { + if (!Array.isArray(content)) { + return [] + } + + const groups: ToolPart[][] = [] + let current: ToolPart[] = [] + + for (const part of content) { + if (isToolPart(part)) { + current.push(part) + + continue + } + + if (current.length) { + groups.push(current) + current = [] + } + } + + if (current.length) { + groups.push(current) + } + + return groups +} + +function groupStatus(parts: ToolPart[]): ToolStatus { + if (parts.some(part => part.result === undefined)) { + return 'running' + } + + const hasError = parts.some(part => { + const resultRecord = parseMaybeObject(part.result) + + return toolStatus(part, resultRecord) === 'error' + }) + + return hasError ? 'error' : 'success' +} + +function groupTitle(parts: ToolPart[]): string { + const first = parts[0] + + if (!first) { + return 'Tool calls' + } + + if (parts.every(part => part.toolName.startsWith('browser_'))) { + return `Browser actions · ${parts.length} steps` + } + + if (parts.every(part => part.toolName.startsWith('web_'))) { + return `Web actions · ${parts.length} steps` + } + + return `Tool actions · ${parts.length} steps` +} + +const STATUS_DOT_CLASS: Record = { + error: 'bg-destructive', + running: 'bg-muted-foreground/55 animate-pulse', + success: 'bg-emerald-500' +} + +function statusDot(status: ToolStatus): ReactNode { + return ( + + ) +} + +function statusBadge(status: ToolStatus): ReactNode { + if (status === 'running') { + return ( + + + Running + + ) + } + + if (status === 'error') { + return ( + + + Error + + ) + } + + return ( + + + Done + + ) +} + +interface ToolEntryProps { + embedded?: boolean + part: ToolPart +} + +function ToolEntry({ embedded = false, part }: ToolEntryProps) { const [open, setOpen] = useState(false) - const isPending = result === undefined + const isPending = part.result === undefined const [tick, setTick] = useState(0) const elapsed = useElapsedSeconds(isPending) - const preview = compactPreview(args) || compactPreview(result) - const label = toolLabel(toolName, isPending) - const detail = detailText(args, result) + const toolViewMode = useStore($toolViewMode) + const preview = compactPreview(part.args) || compactPreview(part.result) const liveDiffs = useStore($toolInlineDiffs) - const sideDiff = toolCallId ? liveDiffs[toolCallId] || '' : '' - const inlineDiff = stripInlineDiffChrome(sideDiff) || inlineDiffFromResult(result) + const sideDiff = part.toolCallId ? liveDiffs[part.toolCallId] || '' : '' + const inlineDiff = stripInlineDiffChrome(sideDiff) || inlineDiffFromResult(part.result) + const view = useMemo(() => buildToolView(part, inlineDiff), [inlineDiff, part]) const spinnerFrame = TOOL_SPINNER_FRAMES[tick % TOOL_SPINNER_FRAMES.length] useEffect(() => { @@ -169,9 +806,15 @@ export const ToolFallback = ({ toolCallId, toolName, args, result }: ToolCallMes }, [isPending]) return ( -
+
{open && ( -
- {detailLabel(toolName)}: - {detail} +
+ {view.previewTarget && isPreviewableTarget(view.previewTarget) && ( + + )} + {view.imageUrl && ( +
+ +
+ )} + {!looksRedundant(view.title, view.detail) && !looksRedundant(view.subtitle, view.detail) && ( +
+ {view.detailLabel && ( + + {view.detailLabel} + + )} + {view.detail} +
+ )} + {toolViewMode === 'technical' && ( +
+ + {part.result !== undefined && } +
+ )}
)} - {inlineDiff && } + {view.inlineDiff && }
) } +function JsonSection({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
+        {value}
+      
+
+ ) +} + +function ToolGroup({ parts }: { parts: ToolPart[] }) { + const [open, setOpen] = useState(parts.some(part => part.result === undefined)) + const status = groupStatus(parts) + + const tailSummary = useMemo(() => { + const tail = parts.at(-1) + + return tail ? buildToolView(tail, '').subtitle : '' + }, [parts]) + + return ( +
+ + {open && ( +
+ {parts.map(part => ( + + ))} +
+ )} +
+ ) +} + +export const ToolFallback = ({ toolCallId, toolName, args, isError, result }: ToolCallMessagePartProps) => { + const messageContent = useAuiState(state => state.message.content as unknown) + const groups = useMemo(() => groupToolParts(messageContent), [messageContent]) + + const currentPart: ToolPart = { + args, + isError, + result, + toolCallId, + toolName, + type: 'tool-call' + } + + if (!toolCallId) { + return + } + + const group = groups.find(parts => parts.some(part => part.toolCallId === toolCallId)) + + if (!group || group.length <= 1) { + return + } + + if (group[0]?.toolCallId !== toolCallId) { + return null + } + + return +} + function InlineDiff({ text }: { text: string }) { return (
diff --git a/apps/desktop/src/components/session-inspector.tsx b/apps/desktop/src/components/session-inspector.tsx
index d844cd5fd42..cabeac65e81 100644
--- a/apps/desktop/src/components/session-inspector.tsx
+++ b/apps/desktop/src/components/session-inspector.tsx
@@ -51,7 +51,7 @@ export const SessionInspector: FC = ({