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
@@ -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[] {