Tighten conversation rhythm, flatten the tool list, and smooth streaming text

Conversation rhythm:
- Single `--paragraph-gap` knob drives paragraph spacing both inside a
  markdown block and between consecutive prose parts, out-specifying Tailwind
  Typography's prose margins. Code cards carry the same gap themselves so it
  holds at any Streamdown nesting depth.
- Two-tier vertical rhythm: `--turn-block-gap` separates scaffolding (tools /
  thinking) from the reply; `--tool-row-gap` keeps a tool run tight.
- Drop the prose indent so prose, tools, todos, and thinking share one left
  edge. `---` renders as quiet spacing, not a heavy rule.

Flat tool list:
- Tools always render as a standalone-row stack, never a "Tool actions · N
  steps" group. assistant-ui slices the tool range unstably (interleaved live
  vs. reconstructed-consecutive when settled), so grouping reshuffled the whole
  turn the instant it settled. Flat rows are pixel-identical either way.
- Inline approvals can no longer be buried in a collapsed group body.
- Remove the now-dead grouping helpers from tool-fallback-model.

Empty thinking:
- Suppress reasoning disclosures with no visible text (encrypted / spinner-
  coerced reasoning) instead of leaving an empty "Thinking" header.
- Tail stall indicator returns "thinking" when a running turn goes quiet.

Streaming cadence:
- Smooth character-reveal decouples visible cadence from bursty arrival.
- Flush queued text deltas before applying tool events so a tool row can't
  jump ahead of its preceding text.
- Disable Nagle on the GUI WebSocket so per-token frames aren't coalesced.

