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

@@ -75,6 +75,18 @@
let cw = $state(300)
let ch = $state(300)
// View transform (zoom-to-fit + drag-pan). The force simulation runs in its
// own graph coordinate space; this maps graph→screen so every entity stays
// visible regardless of how far the layout spreads or how narrow the panel
// is. tx/ty are screen px; scale is unitless. `userPanned` pauses auto-fit
// once the operator drags the background, until the entity set changes or
// they double-click to reset.
let tx = $state(0)
let ty = $state(0)
let scale = $state(1)
let userPanned = $state(false)
const viewTransform = $derived(`translate(${tx},${ty}) scale(${scale})`)
function collectSlugs(value: unknown, out: Set<string>) {
if (typeof value === 'string') {
const m = value.match(SLUG_RE)
@@ -206,6 +218,7 @@
.alphaDecay(0.045)
.on('tick', () => {
nodes = [...nodes]
if (!userPanned) fitView()
})
}
@@ -261,6 +274,49 @@
return slug.split(':').pop() ?? slug
}
// Compute the view transform that fits every node (with label clearance)
// inside the panel, clamped so a single node doesn't fill it and a huge
// graph stays legible. No-op until the layout has positions / a size.
function fitView() {
if (!nodes.length || cw <= 1 || ch <= 1) return
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const n of nodes) {
if (n.x == null || n.y == null) continue
const r = nodeRadius(n) + 12 // node + label clearance
minX = Math.min(minX, n.x - r)
minY = Math.min(minY, n.y - r)
maxX = Math.max(maxX, n.x + r)
maxY = Math.max(maxY, n.y + r)
}
if (!Number.isFinite(minX)) return
const pad = 16
const w = Math.max(maxX - minX, 1)
const h = Math.max(maxY - minY, 1)
const s = Math.min((cw - pad * 2) / w, (ch - pad * 2) / h)
const clamped = Math.max(0.2, Math.min(2.5, Number.isFinite(s) ? s : 1))
scale = clamped
tx = (cw - w * clamped) / 2 - minX * clamped
ty = (ch - h * clamped) / 2 - minY * clamped
}
// When the entity SET changes (a new node added/removed), re-engage auto-fit
// so the new entity is brought into view. Same-slug re-renders (every sim
// tick) leave the signature unchanged and don't reset.
let lastMembership = ''
$effect(() => {
const sig = nodes
.map((n) => n.slug)
.sort()
.join('|')
if (sig !== lastMembership) {
lastMembership = sig
userPanned = false
}
})
// Live touch/health-diff lookups, keyed by slug for O(1) per-node checks
// during render. Kept as plain objects (not Maps) since Svelte 5 runes track
// object identity fine and this is small (≤12 touched, ≤8 diffs).
@@ -283,16 +339,22 @@
return typeof end === 'object' ? end.slug : end
}
// ─── drag / select ───────────────────────────────────────────────────
// ─── drag / select / pan ─────────────────────────────────────────────
// A click (pointerdown+up with no movement in between) opens the entity
// straight in its own floating window (WindowLayer) instead of a
// click-through mini-panel — `selected` now only drives the highlight/dim
// styling below, so you can see at a glance which node you last opened.
// straight in its own floating window (WindowLayer); `selected` only drives
// the highlight/dim styling. Node drag pins the node in GRAPH coords
// (screen→graph via the inverse view transform). Background drag pans the
// view and sets userPanned so auto-fit pauses. Double-click background
// re-fits all entities.
let dragState: { node: Node; moved: boolean } | null = null
let panState: { x: number; y: number } | null = null
function toLocal(clientX: number, clientY: number) {
function toGraph(clientX: number, clientY: number) {
const rect = container!.getBoundingClientRect()
return { x: clientX - rect.left, y: clientY - rect.top }
return {
x: (clientX - rect.left - tx) / scale,
y: (clientY - rect.top - ty) / scale
}
}
function onNodeDown(e: PointerEvent, node: Node) {
@@ -301,26 +363,44 @@
dragState = { node, moved: false }
sim?.alphaTarget(0.2).restart()
}
function onBgDown(e: PointerEvent) {
panState = { x: e.clientX - tx, y: e.clientY - ty }
;(e.currentTarget as Element).setPointerCapture(e.pointerId)
}
function onMove(e: PointerEvent) {
if (!dragState) return
const p = toLocal(e.clientX, e.clientY)
dragState.node.fx = p.x
dragState.node.fy = p.y
dragState.moved = true
nodes = [...nodes]
if (dragState) {
const p = toGraph(e.clientX, e.clientY)
dragState.node.fx = p.x
dragState.node.fy = p.y
dragState.moved = true
nodes = [...nodes]
return
}
if (panState) {
tx = e.clientX - panState.x
ty = e.clientY - panState.y
userPanned = true
}
}
function selectAndOpen(node: Node) {
selected = node
openEntityWindow(node.slug)
}
function onUp() {
if (!dragState) return
const { node, moved } = dragState
node.fx = null
node.fy = null
sim?.alphaTarget(0)
dragState = null
if (!moved) selectAndOpen(node)
if (dragState) {
const { node, moved } = dragState
node.fx = null
node.fy = null
sim?.alphaTarget(0)
dragState = null
if (!moved) selectAndOpen(node)
return
}
panState = null
}
function refit() {
userPanned = false
fitView()
}
const selectedRelations = $derived(
@@ -342,7 +422,7 @@
)
</script>
<aside class="flex h-full min-h-0 flex-col bg-card/40">
<aside class="flex h-full min-h-0 flex-col bg-card">
{#if nowTouching}
<div
class="flex shrink-0 items-center gap-1.5 border-b bg-primary/5 px-3 py-1.5 text-[11px] text-primary"
@@ -447,9 +527,11 @@
class="h-full w-full touch-none select-none"
role="application"
aria-label="Session entity graph"
onpointerdown={onBgDown}
onpointermove={onMove}
onpointerup={onUp}
onpointercancel={onUp}
ondblclick={refit}
>
<defs>
<pattern id={dotGridId} width="12" height="12" patternUnits="userSpaceOnUse">
@@ -457,8 +539,9 @@
</pattern>
</defs>
<rect width={cw} height={ch} fill="url(#{dotGridId})" />
<g>
{#each links as link}
<g transform={viewTransform}>
<g>
{#each links as link}
{@const s = endpoint(link.source)}
{@const t = endpoint(link.target)}
{#if s?.x != null && t?.x != null && s?.y != null && t?.y != null}
@@ -560,6 +643,7 @@
{/if}
{/each}
</g>
</g>
</svg>
{/if}
</div>