feat(desktop): hoisted todo widget, JSON tool summaries, history grouping & timer fixes

- Hoist todo to first-class widget (shadcn checkboxes, brand colors, no
  tool-accordion). Header derives label from active task; non-active rows fade.
- Replace raw JSON dumps with structured key/value summaries via
  formatToolResultSummary; nested error extraction for clearer failures.
- Fix loaded-session grouping: stitch interleaved assistant/tool iterations
  into one bubble instead of orphaned synthetic messages.
- Stable tool/thinking timers via keyed registry so unmount/scroll doesn't
  reset elapsed counts; gate "running" on real live thread state.
- Reorganize chat-only assistant-ui components under components/chat/.
This commit is contained in:
Brooklyn Nicholson
2026-05-11 16:34:25 -04:00
parent 4b3839a8ee
commit 4dd9732a94
34 changed files with 1234 additions and 306 deletions
+16 -6
View File
@@ -2,7 +2,7 @@ import type * as React from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { ZoomableImage } from '@/components/assistant-ui/zoomable-image'
import { ZoomableImage } from '@/components/chat/zoomable-image'
import { PageLoader } from '@/components/page-loader'
import { Button } from '@/components/ui/button'
import { CopyButton } from '@/components/ui/copy-button'
@@ -786,9 +786,6 @@ function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }:
)
}
const CELL_ACTION_CLASS =
'flex h-full w-full min-w-0 items-center gap-2 px-2.5 py-1.5 text-left text-sm leading-snug font-medium text-foreground/90 no-underline transition-colors hover:text-foreground hover:underline'
// Single click target for any row cell. External URLs render as <ExternalLink>;
// local actions render as <button>. Padding lives here, NOT on the <td>, so
// the entire cell area is hoverable and clickable in both branches.
@@ -805,14 +802,27 @@ function ArtifactCellAction({
}) {
if (href) {
return (
<ExternalLink className={CELL_ACTION_CLASS} href={href} showExternalIcon={false} title={title}>
<ExternalLink
className="flex h-full w-full min-w-0 items-center gap-2 px-2.5 py-1.5 text-left text-sm leading-snug font-medium text-foreground/90 no-underline transition-colors hover:text-foreground hover:underline"
href={href}
showExternalIcon={false}
title={title}
>
{children}
</ExternalLink>
)
}
return (
<button className={cn(CELL_ACTION_CLASS, 'cursor-pointer')} onClick={onClick} title={title} type="button">
<button
className={cn(
'flex h-full w-full min-w-0 items-center gap-2 px-2.5 py-1.5 text-left text-sm leading-snug font-medium text-foreground/90 no-underline transition-colors hover:text-foreground hover:underline',
'cursor-pointer'
)}
onClick={onClick}
title={title}
type="button"
>
{children}
</button>
)
+39 -24
View File
@@ -50,9 +50,6 @@ import type { ChatBarProps } from './types'
import { UrlDialog } from './url-dialog'
import { VoiceActivity, VoicePlaybackActivity } from './voice-activity'
const COMPOSER_SHELL_CLASS =
'group/composer absolute bottom-0 left-1/2 z-30 w-[min(var(--composer-width),calc(100%-2rem))] max-w-full -translate-x-1/2 rounded-2xl pt-2 pb-[var(--composer-shell-pad-block-end)]'
function extractClipboardImageBlobs(clipboard: DataTransfer): Blob[] {
const blobs: Blob[] = []
const seen = new Set<Blob>()
@@ -111,21 +108,6 @@ function extractClipboardImageBlobs(clipboard: DataTransfer): Blob[] {
const COMPOSER_STACK_BREAKPOINT_PX = 320
const COMPOSER_SCROLLED_DIM_CLASS =
'opacity-30 group-hover/composer:opacity-100 group-focus-within/composer:opacity-100'
const COMPOSER_FROST_CLASS = cn(
'pointer-events-none absolute inset-0 -z-10 rounded-[inherit]',
'bg-[color-mix(in_srgb,var(--dt-card)_72%,transparent)]',
'backdrop-blur-[0.75rem] backdrop-saturate-[1.12]',
'[-webkit-backdrop-filter:blur(0.75rem)_saturate(1.12)]',
'transition-[background-color] duration-150 ease-out',
'group-data-[thread-scrolled-up]/composer:bg-[color-mix(in_srgb,var(--dt-card)_48%,transparent)]',
'group-focus-within/composer:bg-[var(--dt-card)]',
'group-focus-within/composer:[backdrop-filter:none]',
'group-focus-within/composer:[-webkit-backdrop-filter:none]'
)
const COMPOSER_GLASS = {
fadeBackground: 'linear-gradient(to bottom, transparent, color-mix(in srgb, var(--dt-background) 10%, transparent))',
liquidKey: ['standard', '0.950', '0.072', '0', '46', '0.00', '128'].join(':'),
@@ -921,7 +903,7 @@ export function ChatBar({
<>
<ComposerPrimitive.Unstable_TriggerPopoverRoot>
<ComposerPrimitive.Root
className={COMPOSER_SHELL_CLASS}
className="group/composer absolute bottom-0 left-1/2 z-30 w-[min(var(--composer-width),calc(100%-2rem))] max-w-full -translate-x-1/2 rounded-2xl pt-2 pb-[var(--composer-shell-pad-block-end)]"
data-drag-active={dragActive ? '' : undefined}
data-slot="composer-root"
data-thread-scrolled-up={scrolledUp ? '' : undefined}
@@ -955,7 +937,9 @@ export function ChatBar({
<div
className={cn(
'composer-liquid-shell-wrap absolute -inset-px isolate overflow-hidden rounded-[calc(var(--radius-2xl)+1px)] transition-opacity duration-200 ease-out',
scrolledUp ? COMPOSER_SCROLLED_DIM_CLASS : 'opacity-100'
scrolledUp
? 'opacity-30 group-hover/composer:opacity-100 group-focus-within/composer:opacity-100'
: 'opacity-100'
)}
data-glass-frame="true"
data-show-library-rims={COMPOSER_GLASS.showLibraryRims ? 'true' : undefined}
@@ -990,7 +974,20 @@ export function ChatBar({
data-slot="composer-surface"
ref={composerSurfaceRef}
>
<div aria-hidden className={COMPOSER_FROST_CLASS} />
<div
aria-hidden
className={cn(
'pointer-events-none absolute inset-0 -z-10 rounded-[inherit]',
'bg-[color-mix(in_srgb,var(--dt-card)_72%,transparent)]',
'backdrop-blur-[0.75rem] backdrop-saturate-[1.12]',
'[-webkit-backdrop-filter:blur(0.75rem)_saturate(1.12)]',
'transition-[background-color] duration-150 ease-out',
'group-data-[thread-scrolled-up]/composer:bg-[color-mix(in_srgb,var(--dt-card)_48%,transparent)]',
'group-focus-within/composer:bg-[var(--dt-card)]',
'group-focus-within/composer:[backdrop-filter:none]',
'group-focus-within/composer:[-webkit-backdrop-filter:none]'
)}
/>
{dragActive && (
<div
aria-hidden
@@ -1002,7 +999,9 @@ export function ChatBar({
<div
className={cn(
'relative z-1 flex min-h-0 w-full flex-col gap-(--composer-row-gap) overflow-hidden rounded-[inherit] px-(--composer-surface-pad-x) py-(--composer-surface-pad-y) transition-opacity duration-200 ease-out',
scrolledUp ? COMPOSER_SCROLLED_DIM_CLASS : 'opacity-100'
scrolledUp
? 'opacity-30 group-hover/composer:opacity-100 group-focus-within/composer:opacity-100'
: 'opacity-100'
)}
data-slot="composer-fade"
>
@@ -1042,11 +1041,27 @@ export function ChatBar({
export function ChatBarFallback() {
return (
<div
className={cn(COMPOSER_SHELL_CLASS, 'bg-linear-to-b from-transparent to-background/55')}
className={cn(
'group/composer absolute bottom-0 left-1/2 z-30 w-[min(var(--composer-width),calc(100%-2rem))] max-w-full -translate-x-1/2 rounded-2xl pt-2 pb-[var(--composer-shell-pad-block-end)]',
'bg-linear-to-b from-transparent to-background/55'
)}
data-slot="composer-root"
>
<div className="composer-fallback-surface relative isolate h-(--composer-fallback-height) w-full rounded-[inherit] border border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(18%*var(--composer-ring-strength)),var(--dt-input))] shadow-composer">
<div aria-hidden className={COMPOSER_FROST_CLASS} />
<div
aria-hidden
className={cn(
'pointer-events-none absolute inset-0 -z-10 rounded-[inherit]',
'bg-[color-mix(in_srgb,var(--dt-card)_72%,transparent)]',
'backdrop-blur-[0.75rem] backdrop-saturate-[1.12]',
'[-webkit-backdrop-filter:blur(0.75rem)_saturate(1.12)]',
'transition-[background-color] duration-150 ease-out',
'group-data-[thread-scrolled-up]/composer:bg-[color-mix(in_srgb,var(--dt-card)_48%,transparent)]',
'group-focus-within/composer:bg-[var(--dt-card)]',
'group-focus-within/composer:[backdrop-filter:none]',
'group-focus-within/composer:[-webkit-backdrop-filter:none]'
)}
/>
</div>
</div>
)
@@ -50,7 +50,6 @@ export function SessionActionsMenu({
align = 'end',
sideOffset = 6
}: SessionActionsMenuProps) {
const itemClass = 'gap-2.5 text-foreground focus:bg-accent [&_svg]:size-4'
const [renameOpen, setRenameOpen] = useState(false)
return (
@@ -59,7 +58,7 @@ export function SessionActionsMenu({
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent align={align} aria-label={`Actions for ${title}`} className="w-44" sideOffset={sideOffset}>
<DropdownMenuItem
className={itemClass}
className="gap-2.5 text-foreground focus:bg-accent [&_svg]:size-4"
disabled={!onPin}
onSelect={() => {
triggerHaptic('selection')
@@ -71,14 +70,14 @@ export function SessionActionsMenu({
</DropdownMenuItem>
<CopyButton
appearance="menu-item"
className={itemClass}
className="gap-2.5 text-foreground focus:bg-accent [&_svg]:size-4"
disabled={!sessionId}
errorMessage="Could not copy session ID"
label="Copy ID"
text={sessionId}
/>
<DropdownMenuItem
className={itemClass}
className="gap-2.5 text-foreground focus:bg-accent [&_svg]:size-4"
disabled={!sessionId}
onSelect={() => {
triggerHaptic('selection')
@@ -89,7 +88,7 @@ export function SessionActionsMenu({
<span>Export</span>
</DropdownMenuItem>
<DropdownMenuItem
className={itemClass}
className="gap-2.5 text-foreground focus:bg-accent [&_svg]:size-4"
disabled={!sessionId}
onSelect={() => {
triggerHaptic('selection')
@@ -101,7 +100,10 @@ export function SessionActionsMenu({
</DropdownMenuItem>
<DropdownMenuSeparator className="my-3" />
<DropdownMenuItem
className={cn(itemClass, 'text-destructive focus:text-destructive')}
className={cn(
'gap-2.5 text-foreground focus:bg-accent [&_svg]:size-4',
'text-destructive focus:text-destructive'
)}
disabled={!onDelete}
onSelect={() => {
triggerHaptic('warning')
@@ -62,6 +62,15 @@ interface QueuedStreamDeltas {
const STREAM_DELTA_FLUSH_MS = 16
// Anonymous progress events that carry todos but no name still belong to the
// todo stream; named todo events are obviously routed there too.
function toTodoPayload(payload: GatewayEventPayload | undefined): GatewayEventPayload | undefined {
if (!payload) {return undefined}
const isTodo = payload.name === 'todo' || (!payload.name && Object.hasOwn(payload, 'todos'))
return isTodo ? { ...payload, name: 'todo', tool_id: payload.tool_id || 'todo-live' } : undefined
}
export function useMessageStream({
activeSessionIdRef,
hydrateFromStoredSession,
@@ -552,17 +561,13 @@ export function useMessageStream({
setCurrentUsage(current => ({ ...current, ...payload.usage }))
}
} else if (event.type === 'tool.start' || event.type === 'tool.progress' || event.type === 'tool.generating') {
if (!sessionId) {
return
}
if (!sessionId) {return}
flushQueuedDeltas(sessionId)
upsertToolCall(sessionId, payload, 'running')
upsertToolCall(sessionId, toTodoPayload(payload) ?? payload, 'running')
} else if (event.type === 'tool.complete') {
if (sessionId) {
flushQueuedDeltas(sessionId)
upsertToolCall(sessionId, payload, 'complete')
upsertToolCall(sessionId, toTodoPayload(payload) ?? payload, 'complete')
}
if (typeof payload?.inline_diff === 'string' && payload.inline_diff.trim()) {
@@ -43,9 +43,6 @@ interface StatusbarControlsProps extends ComponentProps<'footer'> {
items?: readonly StatusbarItem[]
}
const statusbarItemClass =
'inline-flex h-full cursor-pointer items-center gap-1 rounded-none px-1.5 text-[0.68rem] text-muted-foreground/95 transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground disabled:cursor-default disabled:opacity-45'
export function StatusbarControls({ className, leftItems = [], items = [], ...props }: StatusbarControlsProps) {
const navigate = useNavigate()
@@ -91,7 +88,10 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className={cn(statusbarItemClass, item.className)}
className={cn(
'inline-flex h-full cursor-pointer items-center gap-1 rounded-none px-1.5 text-[0.68rem] text-muted-foreground/95 transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground disabled:cursor-default disabled:opacity-45',
item.className
)}
disabled={item.disabled}
title={title}
type="button"
@@ -162,7 +162,10 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
if (item.href || item.variant === 'link') {
return (
<a
className={cn(statusbarItemClass, item.className)}
className={cn(
'inline-flex h-full cursor-pointer items-center gap-1 rounded-none px-1.5 text-[0.68rem] text-muted-foreground/95 transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground disabled:cursor-default disabled:opacity-45',
item.className
)}
href={item.href}
rel="noreferrer"
target="_blank"
@@ -175,7 +178,10 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
return (
<button
className={cn(statusbarItemClass, item.className)}
className={cn(
'inline-flex h-full cursor-pointer items-center gap-1 rounded-none px-1.5 text-[0.68rem] text-muted-foreground/95 transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground disabled:cursor-default disabled:opacity-45',
item.className
)}
disabled={item.disabled}
onClick={() => {
if (item.to) {
@@ -1,36 +0,0 @@
import { useEffect, useRef, useState } from 'react'
const ELAPSED_TICK_MS = 1000
export function formatElapsed(seconds: number): string {
if (seconds < 60) {
return `${seconds}s`
}
const minutes = Math.floor(seconds / 60)
const remainder = seconds % 60
return `${minutes}:${String(remainder).padStart(2, '0')}`
}
export function useElapsedSeconds(active = true): number {
const startedAt = useRef(Date.now())
const [elapsed, setElapsed] = useState(0)
useEffect(() => {
if (!active) {
return
}
const update = () => {
setElapsed(Math.max(0, Math.floor((Date.now() - startedAt.current) / 1000)))
}
update()
const id = window.setInterval(update, ELAPSED_TICK_MS)
return () => window.clearInterval(id)
}, [active])
return elapsed
}
@@ -1,6 +0,0 @@
'use client'
// Minimal stubs — attachment upload not wired in the desktop app yet.
export const ComposerAddAttachment = () => null
export const ComposerAttachments = () => null
export const UserMessageAttachments = () => null
@@ -5,7 +5,7 @@ import type { TextMessagePartComponent, TextMessagePartProps } from '@assistant-
import type { FC } from 'react'
import { Fragment, useEffect, useMemo, useState } from 'react'
import { ZoomableImage } from '@/components/assistant-ui/zoomable-image'
import { ZoomableImage } from '@/components/chat/zoomable-image'
import { extractEmbeddedImages } from '@/lib/embedded-images'
const HERMES_REF_TYPES = ['file', 'folder', 'url', 'image', 'tool', 'line'] as const
@@ -42,8 +42,6 @@ const ICON_PATHS: Record<HermesRefType, string[]> = {
const ICON_FALLBACK = ['M8 12a4 4 0 1 0 8 0a4 4 0 1 0 -8 0', 'M16 12v1.5a2.5 2.5 0 0 0 5 0v-1.5a9 9 0 1 0 -5.5 8.28']
const ICON_CLASS = 'size-3 shrink-0 opacity-80'
const SVG_ATTRS =
'xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"'
@@ -55,12 +53,12 @@ export function directiveIconSvg(type: string) {
.map(d => `<path d="${d}"/>`)
.join('')
return `<svg ${SVG_ATTRS} class="${ICON_CLASS}">${inner}</svg>`
return `<svg ${SVG_ATTRS} class="size-3 shrink-0 opacity-80">${inner}</svg>`
}
export function directiveIconElement(type: string) {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
svg.setAttribute('class', ICON_CLASS)
svg.setAttribute('class', 'size-3 shrink-0 opacity-80')
svg.setAttribute('fill', 'none')
svg.setAttribute('stroke', 'currentColor')
svg.setAttribute('stroke-linecap', 'round')
@@ -80,7 +78,7 @@ export function directiveIconElement(type: string) {
const DirectiveIcon: FC<{ type: string }> = ({ type }) => (
<svg
className={ICON_CLASS}
className="size-3 shrink-0 opacity-80"
fill="none"
stroke="currentColor"
strokeLinecap="round"
@@ -9,9 +9,9 @@ import {
import { code } from '@streamdown/code'
import { type ComponentProps, memo, useEffect, useMemo, useState } from 'react'
import { PreviewAttachment } from '@/components/assistant-ui/preview-attachment'
import { SyntaxHighlighter } from '@/components/assistant-ui/shiki-highlighter'
import { ZoomableImage } from '@/components/assistant-ui/zoomable-image'
import { PreviewAttachment } from '@/components/chat/preview-attachment'
import { SyntaxHighlighter } from '@/components/chat/shiki-highlighter'
import { ZoomableImage } from '@/components/chat/zoomable-image'
import { CopyButton } from '@/components/ui/copy-button'
import { normalizeExternalUrl, openExternalLink, PrettyLink } from '@/lib/external-link'
import { isLikelyProseCodeBlock, sanitizeLanguageTag } from '@/lib/markdown-code'
@@ -1,14 +0,0 @@
'use client'
// Minimal reasoning stubs — not surfaced by the Hermes gateway yet.
import { type ReactNode } from 'react'
export const ReasoningRoot = ({ children }: { children: ReactNode; defaultOpen?: boolean }) => (
<div className="my-1">{children}</div>
)
export const ReasoningTrigger = (_props: { active?: boolean }) => null
export const ReasoningContent = ({ children, 'aria-busy': _busy }: { children: ReactNode; 'aria-busy'?: boolean }) => (
<div className="border-l-2 border-border pl-3 text-xs text-muted-foreground">{children}</div>
)
export const ReasoningText = ({ children }: { children: ReactNode }) => <div>{children}</div>
export const Reasoning = (_props: object) => null
@@ -1,5 +1,5 @@
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { useEffect, useState } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -119,6 +119,67 @@ function assistantMultiReasoningMessage(texts: string[]): ThreadMessage {
} as ThreadMessage
}
function assistantTodoMessage(
todos: Array<{ content: string; id: string; status: 'cancelled' | 'completed' | 'in_progress' | 'pending' }>,
running = true
): ThreadMessage {
const suffix = todos.map(todo => `${todo.id}:${todo.status}`).join('|') || 'empty'
return {
id: `assistant-todo-${running ? 'running' : 'done'}-${suffix}`,
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: 'todo-1',
toolName: 'todo',
args: { todos },
argsText: JSON.stringify({ todos }),
...(running ? {} : { result: { todos } })
}
],
status: running ? { type: 'running' } : { type: 'complete', reason: 'stop' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
function assistantReasoningTodoMessage(
todos: Array<{ content: string; id: string; status: 'cancelled' | 'completed' | 'in_progress' | 'pending' }>
): ThreadMessage {
return {
id: 'assistant-reasoning-todo-1',
role: 'assistant',
content: [
{ type: 'reasoning', text: 'Let me make a quick todo list.' },
{
type: 'tool-call',
toolCallId: 'todo-1',
toolName: 'todo',
args: { todos },
argsText: JSON.stringify({ todos }),
result: { todos }
},
{ type: 'text', text: 'Done — fake list created.' }
],
status: { type: 'complete', reason: 'stop' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
function StreamingHarness() {
const [messages, setMessages] = useState<ThreadMessage[]>([userMessage()])
const [isRunning, setIsRunning] = useState(true)
@@ -157,6 +218,20 @@ function StreamingHarness() {
)
}
function TodoHarness({ message }: { message: ThreadMessage }) {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [message],
isRunning: message.status?.type === 'running',
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
function ReasoningHarness() {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [assistantReasoningMessage(' The user is asking what this file is.')],
@@ -298,4 +373,69 @@ describe('assistant-ui streaming renderer', () => {
expect(reasoningParts[0]?.textContent).toBe('First thought.')
expect(reasoningParts[1]?.textContent).toBe('Second thought.')
})
it('renders live todo rows during a running turn', () => {
const { container } = render(
<TodoHarness
message={assistantTodoMessage([
{ content: 'Gather ingredients', id: 'prep', status: 'completed' },
{ content: 'Boil water', id: 'boil', status: 'in_progress' }
])}
/>
)
const ui = within(container)
expect(container.querySelector('[data-slot="aui_todo-hoisted"]')).toBeTruthy()
expect(ui.getAllByText('Boil water').length).toBeGreaterThan(0)
expect(ui.getByText('Gather ingredients')).toBeTruthy()
expect(ui.queryByText(/pending/i)).toBeNull()
expect(ui.queryByRole('button', { name: /todo/i })).toBeNull()
})
it('renders archived todos after turn completion regardless of pending state', () => {
const first = render(
<TodoHarness
message={assistantTodoMessage([
{ content: 'Boil water', id: 'boil', status: 'pending' }
], false)}
/>
)
const ui = within(first.container)
expect(ui.getAllByText('Boil water').length).toBeGreaterThan(0)
first.unmount()
const second = render(
<TodoHarness
message={assistantTodoMessage([
{ content: 'Serve latte', id: 'serve', status: 'completed' }
], false)}
/>
)
const archivedUi = within(second.container)
expect(archivedUi.getAllByText('Serve latte').length).toBeGreaterThan(0)
})
it('hoists todo outside the thinking disclosure when reasoning is present', () => {
const { container } = render(
<TodoHarness
message={assistantReasoningTodoMessage([
{ content: 'Buy oats', id: 'oats', status: 'completed' },
{ content: "Reply to Sam's email", id: 'email', status: 'in_progress' }
])}
/>
)
const todoPanel = container.querySelector('[data-slot="aui_todo-hoisted"]')
const thinkingDisclosure = container.querySelector('[data-slot="aui_thinking-disclosure"]')
expect(todoPanel).toBeTruthy()
expect(thinkingDisclosure).toBeTruthy()
expect(Boolean(thinkingDisclosure?.contains(todoPanel as Node))).toBe(false)
})
})
@@ -14,18 +14,19 @@ import { useStore } from '@nanostores/react'
import { type FC, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom'
import { useElapsedSeconds } from '@/components/assistant-ui/activity-timer'
import { ActivityTimerText } from '@/components/assistant-ui/activity-timer-text'
import { ClarifyTool } from '@/components/assistant-ui/clarify-tool'
import { DirectiveContent, DirectiveText } from '@/components/assistant-ui/directive-text'
import { DisclosureRow } from '@/components/assistant-ui/disclosure-row'
import { GeneratedImageProvider, useGeneratedImageContext } from '@/components/assistant-ui/generated-image-context'
import { ImageGenerationPlaceholder } from '@/components/assistant-ui/image-generation-placeholder'
import { Intro, type IntroProps } from '@/components/assistant-ui/intro'
import { MarkdownText } from '@/components/assistant-ui/markdown-text'
import { PreviewAttachment } from '@/components/assistant-ui/preview-attachment'
import { HoistedTodoPanel, todosFromMessageContent } from '@/components/assistant-ui/todo-tool'
import { ToolFallback } from '@/components/assistant-ui/tool-fallback'
import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button'
import { useElapsedSeconds } from '@/components/chat/activity-timer'
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
import { DisclosureRow } from '@/components/chat/disclosure-row'
import { GeneratedImageProvider, useGeneratedImageContext } from '@/components/chat/generated-image-context'
import { ImageGenerationPlaceholder } from '@/components/chat/image-generation-placeholder'
import { Intro, type IntroProps } from '@/components/chat/intro'
import { PreviewAttachment } from '@/components/chat/preview-attachment'
import { CopyButton } from '@/components/ui/copy-button'
import {
DropdownMenu,
@@ -354,6 +355,7 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
const messageId = useAuiState(s => s.message.id)
const content = useAuiState(s => s.message.content)
const messageText = messageContentText(content)
const hoistedTodos = useMemo(() => todosFromMessageContent(content), [content])
const previewTargets = useMemo(() => {
if (!messageText || !/(https?:\/\/|file:\/\/)/i.test(messageText)) {
@@ -379,6 +381,7 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
className="wrap-anywhere min-w-0 max-w-full overflow-hidden text-pretty text-base leading-(--dt-line-height) text-foreground"
data-slot="aui_assistant-message-content"
>
{hoistedTodos.length > 0 && <HoistedTodoPanel todos={hoistedTodos} />}
<MessagePrimitive.Parts
components={{
Text: MarkdownText,
@@ -458,13 +461,12 @@ const ImageGenerateTool: FC<ToolCallMessagePartProps> = ({ result }) => {
}
const ChainToolFallback: FC<ToolCallMessagePartProps> = props => {
if (props.toolName === 'image_generate') {
return <ImageGenerateTool {...props} />
}
// todo parts are hoisted to a dedicated panel above the message content.
if (props.toolName === 'todo') {return null}
if (props.toolName === 'clarify') {
return <ClarifyTool {...props} />
}
if (props.toolName === 'image_generate') {return <ImageGenerateTool {...props} />}
if (props.toolName === 'clarify') {return <ClarifyTool {...props} />}
return <ToolFallback {...props} />
}
@@ -472,9 +474,10 @@ const ChainToolFallback: FC<ToolCallMessagePartProps> = props => {
const ThinkingDisclosure: FC<{
children: ReactNode
pending?: boolean
}> = ({ children, pending = false }) => {
timerKey?: string
}> = ({ children, pending = false, timerKey }) => {
const [open, setOpen] = useState(false)
const elapsed = useElapsedSeconds(pending)
const elapsed = useElapsedSeconds(pending, timerKey)
return (
<div className="text-sm text-muted-foreground" data-slot="aui_thinking-disclosure">
@@ -503,9 +506,10 @@ const ThinkingDisclosure: FC<{
}
const ReasoningAccordionGroup: FC<{ children?: ReactNode; endIndex: number; startIndex: number }> = ({ children }) => {
const pending = useAuiState(s => s.message.status?.type === 'running')
const pending = useAuiState(s => s.thread.isRunning && s.message.status?.type === 'running')
const messageId = useAuiState(s => s.message.id)
return <ThinkingDisclosure pending={pending}>{children}</ThinkingDisclosure>
return <ThinkingDisclosure pending={pending} timerKey={`reasoning:${messageId}`}>{children}</ThinkingDisclosure>
}
const ReasoningTextPart: FC<{ text: string; status?: { type: string } }> = ({ text, status }) => {
@@ -654,13 +658,13 @@ const AssistantFooter: FC<MessageActionProps> = props => (
className="inline-flex h-6 items-center gap-1 text-xs text-muted-foreground"
hideWhenSingleBranch
>
<BranchPickerPrimitive.Previous className={branchButtonClass}>
<BranchPickerPrimitive.Previous className="grid size-6 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-35">
<ChevronLeftIcon className="size-3.5" />
</BranchPickerPrimitive.Previous>
<span className="tabular-nums">
<BranchPickerPrimitive.Number /> / <BranchPickerPrimitive.Count />
</span>
<BranchPickerPrimitive.Next className={branchButtonClass}>
<BranchPickerPrimitive.Next className="grid size-6 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-35">
<ChevronRightIcon className="size-3.5" />
</BranchPickerPrimitive.Next>
</BranchPickerPrimitive.Root>
@@ -668,9 +672,6 @@ const AssistantFooter: FC<MessageActionProps> = props => (
</div>
)
const branchButtonClass =
'grid size-6 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-35'
const EMPTY_ATTACHMENT_REFS: string[] = []
function messageAttachmentRefs(value: unknown): string[] {
@@ -0,0 +1,94 @@
import { type FC } from 'react'
import { Checkbox } from '@/components/ui/checkbox'
import { Loader2Icon } from '@/lib/icons'
import { parseTodos, type TodoItem, type TodoStatus } from '@/lib/todos'
import { cn } from '@/lib/utils'
export function todosFromMessageContent(content: unknown): TodoItem[] {
if (!Array.isArray(content)) {return []}
let latest: null | TodoItem[] = null
for (const part of content) {
if (!part || typeof part !== 'object') {continue}
const row = part as Record<string, unknown>
if (row.type !== 'tool-call' || row.toolName !== 'todo') {continue}
const parsed = parseTodos(row.result) ?? parseTodos(row.args)
if (parsed !== null) {latest = parsed}
}
return latest ?? []
}
const headerLabel = (todos: readonly TodoItem[]): string =>
todos.find(t => t.status === 'in_progress')?.content
?? todos.find(t => t.status === 'pending')?.content
?? todos.at(-1)?.content
?? 'Tasks'
const Checkmark: FC<{ status: TodoStatus; label: string }> = ({ status, label }) => {
if (status === 'in_progress') {
return (
<span
aria-label={`In progress: ${label}`}
className="grid size-[1.1rem] shrink-0 place-items-center rounded-full border border-ring/65 bg-[color-mix(in_srgb,var(--dt-ring)_14%,transparent)]"
>
<Loader2Icon className="size-3 animate-spin text-ring" />
</span>
)
}
const checked = status === 'completed'
return (
<Checkbox
aria-label={label}
checked={checked}
className={cn(
'size-[1.1rem] shrink-0 rounded-full border-border/80 pointer-events-none disabled:cursor-default disabled:opacity-100',
checked && 'data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground [&_[data-slot=checkbox-indicator]_svg]:size-3',
status === 'cancelled' && 'border-muted-foreground/40'
)}
disabled
/>
)
}
export const HoistedTodoPanel: FC<{ todos: TodoItem[] }> = ({ todos }) => {
if (!todos.length) {return null}
const label = headerLabel(todos)
return (
<section
className="mt-1 mb-3 inline-block w-fit max-w-full overflow-hidden rounded-2xl border border-border/70 bg-card align-top shadow-[0_1px_2px_0_hsl(var(--foreground)/0.04),0_1px_4px_-1px_hsl(var(--foreground)/0.06)]"
data-slot="aui_todo-hoisted"
>
<header className="px-3 pt-3 pb-2">
<span className="block max-w-full truncate text-[0.85rem] font-semibold leading-tight tracking-tight text-foreground" title={label}>
{label}
</span>
</header>
<ul className="grid min-w-0 gap-0.5 px-3 pb-3">
{todos.map(todo => (
<li
// Active row at full presence; everything else fades. Opacity on
// the row so the checkbox glyph dims with the text.
className={cn(
'flex min-w-0 items-center gap-3 py-1.5 transition-opacity',
todo.status === 'in_progress' ? 'opacity-100' : 'opacity-45'
)}
key={todo.id}
>
<Checkmark label={todo.content} status={todo.status} />
<span className="min-w-0 wrap-anywhere text-[0.8rem] leading-[1.2rem] text-foreground">
{todo.content}
</span>
</li>
))}
</ul>
</section>
)
}
@@ -4,12 +4,12 @@ import { type ToolCallMessagePartProps, useAuiState } from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import { type ReactNode, useEffect, useMemo, useRef } from 'react'
import { useElapsedSeconds } from '@/components/assistant-ui/activity-timer'
import { ActivityTimerText } from '@/components/assistant-ui/activity-timer-text'
import { CompactMarkdown } from '@/components/assistant-ui/compact-markdown'
import { DisclosureRow } from '@/components/assistant-ui/disclosure-row'
import { PreviewAttachment } from '@/components/assistant-ui/preview-attachment'
import { ZoomableImage } from '@/components/assistant-ui/zoomable-image'
import { useElapsedSeconds } from '@/components/chat/activity-timer'
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
import { CompactMarkdown } from '@/components/chat/compact-markdown'
import { DisclosureRow } from '@/components/chat/disclosure-row'
import { PreviewAttachment } from '@/components/chat/preview-attachment'
import { ZoomableImage } from '@/components/chat/zoomable-image'
import { CopyButton } from '@/components/ui/copy-button'
import { FadeText } from '@/components/ui/fade-text'
import { normalizeExternalUrl, PrettyLink, LinkifiedText as SharedLinkifiedText, urlSlugTitleLabel } from '@/lib/external-link'
@@ -26,12 +26,11 @@ import {
Wrench
} from '@/lib/icons'
import type { LucideIcon } from '@/lib/icons'
import { extractToolErrorMessage, formatToolResultSummary } from '@/lib/tool-result-summary'
import { cn } from '@/lib/utils'
import { $toolInlineDiffs } from '@/store/tool-diffs'
import { $toolDisclosureStates, $toolViewMode, setToolDisclosureOpen } from '@/store/tool-view'
const TOOL_DETAIL_INDENT_CLASS = 'w-full pl-(--message-text-indent) pr-2'
type ToolTone = 'agent' | 'browser' | 'default' | 'file' | 'image' | 'terminal' | 'web'
type ToolStatus = 'error' | 'running' | 'success' | 'warning'
@@ -386,52 +385,6 @@ function firstStringField(record: Record<string, unknown>, keys: readonly string
return ''
}
function formatScalar(value: unknown): string {
if (typeof value === 'string') {
return value.trim()
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value)
}
return ''
}
function summarizeRecord(record: Record<string, unknown>): string {
const title = firstStringField(record, ['title', 'name', 'path', 'file', 'filepath', 'url', 'href', 'link'])
const subtitle = firstStringField(record, ['url', 'href', 'link', 'status', 'category'])
const body = firstStringField(record, ['snippet', 'description', 'summary', 'message', 'preview', 'text', 'content'])
if (title || subtitle || body) {
return cleanVisibleText([title, subtitle !== title ? subtitle : '', body].filter(Boolean).join('\n'))
}
return Object.entries(record)
.map(([key, value]) => {
const scalar = formatScalar(value)
return scalar ? `${titleForTool(key)}: ${compactPreview(cleanVisibleText(scalar), 96)}` : ''
})
.filter(Boolean)
.slice(0, 4)
.join('\n')
}
function summarizeList(items: unknown[], max = 5): string {
return items
.map(item => {
if (isRecord(item) || (typeof item === 'string' && item.trim().startsWith('{'))) {
return summarizeRecord(parseMaybeObject(item))
}
return compactPreview(item, 140)
})
.filter(Boolean)
.slice(0, max)
.join('\n\n')
}
function collectResultItems(value: unknown): unknown[] {
if (Array.isArray(value)) {
return value
@@ -460,46 +413,6 @@ function collectResultItems(value: unknown): unknown[] {
return payload === record ? [] : collectResultItems(payload)
}
function friendlyJsonSummary(value: unknown, depth = 0): string {
if (depth > 2) {
return ''
}
if (Array.isArray(value)) {
return summarizeList(value)
}
const record = parseMaybeObject(value)
if (!Object.keys(record).length) {
return ''
}
const direct = firstStringField(record, ['message', 'summary', 'preview', 'content', 'text', 'stdout', 'stderr', 'error'])
if (direct) {
return cleanVisibleText(direct)
}
const items = collectResultItems(record)
if (items.length) {
return summarizeList(items)
}
const payload = unwrapToolPayload(record)
if (payload !== value && payload !== record) {
const payloadSummary = friendlyJsonSummary(payload, depth + 1)
if (payloadSummary) {
return payloadSummary
}
}
return ''
}
function extractSearchResults(result: unknown, limit = 6): SearchResultRow[] {
const list = collectResultItems(result)
@@ -518,16 +431,26 @@ function extractSearchResults(result: unknown, limit = 6): SearchResultRow[] {
}
function toolErrorText(part: ToolPart, result: Record<string, unknown>): string {
const extractedError = extractToolErrorMessage(part.result)
if (part.isError) {
return 'Tool returned an error.'
return extractedError || (typeof part.result === 'string' && part.result.trim()) || 'Tool returned an error.'
}
if (typeof result.error === 'string' && result.error.trim()) {
return result.error.trim()
}
if (result.success === false) {
return firstStringField(result, ['message', 'reason']) || 'Tool returned success=false.'
if (extractedError) {
return extractedError
}
if (result.success === false || result.ok === false) {
return firstStringField(result, ['message', 'reason', 'detail']) || 'Tool returned success=false.'
}
if (typeof result.status === 'string' && /\b(error|failed|failure)\b/i.test(result.status)) {
return firstStringField(result, ['message', 'reason', 'detail']) || `Tool returned status "${result.status}".`
}
const exit = numberValue(result.exit_code)
@@ -630,6 +553,32 @@ function inlineDiffFromResult(result: unknown): string {
return typeof value === 'string' ? stripInlineDiffChrome(value) : ''
}
function minimalValueSummary(value: unknown): string {
if (value == null) {
return ''
}
if (typeof value === 'string') {
return value
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value)
}
if (Array.isArray(value)) {
return value.length ? `Returned ${value.length} items.` : 'No items returned.'
}
if (isRecord(value)) {
const count = Object.keys(value).length
return count ? `Returned object with ${count} fields.` : 'Returned an empty object.'
}
return String(value)
}
function fallbackDetailText(args: unknown, result: unknown): string {
const argContext = contextValue(args)
const resultContext = contextValue(result)
@@ -643,10 +592,10 @@ function fallbackDetailText(args: unknown, result: unknown): string {
}
if (result !== undefined) {
return friendlyJsonSummary(result) || prettyJson(result)
return formatToolResultSummary(result) || minimalValueSummary(result)
}
return friendlyJsonSummary(args) || prettyJson(args)
return formatToolResultSummary(args) || minimalValueSummary(args)
}
function toolSubtitle(
@@ -743,7 +692,7 @@ function toolSubtitle(
}
return (
compactPreview(friendlyJsonSummary(part.result), 120) ||
compactPreview(formatToolResultSummary(part.result), 120) ||
compactPreview(resultRecord, 120) ||
compactPreview(argsRecord, 120) ||
fallbackDetailText(argsRecord, resultRecord)
@@ -1011,15 +960,14 @@ function isToolPart(part: unknown): part is ToolPart {
}
function groupToolParts(content: unknown): ToolPart[][] {
if (!Array.isArray(content)) {
return []
}
if (!Array.isArray(content)) {return []}
const groups: ToolPart[][] = []
let current: ToolPart[] = []
for (const part of content) {
if (isToolPart(part)) {
// todo parts render in their own hoisted panel; skip from grouped tools.
if (isToolPart(part) && part.toolName !== 'todo') {
current.push(part)
continue
@@ -1031,9 +979,7 @@ function groupToolParts(content: unknown): ToolPart[][] {
}
}
if (current.length) {
groups.push(current)
}
if (current.length) {groups.push(current)}
return groups
}
@@ -1134,17 +1080,25 @@ interface ToolEntryProps {
}
function ToolEntry({ embedded = false, part }: ToolEntryProps) {
const isPending = part.result === undefined
const elapsed = useElapsedSeconds(isPending)
const messageRunning = useAuiState(state => state.thread.isRunning && state.message.status?.type === 'running')
const toolViewMode = useStore($toolViewMode)
const disclosureStates = useStore($toolDisclosureStates)
const disclosureId = toolPartDisclosureId(part)
const isPending = messageRunning && part.result === undefined
const elapsed = useElapsedSeconds(isPending, `tool:${disclosureId}`)
const open = disclosureStates[disclosureId] ?? false
const preview = compactPreview(part.args) || compactPreview(part.result)
const liveDiffs = useStore($toolInlineDiffs)
const sideDiff = part.toolCallId ? liveDiffs[part.toolCallId] || '' : ''
const inlineDiff = stripInlineDiffChrome(sideDiff) || inlineDiffFromResult(part.result)
const view = useMemo(() => buildToolView(part, inlineDiff), [inlineDiff, part])
// Stale parts (no result, but message stopped running) get a synthetic
// empty result so buildToolView treats them as completed-no-output.
const view = useMemo(() => {
const p = !isPending && part.result === undefined ? { ...part, result: {} } : part
return buildToolView(p, inlineDiff)
}, [inlineDiff, isPending, part])
const detailSections = useMemo(() => {
if (!view.detail) {
@@ -1261,7 +1215,7 @@ function ToolEntry({ embedded = false, part }: ToolEntryProps) {
))}
</DisclosureRow>
{open && (
<div className={cn(TOOL_DETAIL_INDENT_CLASS, 'mt-2 grid min-w-0 max-w-full gap-2 overflow-hidden pb-2')}>
<div className={cn('w-full pl-(--message-text-indent) pr-2', 'mt-2 grid min-w-0 max-w-full gap-2 overflow-hidden pb-2')}>
{!embedded && view.previewTarget && isPreviewableTarget(view.previewTarget) && (
<PreviewAttachment source="tool-result" target={view.previewTarget} />
)}
@@ -1359,7 +1313,8 @@ function groupPreviewTargets(parts: ToolPart[]): string[] {
}
function ToolGroup({ parts }: { parts: ToolPart[] }) {
const isRunning = parts.some(part => part.result === undefined)
const messageRunning = useAuiState(state => state.thread.isRunning && state.message.status?.type === 'running')
const isRunning = messageRunning && parts.some(part => part.result === undefined)
const disclosureStates = useStore($toolDisclosureStates)
const disclosureId = toolGroupDisclosureId(parts)
const open = disclosureStates[disclosureId] ?? isRunning
@@ -1374,6 +1329,7 @@ function ToolGroup({ parts }: { parts: ToolPart[] }) {
}, [disclosureId, isRunning])
const status = groupStatus(parts)
const displayStatus = !isRunning && status === 'running' ? 'warning' : status
const failedStepCount = useMemo(
() => parts.filter(part => toolStatus(part, parseMaybeObject(part.result)) === 'error').length,
@@ -1395,9 +1351,9 @@ function ToolGroup({ parts }: { parts: ToolPart[] }) {
}, [parts])
const statusSummary =
status === 'running' || failedStepCount === 0
displayStatus === 'running' || failedStepCount === 0
? ''
: status === 'warning'
: displayStatus === 'warning'
? failedStepCount === 1
? 'Recovered after 1 failed step'
: `Recovered after ${failedStepCount} failed steps`
@@ -1430,7 +1386,7 @@ function ToolGroup({ parts }: { parts: ToolPart[] }) {
.join('\n\n')
}, [parts])
const showGroupStatusGlyph = status !== 'success'
const showGroupStatusGlyph = displayStatus !== 'success'
const previewTargets = useMemo(() => groupPreviewTargets(parts), [parts])
return (
@@ -1446,13 +1402,13 @@ function ToolGroup({ parts }: { parts: ToolPart[] }) {
>
<span className="flex min-w-0 items-baseline gap-1.5">
{showGroupStatusGlyph && (
<span className="flex h-[1.1rem] shrink-0 items-center">{statusGlyph(status)}</span>
<span className="flex h-[1.1rem] shrink-0 items-center">{statusGlyph(displayStatus)}</span>
)}
<FadeText
className={cn(
'text-[0.78rem] font-medium leading-[1.1rem] text-foreground/85',
status === 'error' && 'text-destructive',
status === 'warning' && 'text-amber-700 dark:text-amber-300'
displayStatus === 'error' && 'text-destructive',
displayStatus === 'warning' && 'text-amber-700 dark:text-amber-300'
)}
>
{groupTitle(parts)}
@@ -1472,7 +1428,7 @@ function ToolGroup({ parts }: { parts: ToolPart[] }) {
<FadeText
className={cn(
'text-[0.68rem] leading-[1.05rem]',
status === 'warning' ? 'text-amber-700/80 dark:text-amber-300/85' : 'text-destructive/85'
displayStatus === 'warning' ? 'text-amber-700/80 dark:text-amber-300/85' : 'text-destructive/85'
)}
>
{statusSummary}
@@ -1480,14 +1436,14 @@ function ToolGroup({ parts }: { parts: ToolPart[] }) {
)}
</DisclosureRow>
{previewTargets.length > 0 && (
<div className={cn(TOOL_DETAIL_INDENT_CLASS, 'mt-2 grid min-w-0 max-w-full gap-2 overflow-hidden')}>
<div className={cn('w-full pl-(--message-text-indent) pr-2', 'mt-2 grid min-w-0 max-w-full gap-2 overflow-hidden')}>
{previewTargets.map(target => (
<PreviewAttachment key={target} source="tool-result" target={target} />
))}
</div>
)}
{open && (
<div className={cn(TOOL_DETAIL_INDENT_CLASS, 'mt-0.5 min-w-0 max-w-full overflow-hidden')}>
<div className={cn('w-full pl-(--message-text-indent) pr-2', 'mt-0.5 min-w-0 max-w-full overflow-hidden')}>
{parts.map(part => (
<ToolEntry embedded key={part.toolCallId || `${part.toolName}-${JSON.stringify(part.args)}`} part={part} />
))}
@@ -1,9 +0,0 @@
'use client'
import { type ReactNode } from 'react'
export const ToolGroupRoot = ({ children }: { children: ReactNode }) => (
<div className="my-2 flex flex-col gap-1">{children}</div>
)
export const ToolGroupTrigger = (_props: { count?: number; active?: boolean }) => null
export const ToolGroupContent = ({ children }: { children: ReactNode }) => <div>{children}</div>
@@ -0,0 +1,43 @@
import { act, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { __resetElapsedTimerRegistryForTests, useElapsedSeconds } from './activity-timer'
function Probe({ active, timerKey }: { active: boolean; timerKey?: string }) {
const elapsed = useElapsedSeconds(active, timerKey)
return <span data-testid="elapsed">{elapsed}</span>
}
describe('useElapsedSeconds', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'))
__resetElapsedTimerRegistryForTests()
})
afterEach(() => {
vi.useRealTimers()
__resetElapsedTimerRegistryForTests()
})
it('keeps elapsed time stable across remounts for the same key', () => {
const first = render(<Probe active timerKey="tool:abc" />)
act(() => {
vi.advanceTimersByTime(5_000)
})
expect(screen.getByTestId('elapsed').textContent).toBe('5')
first.unmount()
act(() => {
vi.advanceTimersByTime(3_000)
})
render(<Probe active timerKey="tool:abc" />)
expect(screen.getByTestId('elapsed').textContent).toBe('8')
})
})
@@ -0,0 +1,52 @@
import { useEffect, useRef, useState } from 'react'
// Module-level registry so timers survive component unmount/remount (e.g.
// when a tool row scrolls out and back). Keyed by caller-supplied timerKey;
// anonymous timers (no key) start fresh each mount.
const startedAtByKey = new Map<string, number>()
function startedAt(key?: string): number {
if (!key) {return Date.now()}
const existing = startedAtByKey.get(key)
if (existing !== undefined) {return existing}
const now = Date.now()
startedAtByKey.set(key, now)
return now
}
export function formatElapsed(seconds: number): string {
if (seconds < 60) {return `${seconds}s`}
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
}
export function useElapsedSeconds(active = true, timerKey?: string): number {
const start = useRef(startedAt(timerKey))
const lastKey = useRef(timerKey)
const [elapsed, setElapsed] = useState(() => Math.max(0, Math.floor((Date.now() - start.current) / 1000)))
if (lastKey.current !== timerKey) {
start.current = startedAt(timerKey)
lastKey.current = timerKey
}
useEffect(() => {
if (!active) {return}
if (timerKey) {start.current = startedAt(timerKey)}
const tick = () => setElapsed(Math.max(0, Math.floor((Date.now() - start.current) / 1000)))
tick()
const id = window.setInterval(tick, 1000)
return () => window.clearInterval(id)
}, [active, timerKey])
return elapsed
}
export function __resetElapsedTimerRegistryForTests() {
startedAtByKey.clear()
}
+1 -3
View File
@@ -14,8 +14,6 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog'
import { Skeleton } from './ui/skeleton'
const pickerPanelClass = 'max-h-[85vh] max-w-2xl gap-0 overflow-hidden p-0'
interface ModelPickerDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
@@ -73,7 +71,7 @@ export function ModelPickerDialog({
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className={pickerPanelClass}>
<DialogContent className="max-h-[85vh] max-w-2xl gap-0 overflow-hidden p-0">
<DialogHeader className="border-b border-border px-4 py-3">
<DialogTitle>Switch model</DialogTitle>
<DialogDescription className="font-mono text-xs leading-relaxed">
+104
View File
@@ -26,6 +26,46 @@ describe('toChatMessages', () => {
expect(chatMessageText(messages[0])).toBe('Planning.Done.')
})
it('keeps assistant tool-call iterations in one loaded assistant bubble', () => {
const messages = toChatMessages([
{ role: 'user', content: 'check this repo', timestamp: 1 },
{
role: 'assistant',
content: "Let me also check if there's a top-level lint workflow.",
timestamp: 2,
tool_calls: [{ id: 'tc-1', function: { name: 'search_files', arguments: '{"path":".github"}' } }]
},
{
role: 'tool',
tool_call_id: 'tc-1',
tool_name: 'search_files',
content: '{"error":"Path not found: /repo/.github"}',
timestamp: 3
},
{
role: 'assistant',
content: 'No CI in this repo. Build is enough.',
timestamp: 4,
tool_calls: [{ id: 'tc-2', function: { name: 'terminal', arguments: '{"command":"git status --short"}' } }]
},
{
role: 'tool',
tool_call_id: 'tc-2',
tool_name: 'terminal',
content: '{"output":"M src/ui/components/image-distortion.tsx\\n","exit_code":0}',
timestamp: 5
},
{ role: 'assistant', content: 'Now let me check git status and commit.', timestamp: 6 }
])
const assistantMessages = messages.filter(message => message.role === 'assistant')
expect(assistantMessages).toHaveLength(1)
expect(assistantMessages[0].parts.filter(part => part.type === 'tool-call')).toHaveLength(2)
expect(chatMessageText(assistantMessages[0])).toContain("Let me also check if there's a top-level lint workflow.")
expect(chatMessageText(assistantMessages[0])).toContain('Now let me check git status and commit.')
})
it('hides attached context payloads from user message display', () => {
const [message] = toChatMessages([
{
@@ -120,4 +160,68 @@ describe('upsertToolPart', () => {
inline_diff: '--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new'
})
})
it('keeps live todo rows stable across sparse progress payloads', () => {
const first = upsertToolPart(
[],
{
name: 'todo',
todos: [{ content: 'Boil water', id: 'boil', status: 'in_progress' }],
tool_id: 'todo-1'
},
'running'
)
const progressed = upsertToolPart(
first,
{
name: 'todo',
preview: 'updating plan',
tool_id: 'todo-1'
},
'running'
)
const [part] = progressed
const args = part && 'args' in part ? (part.args as Record<string, unknown>) : {}
expect(args.todos).toEqual([{ content: 'Boil water', id: 'boil', status: 'in_progress' }])
})
it('archives todo state on completion and accepts explicit empty clears', () => {
const started = upsertToolPart(
[],
{
name: 'todo',
todos: [{ content: 'Boil water', id: 'boil', status: 'in_progress' }],
tool_id: 'todo-1'
},
'running'
)
const completed = upsertToolPart(
started,
{
name: 'todo',
tool_id: 'todo-1'
},
'complete'
)
const cleared = upsertToolPart(
completed,
{
name: 'todo',
todos: [],
tool_id: 'todo-1'
},
'complete'
)
const completedResult = completed[0] && 'result' in completed[0] ? (completed[0].result as Record<string, unknown>) : {}
const clearedResult = cleared[0] && 'result' in cleared[0] ? (cleared[0].result as Record<string, unknown>) : {}
expect(completedResult.todos).toEqual([{ content: 'Boil water', id: 'boil', status: 'in_progress' }])
expect(clearedResult.todos).toEqual([])
})
})
+98 -36
View File
@@ -1,6 +1,7 @@
import type { ThreadMessageLike } from '@assistant-ui/react'
import { mediaDisplayLabel, mediaMarkdownHref } from '@/lib/media'
import { parseTodos } from '@/lib/todos'
import type { SessionMessage, UsageStats } from '@/types/hermes'
export type ChatMessagePart = Exclude<ThreadMessageLike['content'], string>[number]
@@ -211,21 +212,42 @@ function toolId(payload: GatewayEventPayload | undefined): string {
return payload?.tool_id || payload?.name || `tool-${Date.now()}`
}
function toolArgs(payload: GatewayEventPayload | undefined): Record<string, unknown> {
// Carry todo state across sparse progress payloads: if this todo event lacks
// a `todos` field, fall back to whatever we previously stored on the part.
function carryTodos(payload: GatewayEventPayload | undefined, ...prev: unknown[]): { todos: unknown } | undefined {
if (payload && Object.hasOwn(payload, 'todos')) {
const next = parseTodos(payload.todos)
return next === null ? undefined : { todos: next }
}
if (payload?.name !== 'todo') {return undefined}
for (const p of prev) {
const carried = parseTodos(recordFromUnknown(p)?.todos)
if (carried !== null) {return { todos: carried }}
}
return undefined
}
function toolArgs(payload: GatewayEventPayload | undefined, prevArgs?: unknown): Record<string, unknown> {
return {
...(payload?.context ? { context: payload.context } : {}),
...(payload?.preview ? { preview: payload.preview } : {})
...(payload?.preview ? { preview: payload.preview } : {}),
...carryTodos(payload, prevArgs)
}
}
function toolResult(payload: GatewayEventPayload | undefined): Record<string, unknown> {
function toolResult(payload: GatewayEventPayload | undefined, prevResult?: unknown, prevArgs?: unknown): Record<string, unknown> {
return {
...(payload?.inline_diff ? { inline_diff: payload.inline_diff } : {}),
...(payload?.summary ? { summary: payload.summary } : {}),
...(payload?.message ? { message: payload.message } : {}),
...(payload?.preview ? { preview: payload.preview } : {}),
...(payload?.duration_s !== undefined ? { duration_s: payload.duration_s } : {}),
...(payload?.todos ? { todos: payload.todos } : {}),
...carryTodos(payload, prevResult, prevArgs),
...(payload?.error ? { error: payload.error } : {})
}
}
@@ -243,24 +265,21 @@ export function upsertToolPart(
part => part.type === 'tool-call' && ((part.toolCallId && part.toolCallId === id) || part.toolName === name)
)
const prev = index >= 0 ? next[index] : null
const prevArgs = prev && 'args' in prev ? prev.args : undefined
const prevResult = prev && 'result' in prev ? prev.result : undefined
const args = toolArgs(payload, prevArgs)
const base = {
type: 'tool-call' as const,
toolCallId: id,
toolName: name,
args: toolArgs(payload) as never,
argsText: JSON.stringify(toolArgs(payload)),
...(phase === 'complete'
? {
result: toolResult(payload),
isError: Boolean(payload?.error)
}
: {})
args: args as never,
argsText: JSON.stringify(args),
...(phase === 'complete' && { result: toolResult(payload, prevResult, prevArgs), isError: Boolean(payload?.error) })
} satisfies ChatMessagePart
if (index === -1) {
return [...next, base]
}
if (index === -1) {return [...next, base]}
next[index] = { ...next[index], ...base }
return next
@@ -457,20 +476,48 @@ export function toChatMessages(messages: SessionMessage[]): ChatMessage[] {
const result: ChatMessage[] = []
let pendingToolParts: ChatMessagePart[] = []
let pendingToolTimestamp: number | undefined
let activeAssistantIndex: null | number = null
const clearPendingTools = () => {
pendingToolParts = []
pendingToolTimestamp = undefined
}
const appendPartsToActiveAssistant = (parts: ChatMessagePart[], timestamp?: number): boolean => {
if (activeAssistantIndex === null) {
return false
}
const active = result[activeAssistantIndex]
if (!active || active.role !== 'assistant') {
activeAssistantIndex = null
return false
}
active.parts = [...active.parts, ...parts]
active.timestamp = timestamp ?? active.timestamp
return true
}
const flushPendingTools = (index: number) => {
if (!pendingToolParts.length) {
return
}
result.push({
id: `${pendingToolTimestamp || Date.now()}-${index}-tools`,
role: 'assistant',
parts: pendingToolParts,
timestamp: pendingToolTimestamp
})
pendingToolParts = []
pendingToolTimestamp = undefined
if (!appendPartsToActiveAssistant(pendingToolParts, pendingToolTimestamp)) {
result.push({
id: `${pendingToolTimestamp || Date.now()}-${index}-tools`,
role: 'assistant',
parts: pendingToolParts,
timestamp: pendingToolTimestamp
})
activeAssistantIndex = result.length - 1
}
clearPendingTools()
}
messages.forEach((message, index) => {
@@ -515,6 +562,11 @@ export function toChatMessages(messages: SessionMessage[]): ChatMessage[] {
}
if (!parts.length) {
if (message.role !== 'assistant') {
flushPendingTools(index)
activeAssistantIndex = null
}
return
}
@@ -528,22 +580,30 @@ export function toChatMessages(messages: SessionMessage[]): ChatMessage[] {
return
}
if (message.role === 'assistant' && pendingToolParts.length) {
const last = result.at(-1)
if (message.role === 'assistant') {
if (pendingToolParts.length) {
if (!appendPartsToActiveAssistant(pendingToolParts, message.timestamp ?? pendingToolTimestamp)) {
parts.unshift(...pendingToolParts)
}
if (last?.role === 'assistant') {
last.parts = [...last.parts, ...pendingToolParts, ...parts]
last.timestamp = message.timestamp ?? last.timestamp
pendingToolParts = []
pendingToolTimestamp = undefined
clearPendingTools()
}
const activeAssistant =
activeAssistantIndex !== null && result[activeAssistantIndex]?.role === 'assistant'
? result[activeAssistantIndex]
: null
const currentHasToolCall = parts.some(part => part.type === 'tool-call')
const activeHasToolCall = Boolean(activeAssistant?.parts.some(part => part.type === 'tool-call'))
if (activeAssistant && (currentHasToolCall || activeHasToolCall)) {
activeAssistant.parts = [...activeAssistant.parts, ...parts]
activeAssistant.timestamp = message.timestamp ?? activeAssistant.timestamp
return
}
parts.unshift(...pendingToolParts)
pendingToolParts = []
pendingToolTimestamp = undefined
} else if (message.role !== 'assistant') {
} else {
flushPendingTools(index)
}
@@ -553,6 +613,8 @@ export function toChatMessages(messages: SessionMessage[]): ChatMessage[] {
parts,
timestamp: message.timestamp
})
activeAssistantIndex = message.role === 'assistant' ? result.length - 1 : null
})
flushPendingTools(messages.length)
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import { parseTodos } from './todos'
describe('parseTodos', () => {
it('parses todo arrays with valid ids, content, and statuses', () => {
expect(
parseTodos([
{ content: 'Gather ingredients', id: 'prep', status: 'completed' },
{ content: 'Boil water', id: 'boil', status: 'in_progress' },
{ content: 'Serve', id: 'serve', status: 'pending' }
])
).toEqual([
{ content: 'Gather ingredients', id: 'prep', status: 'completed' },
{ content: 'Boil water', id: 'boil', status: 'in_progress' },
{ content: 'Serve', id: 'serve', status: 'pending' }
])
})
it('parses nested todo payloads from wrapped objects and JSON strings', () => {
expect(parseTodos({ todos: [{ content: 'Plate', id: 'plate', status: 'pending' }] })).toEqual([
{ content: 'Plate', id: 'plate', status: 'pending' }
])
expect(parseTodos('{"todos":[{"id":"plate","content":"Plate","status":"pending"}]}')).toEqual([
{ content: 'Plate', id: 'plate', status: 'pending' }
])
})
it('returns null for non-todo payloads', () => {
expect(parseTodos(undefined)).toBeNull()
expect(parseTodos('not json')).toBeNull()
expect(parseTodos({ message: 'no todos here' })).toBeNull()
})
})
+38
View File
@@ -0,0 +1,38 @@
export type TodoStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled'
export interface TodoItem {
content: string
id: string
status: TodoStatus
}
const STATUSES: readonly TodoStatus[] = ['pending', 'in_progress', 'completed', 'cancelled']
const isRecord = (v: unknown): v is Record<string, unknown> => Boolean(v && typeof v === 'object' && !Array.isArray(v))
const isStatus = (v: unknown): v is TodoStatus => (STATUSES as readonly string[]).includes(v as string)
function parseArray(value: unknown[]): TodoItem[] {
return value.flatMap(item => {
if (!isRecord(item) || !isStatus(item.status)) {return []}
const id = String(item.id ?? '').trim()
const content = String(item.content ?? '').trim()
return id && content ? [{ content, id, status: item.status }] : []
})
}
function parse(value: unknown, depth: number): null | TodoItem[] {
if (depth > 2) {return null}
if (Array.isArray(value)) {return parseArray(value)}
if (typeof value === 'string' && value.trim()) {
try { return parse(JSON.parse(value), depth + 1) } catch { return null }
}
if (isRecord(value) && Object.hasOwn(value, 'todos')) {return parse(value.todos, depth + 1)}
return null
}
export const parseTodos = (value: unknown): null | TodoItem[] => parse(value, 0)
@@ -0,0 +1,108 @@
import { describe, expect, it } from 'vitest'
import { extractToolErrorMessage, formatToolResultSummary } from './tool-result-summary'
describe('formatToolResultSummary', () => {
it('unwraps wrapper payloads into structured key-value lines', () => {
const summary = formatToolResultSummary({
success: true,
result: {
data: {
path: '/tmp/demo.txt',
status: 'ok',
lines_written: 12,
checksum: 'abc123'
}
}
})
expect(summary).toContain('- Path: /tmp/demo.txt')
expect(summary).toContain('- Status: ok')
expect(summary).toContain('- Lines Written: 12')
expect(summary).not.toContain('"path"')
})
it('summarizes object arrays as readable list items', () => {
const summary = formatToolResultSummary([
{ title: 'First result', snippet: 'alpha preview text' },
{ title: 'Second result', status: 'cached' },
{ title: 'Third result', summary: 'more details' },
{ title: 'Fourth result', summary: 'line 4' },
{ title: 'Fifth result', summary: 'line 5' },
{ title: 'Sixth result', summary: 'line 6' },
{ title: 'Seventh result', summary: 'line 7' }
])
expect(summary).toContain('- First result - alpha preview text')
expect(summary).toContain('- Second result (cached)')
expect(summary).toContain('- … 1 more item')
})
it('truncates long field values for compact display', () => {
const summary = formatToolResultSummary({
message: 'ok',
details: `prefix ${'x'.repeat(500)}`
})
const detailsLine = summary
.split('\n')
.find(line => line.startsWith('- Details:'))
expect(detailsLine).toBeTruthy()
expect(detailsLine?.length).toBeLessThan(230)
expect(detailsLine).toContain('…')
})
it('formats stringified json payloads without raw dumps', () => {
const summary = formatToolResultSummary(
JSON.stringify({
data: {
title: 'Build report',
completed: true
}
})
)
expect(summary).toContain('- Title: Build report')
expect(summary).toContain('- Completed: true')
})
})
describe('extractToolErrorMessage', () => {
it('finds nested error messages through wrappers', () => {
const error = extractToolErrorMessage({
success: false,
result: {
output: {
error: {
message: 'Permission denied writing /tmp/demo.txt'
}
}
}
})
expect(error).toBe('Permission denied writing /tmp/demo.txt')
})
it('does not treat successful payload messages as errors', () => {
const error = extractToolErrorMessage({
success: true,
message: 'Completed successfully',
data: { count: 3 }
})
expect(error).toBe('')
})
it('ignores placeholder error fields in successful payloads', () => {
const error = extractToolErrorMessage({
success: true,
data: {
error: 'none',
status: 'ok'
}
})
expect(error).toBe('')
})
})
+326
View File
@@ -0,0 +1,326 @@
// Heuristic JSON → human summary for tool results. Default view; technical
// mode still gets the raw JSON section.
const WRAPPER_KEYS = ['data', 'result', 'output', 'response', 'payload'] as const
const PRIORITY_KEYS = ['title', 'name', 'path', 'file', 'filepath', 'url', 'href', 'link', 'status', 'id', 'message', 'summary', 'description'] as const
const ERROR_KEYS = ['error', 'errors', 'failure', 'exception'] as const
const ERROR_MSG_KEYS = ['message', 'reason', 'detail', 'stderr'] as const
const NON_ERROR_TEXT = new Set(['', '0', 'false', 'none', 'null', 'nil', 'ok', 'success', 'n/a', 'na'])
type Json = Record<string, unknown>
const isRecord = (v: unknown): v is Json => Boolean(v && typeof v === 'object' && !Array.isArray(v))
function tryJson(value: string): unknown {
const t = value.trim()
if (!t) {return ''}
if (!/^[{[]|^"/.test(t)) {return value}
try { return JSON.parse(t) } catch { return value }
}
const norm = (v: unknown): unknown => (typeof v === 'string' ? tryJson(v) : v)
const titleCase = (k: string) =>
k.split(/[_\-.]+/).filter(Boolean).map(p => `${p[0]?.toUpperCase() ?? ''}${p.slice(1)}`).join(' ')
const pluralize = (n: number, noun: string) => `${n} ${noun}${n === 1 ? '' : 's'}`
function clipInline(value: string, max = 180): string {
const c = value.replace(/\s+/g, ' ').trim()
return c.length > max ? `${c.slice(0, max - 1)}` : c
}
function clipBlock(value: string, maxChars = 1800, maxLines = 18): string {
const t = value.trim()
if (!t) {return ''}
const lines = t.split('\n')
let text = lines.slice(0, maxLines).join('\n')
const clipped = lines.length > maxLines || text.length > maxChars
if (text.length > maxChars) {text = text.slice(0, maxChars - 1).trimEnd()}
return clipped && !text.endsWith('…') ? `${text}` : text
}
function firstString(record: Json, keys: readonly string[]): string {
for (const k of keys) {
const v = record[k]
if (typeof v === 'string' && v.trim()) {return v.trim()}
}
return ''
}
function orderedKeys(keys: string[]): string[] {
const priority = PRIORITY_KEYS.filter(k => keys.includes(k))
const rest = keys.filter(k => !priority.includes(k as never))
return [...priority, ...rest]
}
const isWrapperKey = (k: string) => (WRAPPER_KEYS as readonly string[]).includes(k)
const skipField = (k: string, v: unknown) => isWrapperKey(k) || ((k === 'success' || k === 'ok') && v === true)
function summarizeScalar(v: unknown): string {
if (typeof v === 'string') {return clipInline(v)}
if (typeof v === 'number' || typeof v === 'boolean') {return String(v)}
return ''
}
function summarizeRecordInline(record: Json, depth: number): string {
if (depth > 3) {return pluralize(Object.keys(record).length, 'field')}
const title = firstString(record, ['title', 'name', 'path', 'file', 'filepath', 'url', 'href', 'link', 'id'])
const status = firstString(record, ['status', 'category', 'type'])
const message = firstString(record, ['snippet', 'summary', 'description', 'message'])
if (title && status) {return `${clipInline(title, 110)} (${clipInline(status, 54)})`}
if (title && message && title !== message) {return `${clipInline(title, 90)} - ${clipInline(message, 84)}`}
if (title) {return clipInline(title, 150)}
const pairs = orderedKeys(Object.keys(record))
.filter(k => !skipField(k, record[k]))
.map(k => {
const s = summarizeScalar(record[k])
return s ? `${titleCase(k)}: ${s}` : ''
})
.filter(Boolean)
.slice(0, 2)
return pairs.length ? pairs.join(' · ') : pluralize(Object.keys(record).length, 'field')
}
function summarizeListItem(item: unknown, depth: number): string {
const v = norm(item)
if (typeof v === 'string') {return clipInline(v)}
if (typeof v === 'number' || typeof v === 'boolean') {return String(v)}
if (v == null) {return ''}
if (Array.isArray(v)) {return pluralize(v.length, 'item')}
if (isRecord(v)) {return summarizeRecordInline(v, depth + 1)}
return clipInline(String(v))
}
function formatFieldValue(value: unknown, depth: number): string {
const v = norm(value)
const scalar = summarizeScalar(v)
if (scalar) {return scalar}
if (v == null) {return ''}
if (Array.isArray(v)) {
if (!v.length) {return '0 items'}
const scalars = v.map(summarizeScalar).filter(Boolean)
if (scalars.length === v.length && v.length <= 4) {return clipInline(scalars.join(', '))}
const first = summarizeListItem(v[0], depth + 1)
return first ? `${pluralize(v.length, 'item')} (${first})` : pluralize(v.length, 'item')
}
if (isRecord(v)) {return summarizeRecordInline(v, depth + 1)}
return clipInline(String(v))
}
function formatArraySummary(value: unknown[], depth: number): string {
if (!value.length) {return 'No items returned.'}
const max = 6
const lines = value.slice(0, max).map(item => summarizeListItem(item, depth + 1)).filter(Boolean).map(l => `- ${l}`)
if (!lines.length) {return `Returned ${pluralize(value.length, 'item')}.`}
if (value.length > max) {
const remaining = value.length - max
lines.push(`- … ${remaining} more ${remaining === 1 ? 'item' : 'items'}`)
}
return lines.join('\n')
}
function formatRecordSummary(record: Json, depth: number): string {
const keys = Object.keys(record)
if (!keys.length) {return 'Returned an empty object.'}
if (depth <= 2) {
const direct = firstString(record, ['message', 'summary', 'description', 'preview', 'text', 'content'])
const meaningful = keys.filter(k => !skipField(k, record[k]) && !isWrapperKey(k))
if (direct && meaningful.length <= 1) {return clipBlock(direct)}
}
const candidates = orderedKeys(keys).filter(k => !skipField(k, record[k]))
const max = 8
const lines: string[] = []
for (const k of candidates) {
const v = formatFieldValue(record[k], depth + 1)
if (!v) {continue}
lines.push(`- ${titleCase(k)}: ${v}`)
if (lines.length >= max) {break}
}
if (!lines.length) {return `Returned object with ${pluralize(keys.length, 'field')}.`}
if (candidates.length > lines.length) {
const remaining = candidates.length - lines.length
lines.push(`- … ${remaining} more ${remaining === 1 ? 'field' : 'fields'}`)
}
return lines.join('\n')
}
function formatSummaryValue(value: unknown, depth: number): string {
if (depth > 4) {return ''}
const v = norm(value)
if (typeof v === 'string') {return clipBlock(v)}
if (typeof v === 'number' || typeof v === 'boolean') {return String(v)}
if (v == null) {return ''}
if (Array.isArray(v)) {return formatArraySummary(v, depth + 1)}
if (isRecord(v)) {return formatRecordSummary(v, depth + 1)}
return clipInline(String(v))
}
function unwrapPayload(value: unknown): unknown {
let cur: unknown = norm(value)
for (let i = 0; i < 4; i += 1) {
if (!isRecord(cur)) {
return cur
}
const record = cur
const key = WRAPPER_KEYS.find(k => record[k] != null)
if (!key) {
return record
}
cur = norm(record[key])
}
return cur
}
function hasMeaningfulErrorValue(value: unknown): boolean {
const v = norm(value)
if (v == null) {return false}
if (typeof v === 'string') {return !NON_ERROR_TEXT.has(v.trim().toLowerCase())}
if (typeof v === 'boolean') {return v}
if (typeof v === 'number') {return v !== 0}
if (Array.isArray(v)) {return v.some(hasMeaningfulErrorValue)}
if (isRecord(v)) {return Object.keys(v).length > 0}
return true
}
function hasErrorSignal(record: Json): boolean {
const status = typeof record.status === 'string' ? record.status : ''
return (
record.success === false ||
record.ok === false ||
/\b(error|failed|failure|fatal|exception)\b/i.test(status) ||
ERROR_KEYS.some(k => hasMeaningfulErrorValue(record[k]))
)
}
function valueErrorText(value: unknown): string {
const v = norm(value)
if (typeof v === 'string') {return hasMeaningfulErrorValue(v) ? clipBlock(v, 700, 12) : ''}
if (Array.isArray(v)) {
return clipBlock(v.map(valueErrorText).filter(Boolean).slice(0, 3).join('; '), 700, 12)
}
if (isRecord(v)) {
const direct = firstString(v, ERROR_MSG_KEYS)
if (direct) {return clipBlock(direct, 700, 12)}
}
return ''
}
function findNestedError(value: unknown, depth: number, seen: Set<unknown>): string {
if (depth > 5) {return ''}
const v = norm(value)
if (!v || typeof v !== 'object' || seen.has(v)) {return ''}
seen.add(v)
if (Array.isArray(v)) {
for (const item of v) {
const nested = findNestedError(item, depth + 1, seen)
if (nested) {return nested}
}
return ''
}
const record = v as Json
for (const k of ERROR_KEYS) {
if (!hasMeaningfulErrorValue(record[k])) {continue}
const text = valueErrorText(record[k])
if (text) {return text}
}
if (hasErrorSignal(record)) {
const direct = firstString(record, ERROR_MSG_KEYS)
if (direct) {return clipBlock(direct, 700, 12)}
}
for (const k of [...ERROR_KEYS, ...WRAPPER_KEYS, 'details', 'meta']) {
const nested = findNestedError(record[k], depth + 1, seen)
if (nested) {return nested}
}
return ''
}
export function formatToolResultSummary(value: unknown): string {
return formatSummaryValue(unwrapPayload(value), 0) || formatSummaryValue(value, 0)
}
export function extractToolErrorMessage(value: unknown): string {
return findNestedError(value, 0, new Set())
}