feat(web): collapse chat tool calls into one agent trace, reverse the activity rail
The chat rendered one card per tool call, so a 20-call turn buried the answer under 20 stacked cards. Merge them with the "thinking" indicator into a single collapsible strip above the answer: - collapsed: the live activity while running, a count once finished - expanded: the turn's work in humanized language (reuses the activity log's toolActivityLabel, so ten identical "run · target: host:strong" rows now read as what they actually did) - per row: the raw args/result, one more click in Also flip the Activity rail to newest-first with the current step on top: - follow-mode/auto-scroll re-anchored to the top to match, or it would jump to the oldest entry on every new event - pending plan steps park at the tail rather than sorting above the running step and pushing it off the top; the goal anchors the bottom Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
115
web/src/lib/components/AgentTrace.svelte
Normal file
115
web/src/lib/components/AgentTrace.svelte
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// The agent's working trace for one assistant turn: the live "thinking"
|
||||||
|
// indicator and that turn's tool calls merged into a single collapsible
|
||||||
|
// strip, instead of a stack of one card per call (a 13-call turn buried the
|
||||||
|
// actual answer). Collapsed it's one line — the current activity while
|
||||||
|
// running, a count once finished. Expanded it lists what the agent did, in
|
||||||
|
// humanized language, each row opening to its raw args/result.
|
||||||
|
import type { ToolCallResult } from '$lib/types'
|
||||||
|
import ToolCallCard from './ToolCallCard.svelte'
|
||||||
|
import Spinner from './Spinner.svelte'
|
||||||
|
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
|
||||||
|
import CheckIcon from '@lucide/svelte/icons/check'
|
||||||
|
import XIcon from '@lucide/svelte/icons/x'
|
||||||
|
import SparklesIcon from '@lucide/svelte/icons/sparkles'
|
||||||
|
|
||||||
|
let {
|
||||||
|
tools = [],
|
||||||
|
label = null,
|
||||||
|
status = 'idle'
|
||||||
|
}: {
|
||||||
|
tools?: ToolCallResult[]
|
||||||
|
/** Live indicator text — the running step, an error, or "Done". */
|
||||||
|
label?: string | null
|
||||||
|
/** `idle` = no live state; the strip is just this turn's finished trace. */
|
||||||
|
status?: 'running' | 'done' | 'error' | 'idle'
|
||||||
|
} = $props()
|
||||||
|
|
||||||
|
let expanded = $state(false)
|
||||||
|
|
||||||
|
const count = $derived(tools.length)
|
||||||
|
// Collapsed line: prefer the live activity while something is happening,
|
||||||
|
// otherwise summarize the turn so a finished trace still says what it was.
|
||||||
|
const headline = $derived.by(() => {
|
||||||
|
if (status !== 'idle' && label) return label
|
||||||
|
if (count > 0) return count === 1 ? '1 tool call' : `${count} tool calls`
|
||||||
|
return 'No tool calls'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="trace rounded-lg border border-border/60 bg-card/40 transition-colors" class:running={status === 'running'}>
|
||||||
|
<button
|
||||||
|
class="flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors hover:bg-muted/40"
|
||||||
|
onclick={() => (expanded = !expanded)}
|
||||||
|
aria-expanded={expanded}
|
||||||
|
aria-label={expanded ? 'Hide agent trace' : 'Show agent trace'}
|
||||||
|
>
|
||||||
|
<span class="shrink-0 {status === 'error' ? 'text-destructive' : status === 'idle' ? 'text-muted-foreground' : 'text-primary'}">
|
||||||
|
{#if status === 'running'}
|
||||||
|
<Spinner class="size-3" />
|
||||||
|
{:else if status === 'error'}
|
||||||
|
<XIcon class="size-3" />
|
||||||
|
{:else if status === 'done'}
|
||||||
|
<CheckIcon class="size-3" />
|
||||||
|
{:else}
|
||||||
|
<SparklesIcon class="size-3" />
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span
|
||||||
|
class="min-w-0 flex-1 truncate text-xs {status === 'error'
|
||||||
|
? 'text-destructive'
|
||||||
|
: status === 'running'
|
||||||
|
? 'text-foreground/80'
|
||||||
|
: 'text-muted-foreground'}"
|
||||||
|
>
|
||||||
|
{headline}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{#if count > 0 && status !== 'idle'}
|
||||||
|
<span class="shrink-0 text-[10px] tabular-nums text-muted-foreground/60">{count}</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<ChevronRightIcon
|
||||||
|
class="size-3 shrink-0 text-muted-foreground/50 transition-transform {expanded ? 'rotate-90' : ''}"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if expanded}
|
||||||
|
<div class="border-t border-border/40 p-1">
|
||||||
|
{#if count > 0}
|
||||||
|
{#each tools as tool (tool.id)}
|
||||||
|
<ToolCallCard {tool} />
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
<p class="px-2 py-1.5 text-[11px] text-muted-foreground">Nothing recorded for this turn yet.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.trace {
|
||||||
|
animation: trace-in 0.2s ease-out;
|
||||||
|
}
|
||||||
|
/* A faint pulse while the agent is mid-turn — the collapsed strip is the
|
||||||
|
only thing on screen then, so it carries the "still working" signal. */
|
||||||
|
.trace.running {
|
||||||
|
border-color: color-mix(in oklab, var(--primary) 35%, var(--border));
|
||||||
|
}
|
||||||
|
@keyframes trace-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.trace {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -9,12 +9,9 @@
|
|||||||
import type { Readable } from 'svelte/store'
|
import type { Readable } from 'svelte/store'
|
||||||
import { Button } from '$lib/components/ui/button'
|
import { Button } from '$lib/components/ui/button'
|
||||||
import { Textarea } from '$lib/components/ui/textarea'
|
import { Textarea } from '$lib/components/ui/textarea'
|
||||||
import Spinner from './Spinner.svelte'
|
import AgentTrace from './AgentTrace.svelte'
|
||||||
import ToolCallCard from './ToolCallCard.svelte'
|
|
||||||
import OperatorQuestion from './OperatorQuestion.svelte'
|
import OperatorQuestion from './OperatorQuestion.svelte'
|
||||||
import CornerDownLeftIcon from '@lucide/svelte/icons/corner-down-left'
|
import CornerDownLeftIcon from '@lucide/svelte/icons/corner-down-left'
|
||||||
import CheckIcon from '@lucide/svelte/icons/check'
|
|
||||||
import XIcon from '@lucide/svelte/icons/x'
|
|
||||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw'
|
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw'
|
||||||
import SquareIcon from '@lucide/svelte/icons/square'
|
import SquareIcon from '@lucide/svelte/icons/square'
|
||||||
import { marked } from 'marked'
|
import { marked } from 'marked'
|
||||||
@@ -217,6 +214,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="max-w-[85%] rounded-2xl rounded-br-sm bg-primary px-4 py-2.5 text-sm text-primary-foreground whitespace-pre-wrap user-msg">{msg.text}</div>
|
<div class="max-w-[85%] rounded-2xl rounded-br-sm bg-primary px-4 py-2.5 text-sm text-primary-foreground whitespace-pre-wrap user-msg">{msg.text}</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
{@const isLast = idx === messages.length - 1}
|
||||||
|
{@const traceStatus = !isLast
|
||||||
|
? 'idle'
|
||||||
|
: error
|
||||||
|
? 'error'
|
||||||
|
: streaming
|
||||||
|
? 'running'
|
||||||
|
: indicatorDone
|
||||||
|
? 'done'
|
||||||
|
: 'idle'}
|
||||||
<div class="flex w-full flex-col gap-2">
|
<div class="flex w-full flex-col gap-2">
|
||||||
<div class="flex items-baseline gap-2 px-1">
|
<div class="flex items-baseline gap-2 px-1">
|
||||||
<span class="text-[10px] font-medium text-muted-foreground/70">Nomos</span>
|
<span class="text-[10px] font-medium text-muted-foreground/70">Nomos</span>
|
||||||
@@ -224,34 +231,25 @@
|
|||||||
<span class="text-[9px] text-muted-foreground/50">{formatTime(msg.created_at)}</span>
|
<span class="text-[9px] text-muted-foreground/50">{formatTime(msg.created_at)}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
<!-- The working trace sits above the answer: it's what happened
|
||||||
|
first, and collapsed it keeps a long tool run from burying
|
||||||
|
the text below it. -->
|
||||||
|
{#if msg.tools.length > 0 || traceStatus !== 'idle'}
|
||||||
|
<AgentTrace
|
||||||
|
tools={msg.tools}
|
||||||
|
status={traceStatus}
|
||||||
|
label={traceStatus === 'idle' ? null : indicatorLabel}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
{#if msg.text}
|
{#if msg.text}
|
||||||
<div class="prose-chat max-w-none text-sm leading-relaxed assistant-msg">
|
<div class="prose-chat max-w-none text-sm leading-relaxed assistant-msg">
|
||||||
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify -->
|
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify -->
|
||||||
{@html render(msg.text)}
|
{@html render(msg.text)}
|
||||||
{#if idx === messages.length - 1 && streaming}
|
{#if isLast && streaming}
|
||||||
<span class="stream-cursor" aria-hidden="true"></span>
|
<span class="stream-cursor" aria-hidden="true"></span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if msg.tools.length > 0}
|
|
||||||
<div class="flex flex-col gap-1.5">
|
|
||||||
{#each msg.tools as tool (tool.id)}
|
|
||||||
<ToolCallCard {tool} />
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if idx === messages.length - 1 && msg.text === '' && (streaming || indicatorDone || error)}
|
|
||||||
<div class="flex items-center gap-2 py-1 text-xs {error ? 'text-destructive' : indicatorDone ? 'text-primary' : 'text-muted-foreground'}">
|
|
||||||
{#if error}
|
|
||||||
<XIcon class="size-3 shrink-0" />
|
|
||||||
{:else if indicatorDone}
|
|
||||||
<CheckIcon class="size-3 shrink-0" />
|
|
||||||
{:else}
|
|
||||||
<Spinner class="size-3 shrink-0 text-primary" />
|
|
||||||
{/if}
|
|
||||||
<span>{indicatorLabel}</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Check, ChevronRight, Loader2, Wrench, X } from '@lucide/svelte'
|
// 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 type { ToolCallResult } from '$lib/types'
|
||||||
|
import { toolActivityLabel } from '$lib/stores/activity'
|
||||||
|
|
||||||
let { tool }: { tool: ToolCallResult } = $props()
|
let { tool }: { tool: ToolCallResult } = $props()
|
||||||
let expanded = $state(false)
|
let expanded = $state(false)
|
||||||
@@ -11,11 +16,7 @@
|
|||||||
return 'done'
|
return 'done'
|
||||||
})
|
})
|
||||||
|
|
||||||
const statusColor = $derived.by(() => {
|
const label = $derived(toolActivityLabel(tool))
|
||||||
if (status === 'running') return 'text-primary'
|
|
||||||
if (status === 'error') return 'text-destructive'
|
|
||||||
return 'text-primary'
|
|
||||||
})
|
|
||||||
|
|
||||||
const argsSummary = $derived.by(() => {
|
const argsSummary = $derived.by(() => {
|
||||||
if (!tool.args) return ''
|
if (!tool.args) return ''
|
||||||
@@ -25,61 +26,62 @@
|
|||||||
const val = typeof first[1] === 'string' ? first[1] : JSON.stringify(first[1])
|
const val = typeof first[1] === 'string' ? first[1] : JSON.stringify(first[1])
|
||||||
return `${first[0]}: ${val.length > 60 ? val.slice(0, 60) + '…' : val}`
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="tool-card rounded-lg border border-border/60 bg-card/40 overflow-hidden transition-all">
|
<div class="tool-row">
|
||||||
<button
|
<button
|
||||||
class="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-muted/40 transition-colors"
|
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)}
|
onclick={() => (expanded = !expanded)}
|
||||||
aria-expanded={expanded}
|
aria-expanded={expanded}
|
||||||
|
disabled={!hasDetail}
|
||||||
>
|
>
|
||||||
<ChevronRight class="size-3 shrink-0 text-muted-foreground transition-transform {expanded ? 'rotate-90' : ''}" />
|
<span class="mt-px shrink-0 {status === 'error' ? 'text-destructive' : 'text-primary'}">
|
||||||
<Wrench class="size-3.5 shrink-0 text-muted-foreground" />
|
|
||||||
<span class="font-mono text-xs font-medium text-foreground/80">{tool.name}</span>
|
|
||||||
{#if argsSummary}
|
|
||||||
<span class="ml-1 truncate text-[11px] text-muted-foreground/70">{argsSummary}</span>
|
|
||||||
{/if}
|
|
||||||
<span class="ml-auto shrink-0 {statusColor}">
|
|
||||||
{#if status === 'running'}
|
{#if status === 'running'}
|
||||||
<Loader2 class="size-3.5 animate-spin" />
|
<Loader2 class="size-3 animate-spin" />
|
||||||
{:else if status === 'error'}
|
{:else if status === 'error'}
|
||||||
<X class="size-3.5" />
|
<X class="size-3" />
|
||||||
{:else}
|
{:else}
|
||||||
<Check class="size-3.5" />
|
<Check class="size-3" />
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</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}
|
||||||
|
<ChevronRight
|
||||||
|
class="mt-px size-3 shrink-0 text-muted-foreground/50 transition-transform {expanded ? 'rotate-90' : ''}"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{#if expanded}
|
{#if expanded}
|
||||||
<div class="border-t border-border/40 px-3 py-2 space-y-2">
|
<div class="space-y-2 px-2 pb-2 pl-7">
|
||||||
{#if tool.args}
|
{#if tool.args}
|
||||||
<div>
|
<div>
|
||||||
<div class="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground mb-1">Args</div>
|
<div class="mb-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Args</div>
|
||||||
<pre class="tool-pre rounded-md bg-muted/60 p-2 text-[11px] overflow-x-auto max-h-48">{JSON.stringify(tool.args, null, 2)}</pre>
|
<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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if tool.result !== undefined && tool.result !== null}
|
{#if tool.result !== undefined && tool.result !== null}
|
||||||
<div>
|
<div>
|
||||||
<div class="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground mb-1">Result</div>
|
<div class="mb-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Result</div>
|
||||||
<pre class="tool-pre rounded-md bg-muted/60 p-2 text-[11px] overflow-x-auto max-h-48">{JSON.stringify(tool.result, null, 2)}</pre>
|
<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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if tool.error}
|
{#if tool.error}
|
||||||
<div>
|
<div>
|
||||||
<div class="text-[10px] font-semibold uppercase tracking-wider text-destructive mb-1">Error</div>
|
<div class="mb-1 text-[10px] font-semibold uppercase tracking-wider text-destructive">Error</div>
|
||||||
<pre class="tool-pre rounded-md bg-destructive/5 border border-destructive/20 p-2 text-[11px] text-destructive overflow-x-auto max-h-48">{tool.error}</pre>
|
<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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
|
||||||
.tool-card {
|
|
||||||
animation: tool-in 0.2s ease-out;
|
|
||||||
}
|
|
||||||
@keyframes tool-in {
|
|
||||||
from { opacity: 0; transform: translateY(-2px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -16,6 +16,9 @@
|
|||||||
import FlagIcon from '@lucide/svelte/icons/flag'
|
import FlagIcon from '@lucide/svelte/icons/flag'
|
||||||
|
|
||||||
// Merged plan + activity timeline, designed for the narrow rail:
|
// Merged plan + activity timeline, designed for the narrow rail:
|
||||||
|
// - ordered newest-first: what the agent is doing right now is at the top,
|
||||||
|
// history flows downward, and the goal sits at the bottom where the task
|
||||||
|
// began (see the sort in `items`)
|
||||||
// - one continuous vertical "backbone"; every item owns a segment of it,
|
// - one continuous vertical "backbone"; every item owns a segment of it,
|
||||||
// colored by state (done = filled primary, running = faint primary,
|
// colored by state (done = filled primary, running = faint primary,
|
||||||
// pending/future = muted) so the line visibly fills in as work completes
|
// pending/future = muted) so the line visibly fills in as work completes
|
||||||
@@ -25,7 +28,8 @@
|
|||||||
// markers on the same backbone
|
// markers on the same backbone
|
||||||
// - the running step auto-expands and the view auto-scrolls to keep the
|
// - the running step auto-expands and the view auto-scrolls to keep the
|
||||||
// current step visible while the agent works (follow mode disengages if
|
// current step visible while the agent works (follow mode disengages if
|
||||||
// the operator scrolls up, re-engages when streaming starts again)
|
// the operator scrolls down into history, re-engages when streaming
|
||||||
|
// starts again)
|
||||||
let { entries, planSteps: steps, streaming = false }: {
|
let { entries, planSteps: steps, streaming = false }: {
|
||||||
entries: ActivityEntry[]
|
entries: ActivityEntry[]
|
||||||
planSteps: PlanStep[]
|
planSteps: PlanStep[]
|
||||||
@@ -63,9 +67,10 @@
|
|||||||
for (const s of steps) {
|
for (const s of steps) {
|
||||||
if (s.status === 'pending' && !entries.some((e) => e.stepSeq === s.seq)) {
|
if (s.status === 'pending' && !entries.some((e) => e.stepSeq === s.seq)) {
|
||||||
// Pending steps with no activity yet still show on the timeline so
|
// Pending steps with no activity yet still show on the timeline so
|
||||||
// the operator sees what's coming — but only if a plan exists.
|
// the operator sees what's coming — but only if a plan exists. ts 0
|
||||||
|
// parks them at the tail of the newest-first sort below (see there).
|
||||||
if (steps.length > 0) {
|
if (steps.length > 0) {
|
||||||
out.push({ kind: 'step', step: s, tools: [], ts: Number.MAX_SAFE_INTEGER - s.seq })
|
out.push({ kind: 'step', step: s, tools: [], ts: 0 })
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -73,8 +78,11 @@
|
|||||||
(e) => e.stepSeq === s.seq && (e.type === 'tool_running' || e.type === 'tool_done' || e.type === 'tool_error')
|
(e) => e.stepSeq === s.seq && (e.type === 'tool_running' || e.type === 'tool_done' || e.type === 'tool_error')
|
||||||
)
|
)
|
||||||
const stepEntry = entries.find((e) => e.id === s.id)
|
const stepEntry = entries.find((e) => e.id === s.id)
|
||||||
|
// Timed from the step's own entry, else its earliest tool — so a step
|
||||||
|
// is placed by when it started, not by its latest activity.
|
||||||
const ts = stepEntry?.timestamp ?? tools[0]?.timestamp ?? Date.now()
|
const ts = stepEntry?.timestamp ?? tools[0]?.timestamp ?? Date.now()
|
||||||
out.push({ kind: 'step', step: s, tools, ts })
|
// Tools inside a step run newest-first too, matching the outer order.
|
||||||
|
out.push({ kind: 'step', step: s, tools: [...tools].reverse(), ts })
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const e of entries) {
|
for (const e of entries) {
|
||||||
@@ -84,7 +92,15 @@
|
|||||||
out.push({ kind: 'entry', entry: e, ts: e.timestamp })
|
out.push({ kind: 'entry', entry: e, ts: e.timestamp })
|
||||||
}
|
}
|
||||||
|
|
||||||
out.sort((a, b) => a.ts - b.ts)
|
// Newest first: whatever the agent is doing right now sits at the top of
|
||||||
|
// the rail, with history flowing downward. The two ts-0 groups fall to
|
||||||
|
// the bottom for free, which is where both belong in this order: the goal
|
||||||
|
// (timestamp 0 — where the task started) and not-yet-run plan steps.
|
||||||
|
// Sorting the latter by their future position would put them *above* the
|
||||||
|
// running step and push it off the top, which is exactly what this
|
||||||
|
// ordering exists to prevent. Array.sort is stable, so each group keeps
|
||||||
|
// its insertion order (plan steps in seq order).
|
||||||
|
out.sort((a, b) => b.ts - a.ts)
|
||||||
return out
|
return out
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -100,9 +116,11 @@
|
|||||||
let container = $state<HTMLDivElement | null>(null)
|
let container = $state<HTMLDivElement | null>(null)
|
||||||
let follow = $state(true)
|
let follow = $state(true)
|
||||||
|
|
||||||
|
// Newest-first, so "following the agent" means being parked at the top —
|
||||||
|
// the mirror of the bottom-anchored follow this had when it ran oldest-first.
|
||||||
function onScroll() {
|
function onScroll() {
|
||||||
if (!container) return
|
if (!container) return
|
||||||
follow = container.scrollHeight - container.scrollTop - container.clientHeight < 80
|
follow = container.scrollTop < 80
|
||||||
}
|
}
|
||||||
|
|
||||||
// A new turn re-engages follow mode even if the operator had scrolled up.
|
// A new turn re-engages follow mode even if the operator had scrolled up.
|
||||||
@@ -129,7 +147,7 @@
|
|||||||
? container.querySelector(`[data-tl-id="${CSS.escape(currentId)}"]`)
|
? container.querySelector(`[data-tl-id="${CSS.escape(currentId)}"]`)
|
||||||
: null
|
: null
|
||||||
if (target) target.scrollIntoView({ behavior: 'auto', block: 'nearest' })
|
if (target) target.scrollIntoView({ behavior: 'auto', block: 'nearest' })
|
||||||
else container.scrollTop = container.scrollHeight
|
else container.scrollTop = 0
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Presentation helpers ──────────────────────────────────────────────────
|
// ── Presentation helpers ──────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -185,7 +185,10 @@ export function activityLogFor(sessionId: string): Readable<ActivityEntry[]> {
|
|||||||
return derived([chat.messages, ws.planSteps, task], ([$msgs, $steps, $task]) => computeActivityLog($msgs, $steps, $task))
|
return derived([chat.messages, ws.planSteps, task], ([$msgs, $steps, $task]) => computeActivityLog($msgs, $steps, $task))
|
||||||
}
|
}
|
||||||
|
|
||||||
function toolActivityLabel(t: ToolCallResult): string {
|
// Humanized, past/present-tense description of what a tool call is doing
|
||||||
|
// ("Check execution", "Research: …") rather than its raw wire name. Exported
|
||||||
|
// so the chat's agent trace can read as a thinking log instead of an API log.
|
||||||
|
export function toolActivityLabel(t: ToolCallResult): string {
|
||||||
const args = t.args ?? {}
|
const args = t.args ?? {}
|
||||||
const str = (v: unknown): string => typeof v === 'string' ? v : ''
|
const str = (v: unknown): string => typeof v === 'string' ? v : ''
|
||||||
switch (t.name) {
|
switch (t.name) {
|
||||||
|
|||||||
Reference in New Issue
Block a user