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
586 lines
26 KiB
Svelte
586 lines
26 KiB
Svelte
<script lang="ts">
|
|
import { untrack } from 'svelte'
|
|
import { slide } from 'svelte/transition'
|
|
import type { ActivityEntry } from '$lib/stores/activity'
|
|
import type { PlanStep } from '$lib/api'
|
|
import Spinner from './Spinner.svelte'
|
|
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
|
|
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
|
|
import CheckIcon from '@lucide/svelte/icons/check'
|
|
import XIcon from '@lucide/svelte/icons/x'
|
|
import PauseIcon from '@lucide/svelte/icons/pause'
|
|
import SlashIcon from '@lucide/svelte/icons/slash'
|
|
import WrenchIcon from '@lucide/svelte/icons/wrench'
|
|
import MilestoneIcon from '@lucide/svelte/icons/milestone'
|
|
import SparklesIcon from '@lucide/svelte/icons/sparkles'
|
|
import HelpCircleIcon from '@lucide/svelte/icons/help-circle'
|
|
import FlagIcon from '@lucide/svelte/icons/flag'
|
|
import ExternalLinkIcon from '@lucide/svelte/icons/external-link'
|
|
import { openEntityWindow } from '$lib/stores/windows'
|
|
|
|
// 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,
|
|
// colored by state (done = filled primary, running = faint primary,
|
|
// pending/future = muted) so the line visibly fills in as work completes
|
|
// - plan steps are filled status nodes ON the backbone; their tool calls
|
|
// branch off with horizontal stubs
|
|
// - flat entries (goal/knowledge/complete/orphan tools) are milestone
|
|
// markers on the same backbone
|
|
// - the running step auto-expands and the view auto-scrolls to keep the
|
|
// current step visible while the agent works (follow mode disengages if
|
|
// the operator scrolls down into history, re-engages when streaming
|
|
// starts again)
|
|
let {
|
|
entries,
|
|
planSteps: steps,
|
|
streaming = false
|
|
}: {
|
|
entries: ActivityEntry[]
|
|
planSteps: PlanStep[]
|
|
streaming?: boolean
|
|
} = $props()
|
|
|
|
// Explicit user toggles only — default open state derives from step status
|
|
// (running = expanded, everything else = collapsed) so a step collapses
|
|
// itself the moment it finishes unless the operator pinned it open.
|
|
let stepToggles = $state(new Map<string, boolean>())
|
|
let expandedTools = $state(new Set<string>())
|
|
|
|
function stepOpen(step: PlanStep): boolean {
|
|
return stepToggles.get(step.id) ?? step.status === 'running'
|
|
}
|
|
function toggleStep(step: PlanStep) {
|
|
stepToggles.set(step.id, !stepOpen(step))
|
|
stepToggles = new Map(stepToggles)
|
|
}
|
|
// Pin each streaming output pane to its tail as chunks arrive. Keyed by
|
|
// tool id because several run entries can be on screen, though only the
|
|
// newest one is ever actually streaming.
|
|
let liveOutputEls = $state<Record<string, HTMLPreElement | null>>({})
|
|
$effect(() => {
|
|
// Depend on entries only. liveOutputEls is written by bind:this, so
|
|
// tracking it here would let a re-render re-trigger this effect.
|
|
const current = entries
|
|
untrack(() => {
|
|
for (const e of current) {
|
|
if (!e.liveOutput) continue
|
|
const el = liveOutputEls[e.id]
|
|
if (el) el.scrollTop = el.scrollHeight
|
|
}
|
|
})
|
|
})
|
|
|
|
function toggleTool(id: string) {
|
|
if (expandedTools.has(id)) expandedTools.delete(id)
|
|
else expandedTools.add(id)
|
|
expandedTools = new Set(expandedTools)
|
|
}
|
|
|
|
// ── Timeline model ────────────────────────────────────────────────────────
|
|
type TLItem =
|
|
| { kind: 'step'; step: PlanStep; tools: ActivityEntry[]; ts: number }
|
|
| { kind: 'entry'; entry: ActivityEntry; ts: number }
|
|
|
|
const items = $derived.by<TLItem[]>(() => {
|
|
const stepIds = new Set(steps.map((s) => s.id))
|
|
const out: TLItem[] = []
|
|
|
|
for (const s of steps) {
|
|
if (s.status === 'pending' && !entries.some((e) => e.stepSeq === s.seq)) {
|
|
// Pending steps with no activity yet still show on the timeline so
|
|
// 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) {
|
|
out.push({ kind: 'step', step: s, tools: [], ts: 0 })
|
|
}
|
|
continue
|
|
}
|
|
const tools = entries.filter(
|
|
(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)
|
|
// 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()
|
|
// 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) {
|
|
const isTool = e.type === 'tool_running' || e.type === 'tool_done' || e.type === 'tool_error'
|
|
if (isTool && e.stepSeq != null) continue // nested under its step
|
|
if (!isTool && stepIds.has(e.id)) continue // rendered as step node
|
|
out.push({ kind: 'entry', entry: e, ts: e.timestamp })
|
|
}
|
|
|
|
// 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
|
|
})
|
|
|
|
// ── Current activity + auto-scroll ────────────────────────────────────────
|
|
const currentId = $derived.by<string | null>(() => {
|
|
const runningTool = entries.find((e) => e.type === 'tool_running' && e.status === 'running')
|
|
if (runningTool) return runningTool.id
|
|
const runningStep = steps.find((s) => s.status === 'running')
|
|
if (runningStep) return runningStep.id
|
|
return null
|
|
})
|
|
|
|
let container = $state<HTMLDivElement | null>(null)
|
|
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() {
|
|
if (!container) return
|
|
follow = container.scrollTop < 80
|
|
}
|
|
|
|
// A new turn re-engages follow mode even if the operator had scrolled up.
|
|
let wasStreaming = $state(false)
|
|
$effect(() => {
|
|
if (streaming && !wasStreaming) follow = true
|
|
wasStreaming = streaming
|
|
})
|
|
|
|
// Scroll to the current step/tool whenever it changes (smooth) or when new
|
|
// entries land while following (instant, to avoid scroll-queue jank).
|
|
$effect(() => {
|
|
if (!currentId || !follow || !container) return
|
|
container
|
|
.querySelector(`[data-tl-id="${CSS.escape(currentId)}"]`)
|
|
?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
|
})
|
|
let lastEntryCount = 0
|
|
$effect(() => {
|
|
const n = entries.length
|
|
if (n === lastEntryCount) return
|
|
lastEntryCount = n
|
|
if (!follow || !container) return
|
|
const target = currentId
|
|
? container.querySelector(`[data-tl-id="${CSS.escape(currentId)}"]`)
|
|
: null
|
|
if (target) target.scrollIntoView({ behavior: 'auto', block: 'nearest' })
|
|
else container.scrollTop = 0
|
|
})
|
|
|
|
// ── Presentation helpers ──────────────────────────────────────────────────
|
|
// Segment geometry: the backbone's center runs at x=17.5px (node center:
|
|
// px-3 = 11.25px at the app's 15px root font-size + half of the 13px node),
|
|
// so the 1px line sits at left-17px. Each item's segment spans its full
|
|
// height so tools inside an expanded step stay on the line; first/last
|
|
// items clip theirs to their node/tool centers so the line never dangles
|
|
// past the timeline's ends.
|
|
function segClass(
|
|
status: string,
|
|
isFirst: boolean,
|
|
isLast: boolean,
|
|
expandedWithTools: boolean
|
|
): string {
|
|
let color = 'bg-border'
|
|
if (status === 'done') color = 'bg-primary/60'
|
|
else if (status === 'running') color = 'bg-primary/40'
|
|
else if (status === 'failed') color = 'bg-destructive/40'
|
|
|
|
if (isFirst && isLast) return `${color} top-[13px] h-0`
|
|
if (isFirst) return `${color} top-[13px] bottom-0`
|
|
if (isLast && expandedWithTools) return `${color} top-0 bottom-[11px]`
|
|
if (isLast) return `${color} top-0 bottom-[calc(100%-13px)]`
|
|
return `${color} top-0 bottom-0`
|
|
}
|
|
|
|
function entryIcon(entry: ActivityEntry) {
|
|
switch (entry.type) {
|
|
case 'goal':
|
|
return MilestoneIcon
|
|
case 'knowledge':
|
|
return SparklesIcon
|
|
case 'complete':
|
|
return FlagIcon
|
|
case 'question':
|
|
return HelpCircleIcon
|
|
default:
|
|
return WrenchIcon
|
|
}
|
|
}
|
|
function hhmm(ts: number): string {
|
|
if (!ts || ts > Number.MAX_SAFE_INTEGER - 1000) return ''
|
|
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
|
}
|
|
function hhmmss(ts: number): string {
|
|
if (!ts) return ''
|
|
return new Date(ts).toLocaleTimeString([], {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit'
|
|
})
|
|
}
|
|
function prettyPrint(raw: string): string {
|
|
try {
|
|
return JSON.stringify(JSON.parse(raw), null, 2)
|
|
} catch {
|
|
return raw
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div class="flex h-full flex-col">
|
|
<div class="flex-1 overflow-y-auto" bind:this={container} onscroll={onScroll}>
|
|
{#if items.length === 0}
|
|
<div class="flex flex-col items-center gap-2 px-3 py-6 text-center">
|
|
<svg viewBox="0 0 64 110" class="h-14 w-auto text-muted-foreground/40" fill="none">
|
|
<line
|
|
x1="32"
|
|
y1="8"
|
|
x2="32"
|
|
y2="102"
|
|
stroke="currentColor"
|
|
stroke-width="1"
|
|
stroke-dasharray="2.5 4"
|
|
opacity="0.35"
|
|
/>
|
|
<circle cx="32" cy="22" r="4" fill="currentColor">
|
|
<animate
|
|
attributeName="opacity"
|
|
values="0.25;0.9;0.25"
|
|
dur="2.4s"
|
|
repeatCount="indefinite"
|
|
/>
|
|
</circle>
|
|
<circle cx="32" cy="55" r="4" fill="currentColor">
|
|
<animate
|
|
attributeName="opacity"
|
|
values="0.25;0.9;0.25"
|
|
dur="2.4s"
|
|
begin="0.6s"
|
|
repeatCount="indefinite"
|
|
/>
|
|
</circle>
|
|
<circle cx="32" cy="55" r="4" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
<animate
|
|
attributeName="r"
|
|
values="4;11;4"
|
|
dur="2.4s"
|
|
begin="0.6s"
|
|
repeatCount="indefinite"
|
|
/>
|
|
<animate
|
|
attributeName="opacity"
|
|
values="0.6;0;0.6"
|
|
dur="2.4s"
|
|
begin="0.6s"
|
|
repeatCount="indefinite"
|
|
/>
|
|
</circle>
|
|
<circle cx="32" cy="88" r="4" fill="currentColor">
|
|
<animate
|
|
attributeName="opacity"
|
|
values="0.25;0.9;0.25"
|
|
dur="2.4s"
|
|
begin="1.2s"
|
|
repeatCount="indefinite"
|
|
/>
|
|
</circle>
|
|
</svg>
|
|
<p class="text-[11px] leading-relaxed text-muted-foreground">Waiting for activity…</p>
|
|
</div>
|
|
{:else}
|
|
<div class="flex flex-col py-1">
|
|
{#each items as item, i (item.kind === 'step' ? item.step.id : item.entry.id)}
|
|
{@const isFirst = i === 0}
|
|
{@const isLast = i === items.length - 1}
|
|
{#if item.kind === 'step'}
|
|
{@const st = item.step.status}
|
|
{@const open = stepOpen(item.step)}
|
|
{@const hasDetail = !!item.step.detail?.trim()}
|
|
{@const expandable = item.tools.length > 0 || hasDetail}
|
|
{@const expandedWithTools = open && item.tools.length > 0}
|
|
<!-- Step node on the backbone -->
|
|
<div class="relative" data-tl-id={item.step.id}>
|
|
<span
|
|
class="pointer-events-none absolute left-[17px] w-px {segClass(
|
|
st,
|
|
isFirst,
|
|
isLast,
|
|
expandedWithTools
|
|
)}"
|
|
aria-hidden="true"
|
|
></span>
|
|
<button
|
|
type="button"
|
|
class="relative flex w-full items-center gap-2 rounded px-3 py-1.5 text-left text-xs {expandable
|
|
? 'cursor-pointer hover:bg-muted/30'
|
|
: 'cursor-default'} {st === 'running' ? 'bg-primary/5' : ''}"
|
|
onclick={() => expandable && toggleStep(item.step)}
|
|
aria-expanded={open}
|
|
disabled={!expandable}
|
|
>
|
|
<!-- Filled status node -->
|
|
<span
|
|
class="relative z-10 flex size-3.5 shrink-0 items-center justify-center rounded-full
|
|
{st === 'done'
|
|
? 'bg-primary'
|
|
: st === 'running'
|
|
? 'bg-background'
|
|
: st === 'failed'
|
|
? 'bg-destructive'
|
|
: st === 'blocked'
|
|
? 'bg-warning/25 border border-warning'
|
|
: st === 'skipped' || st === 'replaced'
|
|
? 'bg-muted'
|
|
: 'bg-background border border-muted-foreground/40'}"
|
|
>
|
|
{#if st === 'running'}
|
|
<span class="absolute -inset-0.5 animate-ping rounded-full bg-primary/30"
|
|
></span>
|
|
<Spinner class="relative size-3.5 text-primary" />
|
|
{:else if st === 'done'}
|
|
<CheckIcon class="size-2.5 text-primary-foreground" strokeWidth={3.5} />
|
|
{:else if st === 'failed'}
|
|
<XIcon class="size-2.5 text-destructive-foreground" strokeWidth={3.5} />
|
|
{:else if st === 'blocked'}
|
|
<PauseIcon class="size-2 text-warning" strokeWidth={3} />
|
|
{:else if st === 'skipped' || st === 'replaced'}
|
|
<SlashIcon class="size-2 text-muted-foreground" strokeWidth={3} />
|
|
{/if}
|
|
</span>
|
|
<span
|
|
title={item.step.title}
|
|
class="min-w-0 flex-1 leading-snug {open
|
|
? 'whitespace-normal'
|
|
: 'truncate'} {st === 'done'
|
|
? 'text-muted-foreground'
|
|
: st === 'running'
|
|
? 'font-medium text-foreground'
|
|
: 'text-muted-foreground'}"
|
|
>
|
|
{item.step.title}
|
|
</span>
|
|
{#if hhmm(item.ts)}
|
|
<span class="shrink-0 text-[9px] tabular-nums text-muted-foreground/60"
|
|
>{hhmm(item.ts)}</span
|
|
>
|
|
{/if}
|
|
{#if item.tools.length > 0}
|
|
<span class="shrink-0 text-muted-foreground/60">
|
|
{#if open}<ChevronDownIcon class="size-3" />{:else}<ChevronRightIcon
|
|
class="size-3"
|
|
/>{/if}
|
|
</span>
|
|
{/if}
|
|
</button>
|
|
|
|
{#if expandedWithTools}
|
|
<div transition:slide={{ duration: 150 }} class="flex flex-col">
|
|
{#each item.tools as tool (tool.id)}
|
|
{@const tOpen = expandedTools.has(tool.id) || !!tool.liveOutput}
|
|
<div class="relative" data-tl-id={tool.id}>
|
|
<!-- Branch stub: backbone → tool -->
|
|
<span
|
|
class="pointer-events-none absolute left-[17px] top-[9.5px] h-px w-[17px] {tool.status ===
|
|
'failed'
|
|
? 'bg-destructive/40'
|
|
: 'bg-border'}"
|
|
aria-hidden="true"
|
|
></span>
|
|
<button
|
|
type="button"
|
|
class="flex w-full items-center gap-1.5 py-1 pl-9 pr-3 text-left text-[11px] {tool.args ||
|
|
tool.detail ||
|
|
tool.liveOutput
|
|
? 'cursor-pointer hover:bg-muted/20'
|
|
: 'cursor-default'}"
|
|
onclick={() =>
|
|
(tool.args || tool.detail || tool.liveOutput) && toggleTool(tool.id)}
|
|
>
|
|
<span class="flex size-3 shrink-0 items-center justify-center">
|
|
{#if tool.status === 'running'}
|
|
<Spinner class="size-2.5 text-primary" />
|
|
{:else if tool.status === 'failed'}
|
|
<XIcon class="size-2.5 text-destructive" strokeWidth={3.5} />
|
|
{:else}
|
|
<CheckIcon class="size-2.5 text-primary/70" strokeWidth={3.5} />
|
|
{/if}
|
|
</span>
|
|
<span
|
|
title={tool.description}
|
|
class="min-w-0 flex-1 truncate leading-snug {tool.status === 'done'
|
|
? 'text-muted-foreground'
|
|
: tool.status === 'failed'
|
|
? 'text-destructive'
|
|
: 'text-foreground/80'}"
|
|
>
|
|
{tool.description}
|
|
</span>
|
|
{#if !tool.link}
|
|
<span class="shrink-0 text-[9px] tabular-nums text-muted-foreground/50"
|
|
>{hhmm(tool.timestamp)}</span
|
|
>
|
|
{/if}
|
|
</button>
|
|
{#if tool.link}
|
|
<button
|
|
type="button"
|
|
class="absolute right-1.5 top-1/2 z-10 flex size-5 -translate-y-1/2 items-center justify-center rounded text-muted-foreground/70 transition-colors hover:bg-muted/60 hover:text-foreground"
|
|
title="Open {tool.link.kind === 'knowledge' ? 'knowledge doc' : 'entity'}"
|
|
aria-label="Open {tool.link.kind === 'knowledge' ? 'knowledge doc' : 'entity'}"
|
|
onclick={() => openEntityWindow(tool.link!.slug)}
|
|
>
|
|
<ExternalLinkIcon class="size-3" />
|
|
</button>
|
|
{/if}
|
|
{#if tOpen}
|
|
<div
|
|
transition:slide={{ duration: 120 }}
|
|
class="flex flex-col gap-1 pb-1.5 pl-[52px] pr-3"
|
|
>
|
|
<div
|
|
class="flex items-center gap-1.5 text-[9px] text-muted-foreground/70"
|
|
>
|
|
<span class="capitalize">{tool.status}</span>
|
|
<span aria-hidden="true">·</span>
|
|
<span>{hhmmss(tool.timestamp)}</span>
|
|
{#if tool.toolName}<span aria-hidden="true">·</span><code
|
|
class="font-mono">{tool.toolName}</code
|
|
>{/if}
|
|
</div>
|
|
{#if tool.args}
|
|
<pre
|
|
class="max-h-36 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-1.5 font-mono text-[9px] leading-relaxed text-muted-foreground">{prettyPrint(
|
|
tool.args
|
|
)}</pre>
|
|
{/if}
|
|
{#if tool.liveOutput}
|
|
<!-- Streaming while the command runs. Bound so it
|
|
can be pinned to the tail as chunks arrive. -->
|
|
<pre
|
|
bind:this={liveOutputEls[tool.id]}
|
|
class="max-h-36 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-1.5 font-mono text-[9px] leading-relaxed text-muted-foreground">{tool.liveOutput}</pre>
|
|
{/if}
|
|
{#if tool.detail}
|
|
<pre
|
|
class="max-h-36 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-1.5 font-mono text-[9px] leading-relaxed {tool.status ===
|
|
'failed'
|
|
? 'text-destructive'
|
|
: 'text-muted-foreground'}">{prettyPrint(tool.detail)}</pre>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{:else}
|
|
<!-- Flat entry: milestone marker on the backbone -->
|
|
{@const e = item.entry}
|
|
{@const Icon = entryIcon(e)}
|
|
{@const eOpen = expandedTools.has(e.id)}
|
|
<div class="relative" data-tl-id={e.id}>
|
|
<span
|
|
class="pointer-events-none absolute left-[17px] w-px {segClass(
|
|
e.status,
|
|
isFirst,
|
|
isLast,
|
|
false
|
|
)}"
|
|
aria-hidden="true"
|
|
></span>
|
|
<button
|
|
type="button"
|
|
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] {e.args ||
|
|
e.detail
|
|
? 'cursor-pointer hover:bg-muted/30'
|
|
: 'cursor-default'}"
|
|
onclick={() => (e.args || e.detail) && toggleTool(e.id)}
|
|
>
|
|
<span
|
|
class="relative z-10 flex size-3.5 shrink-0 items-center justify-center rounded-full border bg-background
|
|
{e.status === 'failed'
|
|
? 'border-destructive text-destructive'
|
|
: e.status === 'running'
|
|
? 'border-primary text-primary'
|
|
: 'border-border text-primary'}"
|
|
>
|
|
{#if e.status === 'running'}
|
|
<Spinner class="size-2.5" />
|
|
{:else if e.status === 'failed'}
|
|
<XIcon class="size-2" strokeWidth={3.5} />
|
|
{:else}
|
|
<Icon class="size-2" strokeWidth={2.5} />
|
|
{/if}
|
|
</span>
|
|
<span
|
|
title={e.description}
|
|
class="min-w-0 flex-1 truncate leading-snug {e.status === 'done'
|
|
? 'text-muted-foreground'
|
|
: 'text-foreground/80'}"
|
|
>
|
|
{e.description}
|
|
</span>
|
|
{#if !e.link}
|
|
<span class="shrink-0 text-[9px] tabular-nums text-muted-foreground/60"
|
|
>{hhmm(e.timestamp)}</span
|
|
>
|
|
{/if}
|
|
</button>
|
|
{#if e.link}
|
|
<button
|
|
type="button"
|
|
class="absolute right-1.5 top-1/2 z-10 flex size-5 -translate-y-1/2 items-center justify-center rounded text-muted-foreground/70 transition-colors hover:bg-muted/60 hover:text-foreground"
|
|
title="Open {e.link.kind === 'knowledge' ? 'knowledge doc' : 'entity'}"
|
|
aria-label="Open {e.link.kind === 'knowledge' ? 'knowledge doc' : 'entity'}"
|
|
onclick={() => openEntityWindow(e.link!.slug)}
|
|
>
|
|
<ExternalLinkIcon class="size-3" />
|
|
</button>
|
|
{/if}
|
|
{#if eOpen}
|
|
<div
|
|
transition:slide={{ duration: 120 }}
|
|
class="flex flex-col gap-1 pb-1.5 pl-9 pr-3"
|
|
>
|
|
<div class="flex items-center gap-1.5 text-[9px] text-muted-foreground/70">
|
|
<span class="capitalize">{e.status}</span>
|
|
<span aria-hidden="true">·</span>
|
|
<span>{hhmmss(e.timestamp)}</span>
|
|
{#if e.toolName}<span aria-hidden="true">·</span><code class="font-mono"
|
|
>{e.toolName}</code
|
|
>{/if}
|
|
</div>
|
|
{#if e.args}
|
|
<pre
|
|
class="max-h-36 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-1.5 font-mono text-[9px] leading-relaxed text-muted-foreground">{prettyPrint(
|
|
e.args
|
|
)}</pre>
|
|
{/if}
|
|
{#if e.detail}
|
|
<pre
|
|
class="max-h-36 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-1.5 font-mono text-[9px] leading-relaxed {e.status ===
|
|
'failed'
|
|
? 'text-destructive'
|
|
: 'text-muted-foreground'}">{prettyPrint(e.detail)}</pre>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|