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) {