v0.20.0: thinking blocks, chat windows overhaul, scroll fix
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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user