The agent could run two turns for one session at once (a reconnect resumed
while the live turn was still going), and their interleaved tool calls
corrupted the activity panel, fabricated a confusing "parallel/nested"
sequence, and made tasks feel stuck/never-ending. Several UX gaps compounded it.
Turn serialization (F1):
- turnGate: at most one in-flight turn per session. Background resume paths
(continuation worker, idle sweep, answer-question, /resume, reconnect)
skip non-blocking when busy; the live chat path waits briefly then bails
cleanly instead of stacking a second turn.
- resumeSession returns whether it ran; continueSession marks an execution
"continued" only after a real run (review P0) so a busy-skip can't lose a
finished-execution result. Idle nudge bumps only after delivery (P1).
Connection state (F2/F3, web):
- humanize/bucket raw errors ("model connection dropped..."); one surface
per drop; a terminal task.status event clears stuck streaming/disconnected
state and dismisses the connection toast. Reconnect no longer spawns turns.
Streaming where you look (F4, web):
- live command output in the global activity timeline and in the inline
tool card (auto-opened, tail-pinned) -- not just the per-window rail.
Other (web): artifact/knowledge deep links (F5); step-first stable
"thinking" headline (F6); stable chat layout, no empty->content reflow (F7);
lazy event sync (P2.2); reconnect skips a terminal session (P2.1).
VERSION: 0.14.2 -> 0.15.0
136 lines
4.7 KiB
Svelte
136 lines
4.7 KiB
Svelte
<script lang="ts">
|
|
// One tool call inside AgentTrace's expanded list. Renders as a borderless
|
|
// row (the trace supplies the container/border) whose own click reveals the
|
|
// raw args/result — so the trace stays a readable thinking log by default
|
|
// and the JSON is one more click away, not stacked inline.
|
|
import { Check, ChevronRight, Loader2, X } from '@lucide/svelte'
|
|
import type { ToolCallResult } from '$lib/types'
|
|
import { toolActivityLabel } from '$lib/stores/activity'
|
|
|
|
let { tool }: { tool: ToolCallResult } = $props()
|
|
let expanded = $state(false)
|
|
let liveEl = $state<HTMLPreElement | null>(null)
|
|
|
|
const status = $derived.by(() => {
|
|
if (tool.type === 'tool_use') return 'running'
|
|
if (tool.error) return 'error'
|
|
return 'done'
|
|
})
|
|
|
|
// Auto-open while a command is streaming its output, so the operator sees it
|
|
// without an extra click — mirrors UnifiedTimeline. Once the tool_result
|
|
// lands (status flips off running) liveOutput clears and the card respects
|
|
// the manual toggle again. (F4)
|
|
const open = $derived(expanded || !!tool.liveOutput)
|
|
$effect(() => {
|
|
if (tool.liveOutput && liveEl) liveEl.scrollTop = liveEl.scrollHeight
|
|
})
|
|
|
|
const label = $derived(toolActivityLabel(tool))
|
|
|
|
const argsSummary = $derived.by(() => {
|
|
if (!tool.args) return ''
|
|
const entries = Object.entries(tool.args)
|
|
if (entries.length === 0) return ''
|
|
const first = entries[0]
|
|
const val = typeof first[1] === 'string' ? first[1] : JSON.stringify(first[1])
|
|
return `${first[0]}: ${val.length > 60 ? val.slice(0, 60) + '…' : val}`
|
|
})
|
|
|
|
const hasDetail = $derived(
|
|
!!tool.args || (tool.result !== undefined && tool.result !== null) || !!tool.error
|
|
)
|
|
</script>
|
|
|
|
<div class="tool-row">
|
|
<button
|
|
class="flex w-full items-start gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-muted/40 disabled:cursor-default"
|
|
onclick={() => (expanded = !expanded)}
|
|
aria-expanded={open}
|
|
disabled={!hasDetail && !tool.liveOutput}
|
|
>
|
|
<span class="mt-px shrink-0 {status === 'error' ? 'text-destructive' : 'text-primary'}">
|
|
{#if status === 'running'}
|
|
<Loader2 class="size-3 animate-spin" />
|
|
{:else if status === 'error'}
|
|
<X class="size-3" />
|
|
{:else}
|
|
<Check class="size-3" />
|
|
{/if}
|
|
</span>
|
|
<span class="min-w-0 flex-1">
|
|
<span class="block truncate text-xs text-foreground/90">{label}</span>
|
|
{#if argsSummary}
|
|
<span class="block truncate font-mono text-[10px] text-muted-foreground/60"
|
|
>{argsSummary}</span
|
|
>
|
|
{/if}
|
|
</span>
|
|
<span class="shrink-0 font-mono text-[10px] text-muted-foreground/50">{tool.name}</span>
|
|
{#if hasDetail || tool.liveOutput}
|
|
<ChevronRight
|
|
class="mt-px size-3 shrink-0 text-muted-foreground/50 transition-transform {open
|
|
? 'rotate-90'
|
|
: ''}"
|
|
/>
|
|
{/if}
|
|
</button>
|
|
|
|
{#if open}
|
|
<div class="space-y-2 px-2 pb-2 pl-7">
|
|
{#if tool.liveOutput}
|
|
<div>
|
|
<div
|
|
class="mb-1 flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wider text-primary"
|
|
>
|
|
<Loader2 class="size-2.5 animate-spin" />
|
|
Live output
|
|
</div>
|
|
<pre
|
|
bind:this={liveEl}
|
|
class="max-h-48 overflow-auto whitespace-pre-wrap break-words rounded-md bg-muted/60 p-2 font-mono text-[11px] text-foreground/90">{tool.liveOutput}</pre>
|
|
</div>
|
|
{/if}
|
|
{#if tool.args}
|
|
<div>
|
|
<div
|
|
class="mb-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground"
|
|
>
|
|
Args
|
|
</div>
|
|
<pre
|
|
class="max-h-48 overflow-x-auto rounded-md bg-muted/60 p-2 text-[11px]">{JSON.stringify(
|
|
tool.args,
|
|
null,
|
|
2
|
|
)}</pre>
|
|
</div>
|
|
{/if}
|
|
{#if tool.result !== undefined && tool.result !== null}
|
|
<div>
|
|
<div
|
|
class="mb-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground"
|
|
>
|
|
Result
|
|
</div>
|
|
<pre
|
|
class="max-h-48 overflow-x-auto rounded-md bg-muted/60 p-2 text-[11px]">{JSON.stringify(
|
|
tool.result,
|
|
null,
|
|
2
|
|
)}</pre>
|
|
</div>
|
|
{/if}
|
|
{#if tool.error}
|
|
<div>
|
|
<div class="mb-1 text-[10px] font-semibold uppercase tracking-wider text-destructive">
|
|
Error
|
|
</div>
|
|
<pre
|
|
class="max-h-48 overflow-x-auto rounded-md border border-destructive/20 bg-destructive/5 p-2 text-[11px] text-destructive">{tool.error}</pre>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|