v0.20.0: thinking blocks, chat windows overhaul, scroll fix
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled

Backend:
- Add isThinking flag to agentEvent for text before tool calls
- Separate thinking from response text in runChatTurn and continue.go
- Persist thinking in a dedicated field in message content

Frontend:
- Add thinking field to MessageContent, ChatMessage, ChatTextEvent types
- Create ThinkingBlock.svelte — collapsible block with brain icon
- SSE handler moves text_delta content to thinking on isThinking flag
- Render thinking block between tools and response in ChatThread
- Fix chat window scroll reset on focus change (stable windowKeys order)
- Remove redundant #key id wrapper in WindowLayer
- Enlarge sidebar rail (24→32 default, 40→60 max)
- Remove glyph from sidebar, square graph at top
- Replace AgentTrace/ToolCallCard/UnifiedTimeline with TurnTrace/ToolLine
This commit is contained in:
2026-08-04 22:42:53 +02:00
parent 20adb89650
commit 1aaedf498a
25 changed files with 1852 additions and 1211 deletions

View File

@@ -433,3 +433,148 @@ export function toolActivityLabel(t: ToolCallResult): string {
return t.name.replace(/_/g, ' ').replace(/^./, (c) => c.toUpperCase())
}
}
// ── toolResultSummary ────────────────────────────────────────────────────
// A one-line, humanized summary of a tool's RESULT (the Claude-Code-style
// "exit 0 · <line>" / "host:hubris (healthy)" affordance) so each tool line
// in the inline trace reads as an outcome instead of a raw JSON blob. Empty
// for a still-running call (no result yet) or an errored one (the error is
// surfaced separately). Best-effort by tool name; the fallback is the first
// non-empty line of the stringified result, truncated — never blank (the
// expandable raw detail is always one click away).
function resultAsString(r: unknown): string {
if (r == null) return ''
if (typeof r === 'string') return r
try {
return JSON.stringify(r)
} catch {
return String(r)
}
}
function firstLine(s: string, max = 80): string {
const line = s
.split(/\r?\n/)
.map((l) => l.trim())
.find((l) => l.length > 0) ?? ''
return line.length > max ? `${line.slice(0, max - 1)}` : line
}
function resultArray(r: unknown): unknown[] | null {
if (Array.isArray(r)) return r
if (r && typeof r === 'object') {
const o = r as Record<string, unknown>
for (const k of [
'entities',
'results',
'relations',
'steps',
'items',
'containers',
'docs',
'questions',
'signals',
'events',
'patterns',
'skills'
]) {
if (Array.isArray(o[k])) return o[k] as unknown[]
}
}
return null
}
function num(v: unknown): number | null {
return typeof v === 'number' && Number.isFinite(v) ? v : null
}
function plural(n: number, singular: string, pluralForm = `${singular}s`): string {
return `${n} ${n === 1 ? singular : pluralForm}`
}
export function toolResultSummary(t: ToolCallResult): string {
if (t.type === 'tool_use') return '' // still running
if (t.error) return '' // error surfaced separately
const args = t.args ?? {}
const str = (v: unknown): string => (typeof v === 'string' ? v : '')
const obj = (r: unknown): Record<string, unknown> | null =>
r && typeof r === 'object' && !Array.isArray(r) ? (r as Record<string, unknown>) : null
switch (t.name) {
case 'run': {
const s = resultAsString(t.result)
const m = s.match(/exit (?:status )?(\d+)/i)
const tag = m ? `exit ${m[1]}` : /error/i.test(s) ? 'error' : 'ok'
const rest = firstLine(s.replace(/[\s\S]*exit (?:status )?\d+/i, ''), 60)
return rest ? `${tag} · ${rest}` : tag
}
case 'get_entity': {
const o = obj(t.result)
const slug = str(o?.slug) || str(args.slug_or_id)
const health = str(o?.health) || str(o?.state)
return [slug, health && `(${health})`].filter(Boolean).join(' ') || 'found'
}
case 'get_relations': {
const a = resultArray(t.result)
return a ? plural(a.length, 'relation') : 'done'
}
case 'list_entities':
case 'list_lxcs': {
const a = resultArray(t.result)
if (!a) return 'done'
return t.name === 'list_lxcs'
? plural(a.length, 'container')
: plural(a.length, 'entity', 'entities')
}
case 'get_health_summary': {
const o = obj(t.result)
const h = (o?.health && obj(o.health)) || o
if (h) {
const parts = ['healthy', 'degraded', 'down', 'unknown']
.map((k) => {
const n = num((h as Record<string, unknown>)[k])
return n != null ? `${k} ${n}` : null
})
.filter((p): p is string => p != null)
if (parts.length) return parts.join(' · ')
}
return 'done'
}
case 'get_state_snapshot': {
const o = obj(t.result)
const drift = num(o?.drift ?? o?.drift_count)
return drift != null ? plural(drift, 'drift') : 'done'
}
case 'search_knowledge':
case 'get_entity_knowledge':
case 'get_patterns':
case 'get_skills': {
const a = resultArray(t.result)
return a ? plural(a.length, 'result') : firstLine(resultAsString(t.result)) || 'done'
}
case 'upsert_knowledge': {
const m = resultAsString(t.result).match(/[a-z]+:nomos\/[a-z0-9-]+/)
return m ? `recorded ${m[0]}` : 'recorded'
}
case 'update_plan_step': {
const seq = num(args.seq)
const status = str(args.status)
if (seq != null && status) return `step ${seq}${status}`
return status || 'updated'
}
case 'propose_plan': {
const a = resultArray(t.result) ?? resultArray(args.steps)
return a ? plural(a.length, 'step') : 'planned'
}
case 'set_goal':
return 'goal set'
case 'complete_task':
return str(args.outcome) || 'complete'
case 'ask_operator':
return 'asked'
case 'ping_service': {
const s = resultAsString(t.result).toLowerCase()
return /ok|reachable|up|healthy/.test(s) ? 'reachable' : firstLine(s, 40) || 'done'
}
case 'get_execution_status': {
const o = obj(t.result)
return str(o?.state) || firstLine(resultAsString(t.result), 40) || 'done'
}
default:
return firstLine(resultAsString(t.result)) || 'done'
}
}