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:
@@ -88,10 +88,12 @@ const TOOL_META: Record<string, ToolMeta> = {
|
||||
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' },
|
||||
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' },
|
||||
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' },
|
||||
search_files: { done: 'Searched files', pending: 'Searching files', icon: 'search', tone: 'file' },
|
||||
session_search_recall: {
|
||||
@@ -102,6 +104,7 @@ const TOOL_META: Record<string, ToolMeta> = {
|
||||
},
|
||||
terminal: { done: 'Ran command', pending: 'Running command', icon: 'terminal', tone: 'terminal' },
|
||||
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_search: { done: 'Searched web', pending: 'Searching web', icon: 'search', tone: 'web' },
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user