Polish: clarify/patch/vision_analyze tool meta, queue-panel + diff-lines
spacing, sticky human bubble expands on focus (not hover).
This commit is contained in:
Brooklyn Nicholson
2026-06-06 10:45:31 -05:00
parent 6bbc5eefa0
commit 9d31577590
12 changed files with 287 additions and 355 deletions
@@ -30,13 +30,13 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN
} }
return ( return (
<div className="rounded-t-2xl border border-b-0 border-border/65 bg-[color-mix(in_srgb,var(--dt-card)_70%,transparent)] pt-0.5 pb-1"> <div className="rounded-t-2xl border border-b-0 border-border/65 bg-[color-mix(in_srgb,var(--dt-card)_70%,transparent)] pt-0.5 pb-1 mx-1">
<button <button
className="flex w-full items-center gap-1.5 px-2 py-0.5 text-left text-[0.72rem] font-medium text-muted-foreground/92 transition-colors hover:text-foreground/90" className="flex w-full items-center gap-1.5 px-2 text-left text-[0.6rem] font-medium text-muted-foreground/92 transition-colors hover:text-foreground/90"
onClick={() => setCollapsed(open => !open)} onClick={() => setCollapsed(open => !open)}
type="button" type="button"
> >
<DisclosureCaret className="shrink-0" open={!collapsed} size="0.875rem" /> <DisclosureCaret className="shrink-0" open={!collapsed} size="1em" />
<span className="truncate">{c.queued(entries.length)}</span> <span className="truncate">{c.queued(entries.length)}</span>
</button> </button>
@@ -64,11 +64,7 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN
<p className="truncate text-[0.73rem] leading-4 text-foreground/92">{entryPreview(entry, c)}</p> <p className="truncate text-[0.73rem] leading-4 text-foreground/92">{entryPreview(entry, c)}</p>
{(attachmentsCount > 0 || isEditing) && ( {(attachmentsCount > 0 || isEditing) && (
<div className="mt-0.5 flex items-center gap-1.5 text-[0.64rem] text-muted-foreground/75"> <div className="mt-0.5 flex items-center gap-1.5 text-[0.64rem] text-muted-foreground/75">
{attachmentsCount > 0 && ( {attachmentsCount > 0 && <span>{c.attachments(attachmentsCount)}</span>}
<span>
{c.attachments(attachmentsCount)}
</span>
)}
{isEditing && ( {isEditing && (
<span className="text-[color-mix(in_srgb,var(--dt-composer-ring)_78%,var(--muted-foreground))]"> <span className="text-[color-mix(in_srgb,var(--dt-composer-ring)_78%,var(--muted-foreground))]">
{c.editingInComposer} {c.editingInComposer}
@@ -410,6 +410,10 @@ export function useMessageStream({
phase: 'running' | 'complete', phase: 'running' | 'complete',
sourceEventType?: string sourceEventType?: string
) => { ) => {
// Text deltas flush on a timer but tool events apply now; flush first so
// a tool part can't jump ahead of the text that preceded it.
flushQueuedDeltas(sessionId)
if (!nativeSubagentSessionsRef.current.has(sessionId)) { if (!nativeSubagentSessionsRef.current.has(sessionId)) {
for (const subagentPayload of delegateTaskPayloads(payload, phase, sourceEventType)) { for (const subagentPayload of delegateTaskPayloads(payload, phase, sourceEventType)) {
upsertSubagent( upsertSubagent(
@@ -428,7 +432,7 @@ export function useMessageStream({
{ pending: m => phase !== 'complete' || (m.pending ?? false) } { pending: m => phase !== 'complete' || (m.pending ?? false) }
) )
}, },
[mutateStream] [flushQueuedDeltas, mutateStream]
) )
const completeAssistantMessage = useCallback( const completeAssistantMessage = useCallback(
@@ -160,7 +160,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
return ( return (
<div <div
className="relative mb-3 mt-2 grid gap-2 rounded-[0.5rem] border border-border/70 bg-card/40 px-3 py-2.5 text-sm shadow-[inset_0_1px_0_color-mix(in_srgb,var(--foreground)_3%,transparent)]" className="relative mb-3 mt-2 grid gap-6 rounded-[0.5rem] border border-border/70 bg-card/40 px-3 py-2.5 text-sm shadow-[inset_0_1px_0_color-mix(in_srgb,var(--foreground)_3%,transparent)]"
data-slot="clarify-inline" data-slot="clarify-inline"
> >
<span aria-hidden className="arc-border" /> <span aria-hidden className="arc-border" />
@@ -7,7 +7,7 @@ import {
type SyntaxHighlighterProps type SyntaxHighlighterProps
} from '@assistant-ui/react-streamdown' } from '@assistant-ui/react-streamdown'
import { code } from '@streamdown/code' import { code } from '@streamdown/code'
import { type ComponentProps, memo, type ReactNode, useDeferredValue, useEffect, useMemo, useState } from 'react' import { type ComponentProps, memo, type ReactNode, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
import { PreviewAttachment } from '@/components/chat/preview-attachment' import { PreviewAttachment } from '@/components/chat/preview-attachment'
import { SyntaxHighlighter } from '@/components/chat/shiki-highlighter' import { SyntaxHighlighter } from '@/components/chat/shiki-highlighter'
@@ -224,6 +224,88 @@ function MarkdownImage({ className, src, alt, ...props }: ComponentProps<'img'>)
) )
} }
// Steady character-reveal for streaming text: decouples visible cadence from
// bursty arrival so text flows instead of popping (cf. assistant-ui's useSmooth,
// reimplemented for a tunable rate). Proportional drain — each frame reveals a
// slice of the backlog so the reveal converges within ~REVEAL_DRAIN_MS whatever
// the size; the per-frame cap stops a huge dump rendering as one slab. The loop
// is gated on backlog, not isRunning, so a stream that completes mid-reveal
// keeps draining its tail instead of snapping.
const REVEAL_DRAIN_MS = 500
const REVEAL_MAX_CHARS_PER_FRAME = 30
function useSmoothReveal(text: string, isRunning: boolean): string {
const [displayed, setDisplayed] = useState(isRunning ? '' : text)
const targetRef = useRef(text)
const shownRef = useRef(displayed)
const frameRef = useRef<number | null>(null)
const lastTickRef = useRef(0)
shownRef.current = displayed
targetRef.current = text
useEffect(() => {
if (typeof window === 'undefined') {
return
}
// Non-extending change (regenerate / branch / history swap): restart from
// empty while streaming, else snap to the replacement.
if (!text.startsWith(shownRef.current)) {
shownRef.current = isRunning ? '' : text
setDisplayed(shownRef.current)
}
if (shownRef.current.length >= text.length || frameRef.current !== null) {
return
}
lastTickRef.current = performance.now()
const tick = () => {
const now = performance.now()
const dt = now - lastTickRef.current
lastTickRef.current = now
const remaining = targetRef.current.length - shownRef.current.length
const add = Math.min(remaining, REVEAL_MAX_CHARS_PER_FRAME, Math.max(1, Math.ceil((remaining * dt) / REVEAL_DRAIN_MS)))
shownRef.current = targetRef.current.slice(0, shownRef.current.length + add)
setDisplayed(shownRef.current)
frameRef.current = shownRef.current.length < targetRef.current.length ? requestAnimationFrame(tick) : null
}
frameRef.current = requestAnimationFrame(tick)
}, [text, isRunning])
useEffect(
() => () => {
if (frameRef.current !== null && typeof window !== 'undefined') {
cancelAnimationFrame(frameRef.current)
}
},
[]
)
return displayed
}
// Re-publish the part context with a smooth character-reveal, above
// DeferStreamingText so the reveal feeds the deferred markdown pipeline. Status
// stays running while revealing so the caret persists past the underlying part
// settling.
function SmoothStreamingText({ children }: { children: ReactNode }) {
const { text, status } = useMessagePartText()
const isRunning = status.type === 'running'
const revealed = useSmoothReveal(text, isRunning)
return (
<TextMessagePartProvider isRunning={isRunning || revealed !== text} text={revealed}>
{children}
</TextMessagePartProvider>
)
}
/** /**
* Re-publish the active message-part context with React's `useDeferredValue` * Re-publish the active message-part context with React's `useDeferredValue`
* applied to the streaming text and status. The outer wrapper still re-renders * applied to the streaming text and status. The outer wrapper still re-renders
@@ -280,7 +362,7 @@ const MARKDOWN_CONTAINER_CLASS_NAME = cn(
'prose-a:break-words prose-p:[overflow-wrap:anywhere]', 'prose-a:break-words prose-p:[overflow-wrap:anywhere]',
'prose-li:marker:text-muted-foreground/70', 'prose-li:marker:text-muted-foreground/70',
'prose-code:rounded-[0.25rem] prose-code:px-[0.1875rem] prose-code:py-px prose-code:font-mono prose-code:text-[0.9em] prose-code:font-normal prose-code:before:content-none prose-code:after:content-none', 'prose-code:rounded-[0.25rem] prose-code:px-[0.1875rem] prose-code:py-px prose-code:font-mono prose-code:text-[0.9em] prose-code:font-normal prose-code:before:content-none prose-code:after:content-none',
'[&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*+*]:mt-1' '[&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*+*]:mt-(--paragraph-gap)'
) )
function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTextSurfaceProps) { function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTextSurfaceProps) {
@@ -308,12 +390,14 @@ function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTex
<h4 className={cn('my-1 font-semibold', HEADING_SIZES.h4, className)} {...props} /> <h4 className={cn('my-1 font-semibold', HEADING_SIZES.h4, className)} {...props} />
), ),
p: ({ className, ...props }: ComponentProps<'p'>) => ( p: ({ className, ...props }: ComponentProps<'p'>) => (
<p className={cn('my-1 wrap-anywhere leading-(--dt-line-height)', className)} {...props} /> // Vertical rhythm is owned by styles.css (`--paragraph-gap`), which
// must out-specify Tailwind Typography's `prose` margins — so no
// `my-*` here on purpose.
<p className={cn('wrap-anywhere leading-(--dt-line-height)', className)} {...props} />
), ),
a: MarkdownLink, a: MarkdownLink,
hr: ({ className, ...props }: ComponentProps<'hr'>) => ( // `---` as quiet spacing, not a heavy full-width rule.
<hr className={cn('border-border', className)} {...props} /> hr: (_props: ComponentProps<'hr'>) => <div aria-hidden className="my-3" />,
),
blockquote: ({ className, ...props }: ComponentProps<'blockquote'>) => ( blockquote: ({ className, ...props }: ComponentProps<'blockquote'>) => (
<blockquote <blockquote
className={cn('border-l-2 border-border pl-3 text-muted-foreground italic', className)} className={cn('border-l-2 border-border pl-3 text-muted-foreground italic', className)}
@@ -391,18 +475,22 @@ interface MarkdownTextContentProps extends MarkdownTextSurfaceProps {
export function MarkdownTextContent({ isRunning, text, ...surfaceProps }: MarkdownTextContentProps) { export function MarkdownTextContent({ isRunning, text, ...surfaceProps }: MarkdownTextContentProps) {
return ( return (
<TextMessagePartProvider isRunning={isRunning} text={text}> <TextMessagePartProvider isRunning={isRunning} text={text}>
<DeferStreamingText> <SmoothStreamingText>
<MarkdownTextSurface {...surfaceProps} /> <DeferStreamingText>
</DeferStreamingText> <MarkdownTextSurface {...surfaceProps} />
</DeferStreamingText>
</SmoothStreamingText>
</TextMessagePartProvider> </TextMessagePartProvider>
) )
} }
const MarkdownTextImpl = () => { const MarkdownTextImpl = () => {
return ( return (
<DeferStreamingText> <SmoothStreamingText>
<MarkdownTextSurface /> <DeferStreamingText>
</DeferStreamingText> <MarkdownTextSurface />
</DeferStreamingText>
</SmoothStreamingText>
) )
} }
@@ -236,6 +236,7 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
> >
{hoistedTodos.length > 0 && <HoistedTodoPanel todos={hoistedTodos} />} {hoistedTodos.length > 0 && <HoistedTodoPanel todos={hoistedTodos} />}
<MessagePrimitive.Parts components={MESSAGE_PARTS_COMPONENTS} /> <MessagePrimitive.Parts components={MESSAGE_PARTS_COMPONENTS} />
{messageStatus === 'running' && <StreamStallIndicator activity={`${content.length}:${messageText.length}`} />}
{previewTargets.length > 0 && ( {previewTargets.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2"> <div className="mt-3 flex flex-wrap gap-2">
{previewTargets.map(target => ( {previewTargets.map(target => (
@@ -287,6 +288,39 @@ const ResponseLoadingIndicator: FC = () => {
) )
} }
// Seconds of no visible output (text or part count) before a still-running turn
// is treated as stalled and the thinking indicator returns at the tail.
const STREAM_STALL_S = 2
// Tail "still thinking" indicator: the pre-first-token spinner goes away once
// text flows, but if the stream then goes quiet mid-turn (tool think-time,
// provider stall) nothing signals that work continues. Watch a per-render
// activity signal; when it hasn't changed for STREAM_STALL_S, re-show the
// dither + a timer counting from the last activity.
const StreamStallIndicator: FC<{ activity: string }> = ({ activity }) => {
const [stalled, setStalled] = useState(false)
useEffect(() => {
setStalled(false)
const id = window.setTimeout(() => setStalled(true), STREAM_STALL_S * 1000)
return () => window.clearTimeout(id)
}, [activity])
const elapsed = useElapsedSeconds(stalled)
if (!stalled) {
return null
}
return (
<StatusRow className="mt-1.5" data-slot="aui_stream-stall" label="Hermes is thinking">
<span aria-hidden="true" className="dither inline-block size-3 rounded-[2px] text-midground/80 animate-pulse" />
<ActivityTimerText seconds={elapsed} />
</StatusRow>
)
}
const ImageGenerateTool: FC<ToolCallMessagePartProps> = ({ result }) => { const ImageGenerateTool: FC<ToolCallMessagePartProps> = ({ result }) => {
const generatedImage = useGeneratedImageContext() const generatedImage = useGeneratedImageContext()
const running = result === undefined const running = result === undefined
@@ -434,6 +468,22 @@ const ReasoningAccordionGroup: FC<{ children?: ReactNode; endIndex: number; star
.some(p => p?.type === 'reasoning' && p.status?.type !== 'complete') .some(p => p?.type === 'reasoning' && p.status?.type !== 'complete')
) )
// A reasoning group with no actual text is pure noise — drop the whole
// "Thinking" disclosure rather than leave an empty header eating a row. This
// applies live too: encrypted/spinner-coerced reasoning (Opus reasoning max)
// never carries visible text, and the bottom-of-thread loader already signals
// "thinking", so an empty header is never wanted. Real reasoning surfaces the
// instant its first token lands.
const hasContent = useAuiState(s =>
s.message.parts
.slice(Math.max(0, startIndex), endIndex + 1)
.some(p => p?.type === 'reasoning' && typeof p.text === 'string' && p.text.trim().length > 0)
)
if (!hasContent) {
return null
}
return ( return (
<ThinkingDisclosure messageRunning={messageRunning} pending={pending} timerKey={`reasoning:${messageId}`}> <ThinkingDisclosure messageRunning={messageRunning} pending={pending} timerKey={`reasoning:${messageId}`}>
{children} {children}
@@ -449,7 +499,7 @@ const ReasoningTextPart: FC<{ text: string; status?: { type: string } }> = ({ te
return ( return (
<MarkdownTextContent <MarkdownTextContent
containerClassName={cn( containerClassName={cn(
'text-xs leading-relaxed text-muted-foreground/85', 'text-xs leading-snug text-muted-foreground/85',
isRunning && 'shimmer text-muted-foreground/55' isRunning && 'shimmer text-muted-foreground/55'
)} )}
containerProps={{ 'data-slot': 'aui_reasoning-text' } as ComponentProps<'div'>} containerProps={{ 'data-slot': 'aui_reasoning-text' } as ComponentProps<'div'>}
@@ -1,5 +1,5 @@
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react' import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
import { cleanup, render, screen, waitFor } from '@testing-library/react' import { cleanup, render, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { clearAllPrompts, setApprovalRequest } from '@/store/prompts' import { clearAllPrompts, setApprovalRequest } from '@/store/prompts'
@@ -8,12 +8,11 @@ import { $toolDisclosureStates } from '@/store/tool-view'
import { Thread } from './thread' import { Thread } from './thread'
// Regression coverage for the "approval buried behind a collapsed tool group" // Regression coverage for the "approval must never be buried" bug. Tools now
// bug. When 2+ tools group into a collapsed "Tool actions · N steps" row, the // render as a flat list (no collapsible "N steps" group), so a pending tool's
// pending tool's inline ApprovalBar lives inside the group body — which is // inline ApprovalBar is always in the visual flow — never inside a `hidden`
// `hidden` until expanded. A live approval must surface WITHOUT the user // body. These assert the bar shows only when an approval is live and is never
// expanding anything, so ToolGroupSlot force-opens its body while an approval // trapped under a `hidden` ancestor.
// targeting one of its pending tools is in flight.
const createdAt = new Date('2026-06-03T00:00:00.000Z') const createdAt = new Date('2026-06-03T00:00:00.000Z')
@@ -71,8 +70,7 @@ stubOffsetDimension('offsetWidth', 'clientWidth', 800)
stubOffsetDimension('offsetHeight', 'clientHeight', 600) stubOffsetDimension('offsetHeight', 'clientHeight', 600)
// A running assistant message with two tools: a completed read_file plus a // A running assistant message with two tools: a completed read_file plus a
// pending terminal (no result). Two visible tools → ToolGroupSlot groups them // pending terminal (no result), rendered as a flat two-row list.
// behind a collapsed "Tool actions · 2 steps" header.
function groupedPendingMessage(): ThreadMessage { function groupedPendingMessage(): ThreadMessage {
return { return {
id: 'assistant-group-1', id: 'assistant-group-1',
@@ -132,32 +130,28 @@ afterEach(() => {
$activeSessionId.set(null) $activeSessionId.set(null)
}) })
describe('ToolGroupSlot approval surfacing', () => { describe('flat tool list approval surfacing', () => {
it('hides the grouped pending tool body when there is no approval', async () => { it('renders no inline approval bar when there is no live approval', async () => {
const { container } = render(<GroupHarness message={groupedPendingMessage()} />) const { container } = render(<GroupHarness message={groupedPendingMessage()} />)
// Group header renders collapsed; the inline approval strip lives in the // The pending terminal row mounts immediately, but its inline ApprovalBar
// hidden body, so with no live approval it must not render at all (the // returns null while $approvalRequest is empty.
// ApprovalBar returns null when $approvalRequest is empty).
await waitFor(() => { await waitFor(() => {
expect(screen.getByText(/Tool actions/)).toBeTruthy() expect(container.querySelectorAll('[data-slot="tool-block"]').length).toBeGreaterThan(0)
}) })
expect(container.querySelector('[data-slot="tool-approval-inline"]')).toBeNull() expect(container.querySelector('[data-slot="tool-approval-inline"]')).toBeNull()
}) })
it('force-opens the group body so the approval surfaces without expanding', async () => { it('surfaces the approval inline and never under a hidden ancestor', async () => {
setApprovalRequest({ command: 'rm -rf /tmp/x', description: 'dangerous command', sessionId: 'sess-1' }) setApprovalRequest({ command: 'rm -rf /tmp/x', description: 'dangerous command', sessionId: 'sess-1' })
const { container } = render(<GroupHarness message={groupedPendingMessage()} />) const { container } = render(<GroupHarness message={groupedPendingMessage()} />)
// Even though the group defaults collapsed, the live approval forces the
// body open so the inline controls are visible (and reachable, not in a
// hidden subtree) immediately.
await waitFor(() => { await waitFor(() => {
const bar = container.querySelector('[data-slot="tool-approval-inline"]') const bar = container.querySelector('[data-slot="tool-approval-inline"]')
expect(bar).not.toBeNull() expect(bar).not.toBeNull()
// The forced-open group body must not be hidden — assert no ancestor // Flat rows live directly in the flow — nothing should ever wrap the bar
// carries the `hidden` attribute that would keep the bar off-screen. // in a `hidden` subtree.
expect(bar?.closest('[hidden]')).toBeNull() expect(bar?.closest('[hidden]')).toBeNull()
}) })
}) })
@@ -88,10 +88,12 @@ const TOOL_META: Record<string, ToolMeta> = {
tone: 'browser' tone: 'browser'
}, },
browser_type: { done: 'Typed on page', pending: 'Typing on page', icon: 'globe', tone: 'browser' }, browser_type: { done: 'Typed on page', pending: 'Typing on page', icon: 'globe', tone: 'browser' },
clarify: { done: 'Asked a question', pending: 'Asking a question', icon: 'question', tone: 'agent' },
edit_file: { done: 'Edited file', pending: 'Editing file', icon: 'edit', tone: 'file' }, edit_file: { done: 'Edited file', pending: 'Editing file', icon: 'edit', tone: 'file' },
execute_code: { done: 'Ran code', pending: 'Running code', icon: 'terminal', tone: 'terminal' }, execute_code: { done: 'Ran code', pending: 'Running code', icon: 'terminal', tone: 'terminal' },
image_generate: { done: 'Generated image', pending: 'Generating image', icon: 'file-media', tone: 'image' }, image_generate: { done: 'Generated image', pending: 'Generating image', icon: 'file-media', tone: 'image' },
list_files: { done: 'Listed files', pending: 'Listing files', icon: 'files', tone: 'file' }, list_files: { done: 'Listed files', pending: 'Listing files', icon: 'files', tone: 'file' },
patch: { done: 'Patched file', pending: 'Patching file', icon: 'diff', tone: 'file' },
read_file: { done: 'Read file', pending: 'Reading file', icon: 'file', tone: 'file' }, read_file: { done: 'Read file', pending: 'Reading file', icon: 'file', tone: 'file' },
search_files: { done: 'Searched files', pending: 'Searching files', icon: 'search', tone: 'file' }, search_files: { done: 'Searched files', pending: 'Searching files', icon: 'search', tone: 'file' },
session_search_recall: { session_search_recall: {
@@ -102,6 +104,7 @@ const TOOL_META: Record<string, ToolMeta> = {
}, },
terminal: { done: 'Ran command', pending: 'Running command', icon: 'terminal', tone: 'terminal' }, terminal: { done: 'Ran command', pending: 'Running command', icon: 'terminal', tone: 'terminal' },
todo: { done: 'Updated todos', pending: 'Updating todos', icon: 'tools', tone: 'agent' }, todo: { done: 'Updated todos', pending: 'Updating todos', icon: 'tools', tone: 'agent' },
vision_analyze: { done: 'Analyzed image', pending: 'Analyzing image', icon: 'eye', tone: 'image' },
web_extract: { done: 'Read webpage', pending: 'Reading webpage', icon: 'globe', tone: 'web' }, web_extract: { done: 'Read webpage', pending: 'Reading webpage', icon: 'globe', tone: 'web' },
web_search: { done: 'Searched web', pending: 'Searching web', icon: 'search', tone: 'web' }, web_search: { done: 'Searched web', pending: 'Searching web', icon: 'search', tone: 'web' },
write_file: { done: 'Edited file', pending: 'Editing file', icon: 'edit', tone: 'file' } write_file: { done: 'Edited file', pending: 'Editing file', icon: 'edit', tone: 'file' }
@@ -1268,124 +1271,3 @@ export function buildToolView(part: ToolPart, inlineDiff: string): ToolView {
tone: meta.tone tone: meta.tone
} }
} }
function isToolPart(part: unknown): part is ToolPart {
if (!part || typeof part !== 'object') {
return false
}
const row = part as Record<string, unknown>
return row.type === 'tool-call' && typeof row.toolName === 'string'
}
export function groupToolParts(content: unknown): ToolPart[][] {
if (!Array.isArray(content)) {
return []
}
const groups: ToolPart[][] = []
let current: ToolPart[] = []
for (const part of content) {
// todo parts render in their own hoisted panel; skip from grouped tools.
if (isToolPart(part) && part.toolName !== 'todo') {
current.push(part)
continue
}
if (current.length) {
groups.push(current)
current = []
}
}
if (current.length) {
groups.push(current)
}
return groups
}
export function groupStatus(parts: ToolPart[]): ToolStatus {
if (parts.some(p => p.result === undefined)) {
return 'running'
}
const statuses = parts.map(part => toolStatus(part, parseMaybeObject(part.result)))
const hasError = statuses.includes('error')
if (!hasError) {
return 'success'
}
return statuses.at(-1) === 'success' ? 'warning' : 'error'
}
export function groupTitle(parts: ToolPart[]): string {
const prefix = PREFIX_META.find(p => parts.every(part => part.toolName.startsWith(p.prefix)))
const verb = prefix?.verb || 'Tool'
return `${verb} actions · ${parts.length} steps`
}
export function groupPreviewTargets(parts: ToolPart[]): string[] {
const seen = new Set<string>()
const targets: string[] = []
for (const part of parts) {
const view = buildToolView(part, inlineDiffFromResult(part.result))
const target = view.previewTarget
if (target && isPreviewableTarget(target) && !seen.has(target)) {
seen.add(target)
targets.push(target)
}
}
return targets
}
export function groupFailedStepCount(parts: ToolPart[]): number {
return parts.filter(part => toolStatus(part, parseMaybeObject(part.result)) === 'error').length
}
export function groupTotalDurationLabel(parts: ToolPart[]): string {
const seconds = parts.reduce((sum, part) => {
const value = numberValue(parseMaybeObject(part.result).duration_s)
return sum + (value && value > 0 ? value : 0)
}, 0)
if (!seconds) {
return ''
}
return formatDurationSeconds(seconds)
}
export function groupTailSubtitle(parts: ToolPart[]): string {
const tail = parts.at(-1)
return tail ? buildToolView(tail, '').subtitle : ''
}
export function groupCopyText(parts: ToolPart[]): string {
return parts
.map(part => {
const view = buildToolView(part, '')
const lines = [view.title]
if (view.subtitle && view.subtitle !== view.title) {
lines.push(view.subtitle)
}
if (view.detail && view.detail !== view.subtitle) {
lines.push(view.detail)
}
return lines.join('\n')
})
.join('\n\n')
}
@@ -3,7 +3,6 @@
import { type ToolCallMessagePartProps, useAuiState } from '@assistant-ui/react' import { type ToolCallMessagePartProps, useAuiState } from '@assistant-ui/react'
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
import { createContext, type FC, type PropsWithChildren, type ReactNode, useContext, useMemo } from 'react' import { createContext, type FC, type PropsWithChildren, type ReactNode, useContext, useMemo } from 'react'
import { useShallow } from 'zustand/shallow'
import { AnsiText } from '@/components/assistant-ui/ansi-text' import { AnsiText } from '@/components/assistant-ui/ansi-text'
import { useElapsedSeconds } from '@/components/chat/activity-timer' import { useElapsedSeconds } from '@/components/chat/activity-timer'
@@ -21,20 +20,13 @@ import { PrettyLink, LinkifiedText as SharedLinkifiedText, urlSlugTitleLabel } f
import { AlertCircle, CheckCircle2 } from '@/lib/icons' import { AlertCircle, CheckCircle2 } from '@/lib/icons'
import { useEnterAnimation } from '@/lib/use-enter-animation' import { useEnterAnimation } from '@/lib/use-enter-animation'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { $approvalRequest } from '@/store/prompts'
import { $toolInlineDiffs } from '@/store/tool-diffs' import { $toolInlineDiffs } from '@/store/tool-diffs'
import { $toolDisclosureOpen, $toolViewMode, setToolDisclosureOpen } from '@/store/tool-view' import { $toolDisclosureOpen, $toolViewMode, setToolDisclosureOpen } from '@/store/tool-view'
import { APPROVAL_TOOLS, PendingToolApproval } from './tool-approval' import { PendingToolApproval } from './tool-approval'
import { import {
groupCopyText as buildGroupCopyText,
buildToolView, buildToolView,
cleanVisibleText, cleanVisibleText,
groupFailedStepCount,
groupPreviewTargets,
groupStatus,
groupTitle,
groupTotalDurationLabel,
inlineDiffFromResult, inlineDiffFromResult,
isPreviewableTarget, isPreviewableTarget,
looksRedundant, looksRedundant,
@@ -47,14 +39,10 @@ import {
type ToolStatus type ToolStatus
} from './tool-fallback-model' } from './tool-fallback-model'
// Tool names that ChainToolFallback intercepts and renders as something // `true` when a ToolEntry is rendered inside an embedding wrapper that owns
// other than a ToolEntry — they don't count toward "is this a group of // the per-row chrome (timer / preview). The flat ToolGroupSlot sets this
// tool calls?" because they have no visible tool block. // false, so every row currently owns its own chrome; kept as a seam for any
const SPECIAL_TOOL_NAMES = new Set(['todo', 'image_generate', 'clarify']) // future embedding surface.
// `true` when the current ToolEntry is being rendered inside a group
// wrapper. Lets ToolEntry suppress per-row chrome (timer / preview) that
// the group already shows.
const ToolEmbedContext = createContext(false) const ToolEmbedContext = createContext(false)
// Shared header chrome for tool rows. Both the single-tool DisclosureRow // Shared header chrome for tool rows. Both the single-tool DisclosureRow
@@ -263,6 +251,7 @@ function ToolEntry({ part }: ToolEntryProps) {
const hasExpandableContent = Boolean( const hasExpandableContent = Boolean(
(view.previewTarget && isPreviewableTarget(view.previewTarget)) || (view.previewTarget && isPreviewableTarget(view.previewTarget)) ||
view.imageUrl || view.imageUrl ||
view.inlineDiff ||
showDetail || showDetail ||
hasSearchHits || hasSearchHits ||
toolViewMode === 'technical' toolViewMode === 'technical'
@@ -403,153 +392,42 @@ function ToolEntry({ part }: ToolEntryProps) {
)} )}
</div> </div>
)} )}
{view.inlineDiff && <DiffLines text={view.inlineDiff} />} {open && view.inlineDiff && <DiffLines text={view.inlineDiff} />}
</div> </div>
) )
} }
/** /**
* Always-present wrapper around the consecutive tool-call range that * Flat, Cursor-style tool list. assistant-ui hands us a *range* of
* `MessagePrimitive.Parts` already grouped for us. Renders a header + * consecutive tool-call parts, but how that range is sliced is unstable: a
* collapsible body when there are 2+ visible tools; otherwise it's a * live stream interleaves narration/reasoning between calls (many tiny
* transparent passthrough that just owns the entry animation for the * ranges), while the settled message reconstructs every tool_call back-to-back
* single ToolEntry inside. * (one big range). Rendering a "Tool actions · N steps" group off that range
* therefore reshuffled the whole turn the instant it settled.
* *
* Crucially, the wrapper element is the SAME `<div>` regardless of * So we never group: each tool is a standalone row, and the wrapper just lays
* group size — only the optional header element appears/disappears. * its children out on the tight `--tool-row-gap` rhythm. One range or ten,
* That preserves React identity for the inner `MessagePartByIndex` * fragmented or consecutive, the result is pixel-identical — a tight, stable
* children when the 1→2 transition happens, so existing tool blocks * stack. The wrapper stays a single `<div>` of stable identity so children
* never remount when a new tool joins them mid-stream. * never remount as the range grows mid-stream. `ToolEmbedContext` is false so
* * every row owns its own chrome (timer / preview / copy / inline approval).
* The previous design (per-tool ToolFallback computing its own group
* lookup and conditionally returning either `<ToolEntry>` or
* `<ToolGroup>`) flipped the React element type at the 1→2 transition
* and tore down the existing tool entirely, which is what showed up as
* "the previous tool's animation resets every time a new tool arrives."
*/ */
export const ToolGroupSlot: FC<PropsWithChildren<{ endIndex: number; startIndex: number }>> = ({ export const ToolGroupSlot: FC<PropsWithChildren<{ endIndex: number; startIndex: number }>> = ({
children, children,
endIndex,
startIndex startIndex
}) => { }) => {
const messageId = useAuiState(s => s.message.id) const messageId = useAuiState(s => s.message.id)
const messageRunning = useAuiState(selectMessageRunning) const messageRunning = useAuiState(selectMessageRunning)
const enterRef = useEnterAnimation(messageRunning, `tool-group:${messageId}:${startIndex}`)
// Pull the visible tool parts in this range. `useShallow` makes this
// re-render only when the actual part references change (assistant-ui
// gives stable refs for unchanged parts), not on every text/reasoning
// delta elsewhere in the message.
const visibleParts = useAuiState(
useShallow((s: { message: { parts: readonly unknown[] } }) =>
s.message.parts.slice(startIndex, endIndex + 1).filter((p): p is ToolPart => {
if (!p || typeof p !== 'object') {
return false
}
const row = p as { toolName?: unknown; type?: unknown }
return row.type === 'tool-call' && typeof row.toolName === 'string' && !SPECIAL_TOOL_NAMES.has(row.toolName)
})
)
)
const isGroup = visibleParts.length > 1
const isRunning = messageRunning && visibleParts.some(p => p.result === undefined)
// Stable across the group's lifetime (start index doesn't shift when
// tools append to the end), so user-driven open/close persists across
// streaming.
const disclosureId = `tool-group:${messageId}:${startIndex}`
const userOpen = useDisclosureOpen(disclosureId)
// A live approval request must NEVER be buried inside a collapsed group —
// the user has to be able to act on it without first expanding "Tool
// actions · N steps". When an approval is in flight and this group hosts
// the pending approval-eligible tool that raised it (terminal /
// execute_code with no result yet — see tool-approval.tsx for why the
// single pending row IS the one that raised it), force the body open so
// the inline ApprovalBar surfaces. The user can still collapse the group
// again once the approval resolves.
const approvalRequest = useStore($approvalRequest)
const hostsLiveApproval =
approvalRequest !== null &&
messageRunning &&
visibleParts.some(p => p.result === undefined && APPROVAL_TOOLS.has(p.toolName))
const open = userOpen || hostsLiveApproval
const enterRef = useEnterAnimation(messageRunning, disclosureId)
const status = groupStatus(visibleParts)
const displayStatus = !isRunning && status === 'running' ? 'success' : status
const failedStepCount = useMemo(() => groupFailedStepCount(visibleParts), [visibleParts])
const totalDurationLabel = useMemo(() => groupTotalDurationLabel(visibleParts), [visibleParts])
const statusSummary =
displayStatus === 'running' || failedStepCount === 0
? ''
: displayStatus === 'warning'
? failedStepCount === 1
? 'Recovered after 1 failed step'
: `Recovered after ${failedStepCount} failed steps`
: failedStepCount === 1
? '1 step failed'
: `${failedStepCount} steps failed`
const groupCopyText = useMemo(() => buildGroupCopyText(visibleParts), [visibleParts])
const previewTargets = useMemo(() => groupPreviewTargets(visibleParts), [visibleParts])
return ( return (
<ToolEmbedContext.Provider value={isGroup}> <ToolEmbedContext.Provider value={false}>
<div className="min-w-0 max-w-full overflow-hidden" data-slot="tool-block" ref={enterRef}> <div
{isGroup && ( className="grid min-w-0 max-w-full gap-(--tool-row-gap) overflow-hidden"
<DisclosureRow data-slot="tool-block"
key="header" ref={enterRef}
onToggle={() => setToolDisclosureOpen(disclosureId, !open)} >
open={open} {children}
trailing={
!isRunning && groupCopyText ? (
<CopyButton appearance="tool-row" label="Copy activity" stopPropagation text={groupCopyText} />
) : undefined
}
>
<span className="flex min-w-0 items-center gap-1.5">
<ToolGlyph status={displayStatus === 'success' ? undefined : displayStatus} />
<FadeText
className={cn(
TOOL_HEADER_TITLE_CLASS,
displayStatus === 'error' && 'text-destructive',
displayStatus === 'warning' && 'text-amber-700 dark:text-amber-300'
)}
>
{groupTitle(visibleParts)}
</FadeText>
{totalDurationLabel && <span className={TOOL_HEADER_DURATION_CLASS}>{totalDurationLabel}</span>}
</span>
{statusSummary && (
<FadeText
className={cn(
TOOL_HEADER_SUBTITLE_CLASS,
displayStatus === 'warning' ? 'text-amber-700/80 dark:text-amber-300/85' : 'text-destructive/85'
)}
>
{statusSummary}
</FadeText>
)}
</DisclosureRow>
)}
{isGroup && previewTargets.length > 0 && (
<div className="mt-2 grid w-full min-w-0 max-w-full gap-2 overflow-hidden pr-2 pl-3">
{previewTargets.map(target => (
<PreviewAttachment key={target} source="tool-result" target={target} />
))}
</div>
)}
{/* Body is always rendered so children stay mounted across collapse/
expand and across the 1→2 group transition. `hidden` removes it
from a11y/visual flow without unmounting React subtree. */}
<div className={cn(isGroup && 'mt-0.5 w-full overflow-hidden pr-2 pl-3')} hidden={isGroup && !open} key="body">
{children}
</div>
</div> </div>
</ToolEmbedContext.Provider> </ToolEmbedContext.Provider>
) )
@@ -38,7 +38,7 @@ export function DiffLines({ className, text, ...props }: DiffLinesProps) {
return ( return (
<pre <pre
className={cn( className={cn(
'mt-2 max-h-96 max-w-full min-w-0 overflow-auto rounded-md border border-border/60 bg-muted/35 px-2.5 py-1.5 font-mono text-[0.7rem] leading-relaxed text-muted-foreground', 'mt-1 mb-1.5 max-h-96 max-w-full min-w-0 overflow-auto rounded-md border border-border/60 bg-muted/35 px-2.5 py-1.5 font-mono text-[0.7rem] leading-relaxed text-muted-foreground',
className className
)} )}
data-slot="diff-lines" data-slot="diff-lines"
+2 -3
View File
@@ -521,8 +521,7 @@ export const en: Translations = {
editTitle: 'Edit cron job', editTitle: 'Edit cron job',
createTitle: 'New cron job', createTitle: 'New cron job',
editDesc: 'Update the schedule, prompt, or delivery target. Changes apply on next run.', editDesc: 'Update the schedule, prompt, or delivery target. Changes apply on next run.',
createDesc: createDesc: 'Schedule a prompt to run automatically. Use cron syntax or a natural phrase like "every 15 minutes".',
'Schedule a prompt to run automatically. Use cron syntax or a natural phrase like "every 15 minutes".',
nameLabel: 'Name', nameLabel: 'Name',
namePlaceholder: 'Morning briefing', namePlaceholder: 'Morning briefing',
promptLabel: 'Prompt', promptLabel: 'Prompt',
@@ -592,7 +591,7 @@ export const en: Translations = {
groupTitleGrouped: 'Ungroup sessions', groupTitleGrouped: 'Ungroup sessions',
groupTitleUngrouped: 'Group by workspace', groupTitleUngrouped: 'Group by workspace',
allPinned: 'Everything here is pinned. Unpin a chat to show it in recents.', allPinned: 'Everything here is pinned. Unpin a chat to show it in recents.',
shiftClickHint: 'Shift-click a chat to pin · drag to reorder', shiftClickHint: 'Shift-click a chat to pin',
noWorkspace: 'No workspace', noWorkspace: 'No workspace',
newSessionIn: label => `New session in ${label}`, newSessionIn: label => `New session in ${label}`,
reorderWorkspace: label => `Reorder workspace ${label}`, reorderWorkspace: label => `Reorder workspace ${label}`,
+56 -37
View File
@@ -278,11 +278,21 @@
--composer-shell-pad-block-end: 0.625rem; --composer-shell-pad-block-end: 0.625rem;
--message-text-indent: 0.75rem; --message-text-indent: 0.75rem;
--conversation-text-font-size: 0.8125rem; --conversation-text-font-size: 0.8125rem;
--conversation-tool-font-size: var(--conversation-text-font-size); --conversation-tool-font-size: 0.6875rem;
--conversation-caption-font-size: 0.75rem; --conversation-caption-font-size: 0.75rem;
--conversation-line-height: 1.125rem; --conversation-line-height: 1.125rem;
--conversation-caption-line-height: 1rem; --conversation-caption-line-height: 1rem;
--conversation-turn-gap: 0.375rem; --conversation-turn-gap: 0.375rem;
/* Gap between top-level turn blocks (prose ↔ tools ↔ thinking) — enough air
that scaffolding reads as separate from the reply, not crammed into it. */
--turn-block-gap: 0.75rem;
/* Tight gap between tool rows inside a single action group, so a back-to-back
run still reads as one cohesive sequence. */
--tool-row-gap: 0.375rem;
/* Paragraph spacing — vertical gap between prose paragraphs, both inside a
markdown block and between consecutive prose parts. Single knob; tweak
freely. */
--paragraph-gap: 0.45rem;
--sticky-human-top: 0.23rem; --sticky-human-top: 0.23rem;
--file-tree-row-height: 1.375rem; --file-tree-row-height: 1.375rem;
@@ -798,14 +808,27 @@ canvas {
font-size: inherit; font-size: inherit;
} }
/* Streamed prose hangs slightly indented from the tool/todo column so the /* Tailwind Typography sets `.prose :where(p) { margin: 1.25em }` (~16px). That
reading column reads as a "reply" within the conversation gutter. Tools, selector ties our `my-*` utility on specificity and wins on source order, so
todos, and thinking blocks keep the existing --message-text-indent so they paragraph spacing must be reclaimed here at higher specificity. One tight
remain flush with the user message text above them. */ top-margin (bottom zeroed to avoid doubling), first child reset to flush. */
[data-slot='aui_assistant-message-content'] > .aui-md { [data-slot='aui_assistant-message-content'] .aui-md :where(p) {
padding-inline-start: var(--md-text-indent, 0.5rem); margin-block: var(--paragraph-gap) 0;
} }
/* First rendered element of a prose block is flush — the block-level gap above
(tool / paragraph) already provides the separation. Reach one level deep too:
Streamdown wraps blocks in a `div.space-y-*`, so the real first line is the
first child's first child. */
[data-slot='aui_assistant-message-content'] .aui-md > :first-child,
[data-slot='aui_assistant-message-content'] .aui-md > :first-child > :first-child {
margin-top: 0;
}
/* Prose, tools, todos, and thinking all share one left edge (the message
content's --message-text-indent). No extra prose indent — a single gutter
reads cleaner than a ragged tool-vs-reply column. */
[data-slot='aui_user-message-root'] { [data-slot='aui_user-message-root'] {
top: var(--sticky-human-top); top: var(--sticky-human-top);
} }
@@ -816,12 +839,13 @@ canvas {
} }
/* Sticky human bubbles clamp to ~2 lines with a soft bottom fade so a long /* Sticky human bubbles clamp to ~2 lines with a soft bottom fade so a long
prompt doesn't dominate the viewport while you read the response stuck prompt doesn't dominate the viewport. The clamp lifts on focus only (clicking
beneath it. The clamp lifts on hover / focus (clicking the bubble opens the opens the edit composer, which shows the full text) — not on hover, so the
edit composer, which already shows the full text). --human-msg-full is the bubble doesn't jump as the pointer passes over it. --human-msg-full is the
measured content height (set in UserMessage) so expand/collapse animates to measured content height (set in UserMessage) so it animates to the real
the real height instead of overshooting the cap. */ height instead of overshooting the cap. */
.sticky-human-clamp { .sticky-human-clamp {
cursor: pointer;
max-height: calc(2 * var(--dt-line-height) * var(--conversation-text-font-size) + 0.15rem); max-height: calc(2 * var(--dt-line-height) * var(--conversation-text-font-size) + 0.15rem);
overflow: hidden; overflow: hidden;
transition: max-height 0.08s cubic-bezier(0.4, 0, 0.2, 1); transition: max-height 0.08s cubic-bezier(0.4, 0, 0.2, 1);
@@ -832,7 +856,6 @@ canvas {
mask-image: linear-gradient(to bottom, #000 55%, transparent); mask-image: linear-gradient(to bottom, #000 55%, transparent);
} }
.composer-human-message:hover .sticky-human-clamp,
.composer-human-message:focus-within .sticky-human-clamp { .composer-human-message:focus-within .sticky-human-clamp {
max-height: min(var(--human-msg-full, 24rem), 24rem); max-height: min(var(--human-msg-full, 24rem), 24rem);
overflow-y: auto; overflow-y: auto;
@@ -992,7 +1015,7 @@ canvas {
[data-slot='aui_assistant-message-content'] .aui-md [data-streamdown='code-block'] { [data-slot='aui_assistant-message-content'] .aui-md [data-streamdown='code-block'] {
contain: none; contain: none;
overflow: visible; overflow: visible;
margin-block: 0.375rem !important; margin-block: var(--paragraph-gap) 0 !important;
padding: 0 !important; padding: 0 !important;
gap: 0 !important; gap: 0 !important;
border: 0 !important; border: 0 !important;
@@ -1006,6 +1029,11 @@ canvas {
} }
[data-slot='aui_assistant-message-content'] .aui-md [data-slot='code-card'] { [data-slot='aui_assistant-message-content'] .aui-md [data-slot='code-card'] {
/* Streamdown nests blocks, so the container's child-combinator rhythm can't
reach the card. Carry the paragraph gap on the card itself (top-owned);
collapses cleanly with the wrapper's margin when one is present, and the
first-child reset still flushes a leading code block. */
margin-block: var(--paragraph-gap) 0;
position: relative; position: relative;
transition: transition:
border-color 180ms ease-out, border-color 180ms ease-out,
@@ -1075,34 +1103,25 @@ canvas {
opacity: 1; opacity: 1;
} }
/* Conversation block rhythm. Consecutive tool calls stay tight so a step /* Conversation block rhythm. assistant-ui renders each range as a direct child
sequence reads as one action group; the gap between any scaffolding of the message content with no per-part wrapper, so adjacency rules cover
block and adjacent prose bumps up so the model's reply visually every pairing — first block needs no reset, nested tool rows are untouched.
separates from its scaffolding. */ Two tiers: scaffolding (tool / thinking) gets a roomy block gap so it reads
[data-slot='tool-block'] + [data-slot='tool-block'] { as separate from the reply; consecutive prose collapses to a tight paragraph
margin-top: 0.375rem; rhythm so split-out text parts don't look like a big gap. */
} /* Scaffolding adjacent to anything → roomy block gap. */
[data-slot='tool-block']:has(> :nth-child(2)) + [data-slot='tool-block'] {
margin-top: 0.625rem;
}
[data-slot='aui_assistant-message-content'] [data-slot='aui_assistant-message-content']
:is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure']) > :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure'])
+ .aui-md, + :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure'], .aui-md),
[data-slot='aui_assistant-message-content'] [data-slot='aui_assistant-message-content']
.aui-md > .aui-md
+ :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure']) { + :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure']) {
margin-top: 1rem; margin-top: var(--turn-block-gap);
} }
[data-slot='aui_assistant-message-content'] [data-slot='aui_thinking-disclosure'] + [data-slot='tool-block'], /* Prose ↔ prose → tight paragraph rhythm, matching in-block paragraph spacing. */
[data-slot='aui_assistant-message-content'] [data-slot='tool-block'] + [data-slot='aui_thinking-disclosure'] { [data-slot='aui_assistant-message-content'] > .aui-md + .aui-md {
margin-top: 0.75rem; margin-top: var(--paragraph-gap);
}
[data-slot='aui_assistant-message-content'] > [data-slot='tool-block']:first-child {
margin-top: 0;
} }
/* Message action bars — flat icon hits with default dim; only the hovered/focused control is full-strength. */ /* Message action bars — flat icon hits with default dim; only the hovered/focused control is full-strength. */
+22
View File
@@ -26,6 +26,7 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import logging import logging
import socket
from typing import Any from typing import Any
from tui_gateway import server from tui_gateway import server
@@ -137,6 +138,24 @@ def _ws_peer_label(ws: Any) -> str:
return f"{host}:{port}" if port is not None else host return f"{host}:{port}" if port is not None else host
def _disable_nagle(ws: Any) -> None:
"""Disable Nagle so streamed JSON-RPC frames go out individually.
Without it the kernel coalesces the small per-token frames, so a burst after
the model's think-pause lands on the client in one tick and no client-side
smoothing can recover the cadence. GUI/WS only; chat platforms don't hit
this path. Best-effort — skip silently if the socket isn't reachable.
"""
try:
scope = getattr(ws, "scope", None) or {}
transport = (scope.get("extensions") or {}).get("transport") or getattr(ws, "transport", None)
sock = transport.get_extra_info("socket") if transport is not None else None
if sock is not None:
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
except Exception as exc: # pragma: no cover - best-effort tuning
_log.debug("ws TCP_NODELAY skip: %s", exc)
async def handle_ws(ws: Any) -> None: async def handle_ws(ws: Any) -> None:
"""Run one WebSocket session. Wire-compatible with ``tui_gateway.entry``.""" """Run one WebSocket session. Wire-compatible with ``tui_gateway.entry``."""
peer = _ws_peer_label(ws) peer = _ws_peer_label(ws)
@@ -150,6 +169,9 @@ async def handle_ws(ws: Any) -> None:
try: try:
await ws.accept() await ws.accept()
disconnect_reason = "connected" disconnect_reason = "connected"
# Push small streamed frames out immediately instead of letting Nagle
# batch them — keeps the live token cadence intact for GUI clients.
_disable_nagle(ws)
_log.info("ws accepted peer=%s", peer) _log.info("ws accepted peer=%s", peer)
transport = WSTransport(ws, asyncio.get_running_loop(), peer=peer) transport = WSTransport(ws, asyncio.get_running_loop(), peer=peer)