feat(web+nomos): fix chat streaming reactivity, unified activity timeline, tool cards in chat
- fix(web): Svelte 5 identity-based reactivity broke text_delta streaming — immutable message objects in all three chat handlers so text streams live - feat(web): streaming cursor + inline status indicator merged into message flow - feat(web): expandable inline tool call cards in chat thread - feat(web): merge Plan + Event log into one backbone Activity timeline — filled status nodes, branch stubs, auto-scroll follow mode, per-session activityLog, compact for the rail - fix(nomos): add X-Accel-Buffering:no to /chat SSE (proxy buffering) - fix(nomos): plan step auto-close SQL param bug (store.go) - polish: timestamps, role labels, code copy button, table overflow, min window size, delete AgentIndicator/ActivityTimeline dead code
This commit is contained in:
@@ -1,154 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { ActivityEntry } from '$lib/stores/activity'
|
||||
import Spinner from './Spinner.svelte'
|
||||
import CircleDotIcon from '@lucide/svelte/icons/circle-dot'
|
||||
import CircleXIcon from '@lucide/svelte/icons/circle-x'
|
||||
import MilestoneIcon from '@lucide/svelte/icons/milestone'
|
||||
import ListTodoIcon from '@lucide/svelte/icons/list-todo'
|
||||
import SparklesIcon from '@lucide/svelte/icons/sparkles'
|
||||
import HelpCircleIcon from '@lucide/svelte/icons/help-circle'
|
||||
import FlagIcon from '@lucide/svelte/icons/flag'
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
|
||||
|
||||
// Prop-driven (not store-imported) — see SessionGraph.svelte for why.
|
||||
let { entries }: { entries: ActivityEntry[] } = $props()
|
||||
|
||||
let expanded = $state(new Set<string>())
|
||||
|
||||
function toggle(id: string) {
|
||||
if (expanded.has(id)) expanded.delete(id)
|
||||
else expanded.add(id)
|
||||
expanded = new Set(expanded)
|
||||
}
|
||||
|
||||
function typeIcon(type: ActivityEntry['type']) {
|
||||
switch (type) {
|
||||
case 'goal': return MilestoneIcon
|
||||
case 'plan': return ListTodoIcon
|
||||
case 'step_running': case 'step_done': case 'step_failed':
|
||||
case 'tool_running': case 'tool_done': case 'tool_error':
|
||||
return null // use status icon instead
|
||||
case 'knowledge': return SparklesIcon
|
||||
case 'complete': return FlagIcon
|
||||
case 'question': return HelpCircleIcon
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
function statusColor(status: ActivityEntry['status']) {
|
||||
if (status === 'failed') return 'text-destructive'
|
||||
return 'text-primary'
|
||||
}
|
||||
|
||||
// Tool results often arrive as a JSON string — pretty-print it when it
|
||||
// parses, otherwise fall back to the raw text rather than hiding it.
|
||||
function prettyPrint(raw: string): string {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(raw), null, 2)
|
||||
} catch {
|
||||
return raw
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(ts: number): string | null {
|
||||
if (!ts) return null
|
||||
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col">
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
{#if entries.length === 0}
|
||||
<div class="flex flex-col items-center gap-3 px-3 py-8 text-center">
|
||||
<svg viewBox="0 0 64 110" class="h-20 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="max-w-[12rem] text-xs leading-relaxed text-muted-foreground">Waiting for activity…</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col py-1">
|
||||
{#each entries as entry, i (entry.id)}
|
||||
{@const isLast = i === entries.length - 1}
|
||||
{@const icon = typeIcon(entry.type)}
|
||||
{@const isOpen = expanded.has(entry.id)}
|
||||
{@const time = formatTime(entry.timestamp)}
|
||||
<div class="relative">
|
||||
<!-- connector line -->
|
||||
{#if !isLast}
|
||||
<div class="absolute left-[17px] top-6 bottom-0 w-px bg-border"></div>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-start gap-2 {entry.indent ? 'pl-7' : 'px-3'} py-1.5 text-left text-xs hover:bg-muted/30 cursor-pointer"
|
||||
onclick={() => toggle(entry.id)}
|
||||
>
|
||||
<!-- status icon -->
|
||||
<span class="relative mt-0.5 flex size-3.5 shrink-0 items-center justify-center rounded-full {statusColor(entry.status)}">
|
||||
{#if entry.status === 'running'}
|
||||
<Spinner class="size-3.5" />
|
||||
{:else if entry.status === 'failed'}
|
||||
<CircleXIcon class="size-3.5" />
|
||||
{:else if icon}
|
||||
{@const IconComp = icon}
|
||||
<IconComp class="size-3" />
|
||||
{:else}
|
||||
<CircleDotIcon class="size-3" />
|
||||
{/if}
|
||||
</span>
|
||||
<!-- description -->
|
||||
<span class="min-w-0 flex-1 leading-snug {entry.status === 'done' ? 'text-muted-foreground' : ''}">
|
||||
{entry.description}
|
||||
</span>
|
||||
<span class="mt-0.5 shrink-0 text-muted-foreground">
|
||||
{#if isOpen}
|
||||
<ChevronDownIcon class="size-3" />
|
||||
{:else}
|
||||
<ChevronRightIcon class="size-3" />
|
||||
{/if}
|
||||
</span>
|
||||
</button>
|
||||
<!-- detail -->
|
||||
{#if isOpen}
|
||||
<div class="flex flex-col gap-1.5 pl-8 pr-3 pb-2">
|
||||
<div class="flex items-center gap-2 text-[10px] text-muted-foreground">
|
||||
<span class="capitalize">{entry.status}</span>
|
||||
{#if time}<span aria-hidden="true">·</span><span>{time}</span>{/if}
|
||||
{#if entry.toolName}<span aria-hidden="true">·</span><code class="font-mono">{entry.toolName}</code>{/if}
|
||||
</div>
|
||||
{#if entry.args}
|
||||
<div>
|
||||
<p class="mb-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground/70">Called with</p>
|
||||
<pre class="overflow-x-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-2 font-mono text-[10px] leading-relaxed text-muted-foreground">{prettyPrint(entry.args)}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
{#if entry.detail}
|
||||
<div>
|
||||
{#if entry.args}<p class="mb-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground/70">{entry.status === 'failed' ? 'Error' : 'Result'}</p>{/if}
|
||||
<pre class="overflow-x-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-2 font-mono text-[10px] leading-relaxed text-muted-foreground">{prettyPrint(entry.detail)}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
{#if !entry.args && !entry.detail}
|
||||
<p class="text-[10px] text-muted-foreground/70">No further detail for this step.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,42 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { ActivityEntry } from '$lib/stores/activity'
|
||||
import Spinner from './Spinner.svelte'
|
||||
import CheckIcon from '@lucide/svelte/icons/check'
|
||||
import XIcon from '@lucide/svelte/icons/x'
|
||||
|
||||
let { active = false, lastActivity = null as ActivityEntry | null, error = '' }: { active?: boolean; lastActivity?: ActivityEntry | null; error?: string } = $props()
|
||||
|
||||
let done = $state(false)
|
||||
let wasActive = $state(false)
|
||||
|
||||
$effect(() => {
|
||||
if (active) { done = false; wasActive = true }
|
||||
if (!active && wasActive) {
|
||||
done = true
|
||||
const t = setTimeout(() => { done = false; wasActive = false }, 3000)
|
||||
return () => clearTimeout(t)
|
||||
}
|
||||
})
|
||||
|
||||
const label = $derived.by(() => {
|
||||
if (error) return error
|
||||
if (!active && done) return 'Done'
|
||||
if (lastActivity) return lastActivity.description
|
||||
return 'Agent is thinking…'
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if active || done || error}
|
||||
<div class="flex items-center gap-2 py-2 text-xs transition-opacity {error ? 'text-destructive' : done ? 'text-success opacity-50' : 'text-muted-foreground'}">
|
||||
<span class="shrink-0">
|
||||
{#if error}
|
||||
<XIcon class="size-3" />
|
||||
{:else if done}
|
||||
<CheckIcon class="size-3" />
|
||||
{:else}
|
||||
<Spinner class="size-3 text-primary" />
|
||||
{/if}
|
||||
</span>
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -5,11 +5,15 @@
|
||||
// through this, so the message-bubble/markdown styling lives in one place
|
||||
// instead of being copy-pasted between the two.
|
||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||
import { activityLog } from '$lib/stores/activity'
|
||||
import AgentIndicator from '$lib/components/AgentIndicator.svelte'
|
||||
import { activityLog, type ActivityEntry } from '$lib/stores/activity'
|
||||
import type { Readable } from 'svelte/store'
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import { Textarea } from '$lib/components/ui/textarea'
|
||||
import Spinner from './Spinner.svelte'
|
||||
import ToolCallCard from './ToolCallCard.svelte'
|
||||
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 SquareIcon from '@lucide/svelte/icons/square'
|
||||
import { marked } from 'marked'
|
||||
@@ -26,7 +30,8 @@
|
||||
onCancel,
|
||||
onReconnect,
|
||||
onDismissError,
|
||||
suggestions = []
|
||||
suggestions = [],
|
||||
activityLog: activityLogProp = activityLog
|
||||
}: {
|
||||
messages: ChatMessage[]
|
||||
streaming: boolean
|
||||
@@ -38,6 +43,7 @@
|
||||
onReconnect: () => void
|
||||
onDismissError: (id: string) => void
|
||||
suggestions?: string[]
|
||||
activityLog?: Readable<ActivityEntry[]>
|
||||
} = $props()
|
||||
|
||||
let input = $state('')
|
||||
@@ -45,6 +51,26 @@
|
||||
let scrolledUp = $state(false)
|
||||
let container = $state<HTMLDivElement | null>(null)
|
||||
|
||||
let indicatorDone = $state(false)
|
||||
let wasStreaming = $state(false)
|
||||
|
||||
$effect(() => {
|
||||
if (streaming) { indicatorDone = false; wasStreaming = true }
|
||||
if (!streaming && wasStreaming) {
|
||||
indicatorDone = true
|
||||
const t = setTimeout(() => { indicatorDone = false; wasStreaming = false }, 3000)
|
||||
return () => clearTimeout(t)
|
||||
}
|
||||
})
|
||||
|
||||
const indicatorLabel = $derived.by(() => {
|
||||
if (error) return error
|
||||
if (!streaming && indicatorDone) return 'Done'
|
||||
const running = $activityLogProp.find((e: ActivityEntry) => e.status === 'running')
|
||||
if (running) return running.description
|
||||
return 'Agent is thinking…'
|
||||
})
|
||||
|
||||
// Resizable input area — drag the splitter above it to grow the textarea,
|
||||
// capped so it can't swallow the whole thread. Both the minimum and the
|
||||
// default are exactly one line: measured from the textarea's own
|
||||
@@ -102,7 +128,26 @@
|
||||
})
|
||||
|
||||
function render(text: string): string {
|
||||
return DOMPurify.sanitize(marked.parse(text, { async: false }) as string)
|
||||
const renderer = new marked.Renderer()
|
||||
renderer.code = function ({ text, lang }) {
|
||||
const escaped = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
return `<div class="code-block-wrapper relative group"><pre><code class="language-${lang || 'plaintext'}">${escaped}</code></pre><button class="code-copy-btn" onclick="navigator.clipboard.writeText(this.parentElement.querySelector('pre code').textContent)" title="Copy" aria-label="Copy code"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button></div>`
|
||||
}
|
||||
renderer.table = function (token) {
|
||||
const header = token.header.map((c: { text: string }) => `<th>${c.text}</th>`).join('')
|
||||
const body = token.rows.map((r: { text: string }[]) => `<tr>${r.map((c) => `<td>${c.text}</td>`).join('')}</tr>`).join('')
|
||||
return `<div class="table-wrapper"><table><thead><tr>${header}</tr></thead><tbody>${body}</tbody></table></div>`
|
||||
}
|
||||
return DOMPurify.sanitize(marked.parse(text, { async: false, renderer }) as string)
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function submit() {
|
||||
@@ -149,27 +194,56 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#each messages as msg (msg.id)}
|
||||
{#each messages as msg, idx (msg.id)}
|
||||
<div class="flex flex-col gap-1.5 {msg.role === 'user' ? 'items-end' : 'items-start'}">
|
||||
{#if msg.role === 'user'}
|
||||
<div class="flex items-baseline gap-2 px-1">
|
||||
<span class="text-[10px] font-medium text-muted-foreground/70">You</span>
|
||||
{#if msg.created_at}
|
||||
<span class="text-[9px] text-muted-foreground/50">{formatTime(msg.created_at)}</span>
|
||||
{/if}
|
||||
</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}
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<div class="flex items-baseline gap-2 px-1">
|
||||
<span class="text-[10px] font-medium text-muted-foreground/70">Nomos</span>
|
||||
{#if msg.created_at}
|
||||
<span class="text-[9px] text-muted-foreground/50">{formatTime(msg.created_at)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if msg.text}
|
||||
<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 -->
|
||||
{@html render(msg.text)}
|
||||
{#if idx === messages.length - 1 && streaming}
|
||||
<span class="stream-cursor" aria-hidden="true"></span>
|
||||
{/if}
|
||||
</div>
|
||||
{/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-success/50' : '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>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
<AgentIndicator
|
||||
active={streaming || $activityLog.some((e) => e.status === 'running')}
|
||||
lastActivity={$activityLog.find((e) => e.status === 'running') ?? null}
|
||||
{error}
|
||||
/>
|
||||
<div bind:this={messagesEnd}></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -269,6 +343,7 @@
|
||||
/* User message — soft terracotta bubble, gentle lift */
|
||||
.user-msg {
|
||||
box-shadow: 0 1px 8px -4px var(--primary);
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Prose overrides */
|
||||
@@ -337,12 +412,26 @@
|
||||
|
||||
/* Section headings — serif (Inknut) with a short accent rule. Extra top
|
||||
margin separates sections; the first heading in a message doesn't. */
|
||||
.prose-chat :global(h1),
|
||||
.prose-chat :global(h2),
|
||||
.prose-chat :global(h3) {
|
||||
.prose-chat :global(h1) {
|
||||
font-size: 1.15em;
|
||||
font-weight: 600;
|
||||
margin: 1.15rem 0 0.4rem;
|
||||
letter-spacing: 0.01em;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
.prose-chat :global(h2) {
|
||||
font-size: 1.08em;
|
||||
font-weight: 600;
|
||||
margin: 1.15rem 0 0.4rem;
|
||||
letter-spacing: 0.01em;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
.prose-chat :global(h3) {
|
||||
font-size: 1.02em;
|
||||
font-weight: 600;
|
||||
margin: 1.15rem 0 0.4rem;
|
||||
font-size: 1.03em;
|
||||
letter-spacing: 0.01em;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
@@ -370,6 +459,13 @@
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.prose-chat :global(.table-wrapper) {
|
||||
overflow-x: auto;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
.prose-chat :global(.table-wrapper table) {
|
||||
margin: 0;
|
||||
}
|
||||
.prose-chat :global(th) {
|
||||
background: var(--muted);
|
||||
font-weight: 600;
|
||||
@@ -433,4 +529,51 @@
|
||||
background: linear-gradient(to right, transparent, var(--primary), transparent);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
/* Code copy button — global: injected via render() into {html} blocks */
|
||||
.prose-chat :global(.code-block-wrapper) {
|
||||
position: relative;
|
||||
}
|
||||
.prose-chat :global(.code-copy-btn) {
|
||||
position: absolute;
|
||||
top: 0.375rem;
|
||||
right: 0.375rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border-radius: 0.375rem;
|
||||
color: var(--muted-foreground);
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, color 0.15s;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
.prose-chat :global(.code-block-wrapper:hover .code-copy-btn) {
|
||||
opacity: 1;
|
||||
}
|
||||
.prose-chat :global(.code-copy-btn:hover) {
|
||||
color: var(--foreground);
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
/* Streaming cursor — blinking block appended after streaming text */
|
||||
.stream-cursor {
|
||||
display: inline-block;
|
||||
width: 0.55em;
|
||||
height: 1.1em;
|
||||
background: var(--primary);
|
||||
opacity: 0.75;
|
||||
border-radius: 1px;
|
||||
margin-left: 1px;
|
||||
vertical-align: text-bottom;
|
||||
animation: cursor-blink 0.9s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes cursor-blink {
|
||||
0%, 100% { opacity: 0.75; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
<script lang="ts">
|
||||
// Floating-window content for a task/session — the per-window counterpart
|
||||
// to the main Chat page (thread + rail), fully self-contained per
|
||||
// Floating-window content for a task/session — self-contained per
|
||||
// sessionId via chat.ts's chatFor()/loadSessionChat()/sendSessionMessage()
|
||||
// and workspace.ts's workspaceFor()/startSessionWorkspace(), so several of
|
||||
// these can be open (and independently live) at once without the "which
|
||||
// one's on screen" guarding the main page's singleton stores need.
|
||||
// these can be open (and independently live) at once.
|
||||
import { onDestroy, onMount } from 'svelte'
|
||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||
import { chatFor, loadSessionChat, sendSessionMessage, cancelSessionStream, stopSessionPolling, dismissError, chatErrors } from '$lib/stores/chat'
|
||||
import { activityLogFor } from '$lib/stores/activity'
|
||||
import ChatThread from '$lib/components/ChatThread.svelte'
|
||||
import TaskContextPanel from '$lib/components/TaskContextPanel.svelte'
|
||||
|
||||
@@ -16,12 +15,17 @@
|
||||
// Svelte's `$store` auto-subscription only works on a plain identifier
|
||||
// bound directly to a store, not a member expression — chatFor() returns
|
||||
// an object of stores, so pull each one out into its own identifier here.
|
||||
// sessionId is a stable prop (one per window mount, never changes), so
|
||||
// capturing it at init is safe and intended.
|
||||
// eslint-disable-next-line svelte/valid-compile
|
||||
const chat = chatFor(sessionId)
|
||||
const chatMessages = chat.messages
|
||||
const chatStreaming = chat.streaming
|
||||
const chatConnectionState = chat.connectionState
|
||||
const chatError = chat.error
|
||||
const chatNotFound = chat.notFound
|
||||
// eslint-disable-next-line svelte/valid-compile
|
||||
const sessionActivityLog = activityLogFor(sessionId)
|
||||
let loading = $state(true)
|
||||
|
||||
onMount(async () => {
|
||||
@@ -53,6 +57,7 @@
|
||||
connectionState={$chatConnectionState}
|
||||
error={$chatError}
|
||||
chatErrors={$chatErrors}
|
||||
activityLog={sessionActivityLog}
|
||||
onSend={(text) => sendSessionMessage(sessionId, text)}
|
||||
onCancel={() => cancelSessionStream(sessionId)}
|
||||
onReconnect={() => loadSessionChat(sessionId)}
|
||||
|
||||
@@ -6,16 +6,10 @@
|
||||
import { activityLog, activityLogFor } from '$lib/stores/activity'
|
||||
import OperatorQuestion from './OperatorQuestion.svelte'
|
||||
import SessionGraph from './SessionGraph.svelte'
|
||||
import ActivityTimeline from './ActivityTimeline.svelte'
|
||||
import UnifiedTimeline from './UnifiedTimeline.svelte'
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
|
||||
import MilestoneIcon from '@lucide/svelte/icons/milestone'
|
||||
import Spinner from './Spinner.svelte'
|
||||
import CircleIcon from '@lucide/svelte/icons/circle'
|
||||
import CircleCheckIcon from '@lucide/svelte/icons/circle-check'
|
||||
import CircleXIcon from '@lucide/svelte/icons/circle-x'
|
||||
import CircleSlashIcon from '@lucide/svelte/icons/circle-slash'
|
||||
import CirclePauseIcon from '@lucide/svelte/icons/circle-pause'
|
||||
|
||||
// Omitted (main Chat page): tracks the global "current session" — one
|
||||
// shared view, same as always. Passed (a floating task window's
|
||||
@@ -42,7 +36,6 @@
|
||||
const effectiveSessionId = $derived(sessionId ?? $currentSession)
|
||||
|
||||
let scopeOpen = $state(true)
|
||||
let planOpen = $state(true)
|
||||
let activityOpen = $state(true)
|
||||
|
||||
// Resize: each section is a Pane in one vertical Splitpanes. Sizes are
|
||||
@@ -52,12 +45,12 @@
|
||||
// last size so reopening restores it.
|
||||
const COLLAPSED_SIZE = 6
|
||||
const OPEN_MIN_SIZE = 12
|
||||
let sizes = $state<(number | undefined)[]>([undefined, undefined, undefined])
|
||||
let sizes = $state<(number | undefined)[]>([undefined, undefined])
|
||||
// Reopening must restore a concrete number, never `undefined` — the pane
|
||||
// only re-triggers the library's resize/equalize pass when `size` changes
|
||||
// to a different *number*, so setting it back to `undefined` silently
|
||||
// no-ops and leaves the section stuck at its collapsed height.
|
||||
let savedSizes: number[] = [34, 33, 33]
|
||||
let savedSizes: number[] = [34, 66]
|
||||
|
||||
function toggleSection(i: number, isOpen: boolean) {
|
||||
if (isOpen) {
|
||||
@@ -71,22 +64,9 @@
|
||||
// Plan collapsed status
|
||||
const planDone = $derived($planStepsStore.filter((s) => s.status === 'done').length)
|
||||
const planTotal = $derived($planStepsStore.length)
|
||||
const planPct = $derived(planTotal > 0 ? Math.round((planDone / planTotal) * 100) : 0)
|
||||
|
||||
// When there are no plan steps, the empty state depends on WHY: a task that's
|
||||
// actively planning (or streaming its first turn) is genuinely waiting for one,
|
||||
// but a finished task that never planned (a read-only lookup, a direct answer)
|
||||
// will never get one — a perpetual "Awaiting plan…" there is misleading.
|
||||
const planPhase = $derived.by<'drafting' | 'none' | 'idle'>(() => {
|
||||
const st = $taskStore?.status
|
||||
if (st === 'done' || st === 'failed' || st === 'abandoned') return 'none'
|
||||
if (st === 'planning' || $streamingStore) return 'drafting'
|
||||
return 'idle'
|
||||
})
|
||||
|
||||
// Activity collapsed status
|
||||
const activityRunning = $derived($activityLogStore.filter((e) => e.status === 'running').length)
|
||||
const activityCount = $derived($activityLogStore.length)
|
||||
</script>
|
||||
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
@@ -116,150 +96,35 @@
|
||||
{/if}
|
||||
</Pane>
|
||||
|
||||
<!-- Plan -->
|
||||
<Pane bind:size={sizes[1]} minSize={planOpen ? OPEN_MIN_SIZE : COLLAPSED_SIZE} maxSize={planOpen ? 100 : COLLAPSED_SIZE} class="flex flex-col">
|
||||
<!-- Activity (merged plan + event log) -->
|
||||
<Pane bind:size={sizes[1]} minSize={activityOpen ? OPEN_MIN_SIZE : COLLAPSED_SIZE} maxSize={activityOpen ? 100 : COLLAPSED_SIZE} class="flex flex-col">
|
||||
<button
|
||||
type="button"
|
||||
class="flex shrink-0 items-center gap-1.5 px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground"
|
||||
onclick={() => {
|
||||
toggleSection(1, planOpen)
|
||||
planOpen = !planOpen
|
||||
}}
|
||||
>
|
||||
{#if planOpen}<ChevronDownIcon class="size-3" />{:else}<ChevronRightIcon class="size-3" />{/if}
|
||||
<span>Plan</span>
|
||||
{#if !planOpen}
|
||||
{#if planTotal > 0}
|
||||
<span class="ml-auto font-normal normal-case">Step {planDone}/{planTotal}</span>
|
||||
{:else if $taskStore?.goal}
|
||||
<span class="ml-auto max-w-[120px] truncate font-normal normal-case">{$taskStore.goal}</span>
|
||||
{:else}
|
||||
<span class="ml-auto font-normal normal-case text-muted-foreground">No plan yet</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</button>
|
||||
{#if planOpen}
|
||||
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||
{#if $taskStore?.goal}
|
||||
<div class="flex items-start gap-2 px-3 py-2">
|
||||
<MilestoneIcon class="mt-0.5 size-3 shrink-0 text-primary" />
|
||||
<span class="text-xs leading-snug text-foreground/90">{$taskStore.goal}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if planTotal > 0}
|
||||
<div class="px-3 pb-2.5">
|
||||
<div class="mb-1.5 flex items-baseline justify-between text-[11px]">
|
||||
<span class="font-medium text-foreground">{planDone} of {planTotal} done</span>
|
||||
<span class="tabular-nums text-muted-foreground">{planPct}%</span>
|
||||
</div>
|
||||
<div class="h-1.5 overflow-hidden rounded-full bg-muted">
|
||||
<div class="h-full rounded-full bg-primary transition-all duration-500 ease-out" style="width: {planPct}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<ol class="flex flex-col overflow-y-auto px-2 pb-2 text-[11px]">
|
||||
{#each $planStepsStore as step, i (step.id)}
|
||||
{@const isDone = step.status === 'done'}
|
||||
{@const isRunning = step.status === 'running'}
|
||||
<li class="relative flex items-start gap-2.5 rounded-md px-2 py-1.5 transition-colors {isRunning ? 'bg-primary/5' : ''}">
|
||||
{#if i < $planStepsStore.length - 1}
|
||||
<span class="pointer-events-none absolute bottom-[-2px] left-[13.5px] top-[22px] w-px bg-border" aria-hidden="true"></span>
|
||||
{/if}
|
||||
<span class="relative z-10 mt-px flex size-3.5 shrink-0 items-center justify-center rounded-full bg-background">
|
||||
{#if isDone}
|
||||
<CircleCheckIcon class="size-3.5 text-primary" />
|
||||
{:else if isRunning}
|
||||
<Spinner class="size-3.5 text-primary" />
|
||||
{:else if step.status === 'failed'}
|
||||
<CircleXIcon class="size-3.5 text-destructive" />
|
||||
{:else if step.status === 'blocked'}
|
||||
<CirclePauseIcon class="size-3.5 text-warning" />
|
||||
{:else if step.status === 'skipped' || step.status === 'replaced'}
|
||||
<CircleSlashIcon class="size-3.5 text-muted-foreground" />
|
||||
{:else}
|
||||
<CircleIcon class="size-3.5 text-muted-foreground/40" />
|
||||
{/if}
|
||||
</span>
|
||||
<span
|
||||
class="min-w-0 flex-1 leading-snug {isDone
|
||||
? 'text-muted-foreground line-through decoration-muted-foreground/40'
|
||||
: isRunning
|
||||
? 'font-medium text-foreground'
|
||||
: 'text-muted-foreground'}"
|
||||
>{step.title}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{:else if planPhase === 'drafting'}
|
||||
<div class="flex flex-col items-center gap-3 px-3 py-6 text-center">
|
||||
<svg viewBox="0 0 140 88" class="h-16 w-auto text-primary" fill="none">
|
||||
<g stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
|
||||
<circle cx="16" cy="20" r="4.5" fill="currentColor">
|
||||
<animate attributeName="opacity" values="0.35;1;0.35" dur="1.3s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
<line x1="30" y1="20" x2="124" y2="20" opacity="0.4">
|
||||
<animate attributeName="opacity" values="0.15;0.5;0.15" dur="1.3s" repeatCount="indefinite" />
|
||||
</line>
|
||||
<circle cx="16" cy="44" r="4.5" fill="currentColor">
|
||||
<animate attributeName="opacity" values="0.35;1;0.35" dur="1.3s" begin="0.25s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
<line x1="30" y1="44" x2="102" y2="44" opacity="0.4">
|
||||
<animate attributeName="opacity" values="0.15;0.5;0.15" dur="1.3s" begin="0.25s" repeatCount="indefinite" />
|
||||
</line>
|
||||
<circle cx="16" cy="68" r="4.5" fill="currentColor">
|
||||
<animate attributeName="opacity" values="0.35;1;0.35" dur="1.3s" begin="0.5s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
<line x1="30" y1="68" x2="80" y2="68" opacity="0.4">
|
||||
<animate attributeName="opacity" values="0.15;0.5;0.15" dur="1.3s" begin="0.5s" repeatCount="indefinite" />
|
||||
</line>
|
||||
</g>
|
||||
</svg>
|
||||
<p class="text-xs text-muted-foreground">Drafting a plan…</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col items-center gap-3 px-3 py-6 text-center">
|
||||
<svg viewBox="0 0 140 88" class="h-16 w-auto text-muted-foreground/40" fill="none">
|
||||
<g stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
|
||||
<circle cx="16" cy="20" r="4.5" fill="currentColor" opacity="0.7" />
|
||||
<line x1="30" y1="20" x2="124" y2="20" opacity="0.35" />
|
||||
<circle cx="16" cy="44" r="4.5" fill="currentColor" opacity="0.45" />
|
||||
<line x1="30" y1="44" x2="102" y2="44" opacity="0.25" />
|
||||
<circle cx="16" cy="68" r="4.5" fill="none" opacity="0.3" />
|
||||
<line x1="30" y1="68" x2="80" y2="68" opacity="0.15" stroke-dasharray="2.5 3.5" />
|
||||
</g>
|
||||
</svg>
|
||||
<p class="max-w-[14rem] text-xs leading-relaxed text-muted-foreground">
|
||||
{planPhase === 'none' ? 'Handled directly — no plan needed' : 'No plan for this task yet'}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</Pane>
|
||||
|
||||
<!-- Activity -->
|
||||
<Pane bind:size={sizes[2]} minSize={activityOpen ? OPEN_MIN_SIZE : COLLAPSED_SIZE} maxSize={activityOpen ? 100 : COLLAPSED_SIZE} class="flex flex-col">
|
||||
<button
|
||||
type="button"
|
||||
class="flex shrink-0 items-center gap-1.5 px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground"
|
||||
onclick={() => {
|
||||
toggleSection(2, activityOpen)
|
||||
toggleSection(1, activityOpen)
|
||||
activityOpen = !activityOpen
|
||||
}}
|
||||
>
|
||||
{#if activityOpen}<ChevronDownIcon class="size-3" />{:else}<ChevronRightIcon class="size-3" />{/if}
|
||||
<span>Event log</span>
|
||||
{#if !activityOpen}
|
||||
{#if $streamingStore && activityRunning > 0}
|
||||
<Spinner class="size-3 text-primary" />
|
||||
<span class="font-normal normal-case text-primary">{activityRunning} running</span>
|
||||
<span>Activity</span>
|
||||
{#if $streamingStore && activityRunning > 0}
|
||||
<Spinner class="size-3 text-primary" />
|
||||
{/if}
|
||||
{#if planTotal > 0}
|
||||
<span class="font-normal normal-case tabular-nums {planDone === planTotal ? 'text-muted-foreground' : 'text-primary'}">{planDone}/{planTotal}</span>
|
||||
{/if}
|
||||
{#if !activityOpen && planTotal === 0}
|
||||
{#if $taskStore?.goal}
|
||||
<span class="ml-auto max-w-[120px] truncate font-normal normal-case">{$taskStore.goal}</span>
|
||||
{:else}
|
||||
<span class="ml-auto font-normal normal-case">{activityCount || '—'} action{activityCount === 1 ? '' : 's'}</span>
|
||||
<span class="ml-auto font-normal normal-case text-muted-foreground">No activity yet</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</button>
|
||||
{#if activityOpen}
|
||||
<div class="min-h-0 flex-1 overflow-hidden">
|
||||
<ActivityTimeline entries={$activityLogStore} />
|
||||
<UnifiedTimeline entries={$activityLogStore} planSteps={$planStepsStore} streaming={$streamingStore} />
|
||||
</div>
|
||||
{/if}
|
||||
</Pane>
|
||||
|
||||
85
web/src/lib/components/ToolCallCard.svelte
Normal file
85
web/src/lib/components/ToolCallCard.svelte
Normal file
@@ -0,0 +1,85 @@
|
||||
<script lang="ts">
|
||||
import { Check, ChevronRight, Loader2, Wrench, X } from '@lucide/svelte'
|
||||
import type { ToolCallResult } from '$lib/types'
|
||||
|
||||
let { tool }: { tool: ToolCallResult } = $props()
|
||||
let expanded = $state(false)
|
||||
|
||||
const status = $derived.by(() => {
|
||||
if (tool.type === 'tool_use') return 'running'
|
||||
if (tool.error) return 'error'
|
||||
return 'done'
|
||||
})
|
||||
|
||||
const statusColor = $derived.by(() => {
|
||||
if (status === 'running') return 'text-primary'
|
||||
if (status === 'error') return 'text-destructive'
|
||||
return 'text-success/60'
|
||||
})
|
||||
|
||||
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}`
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="tool-card rounded-lg border border-border/60 bg-card/40 overflow-hidden transition-all">
|
||||
<button
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-muted/40 transition-colors"
|
||||
onclick={() => (expanded = !expanded)}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
<ChevronRight class="size-3 shrink-0 text-muted-foreground transition-transform {expanded ? 'rotate-90' : ''}" />
|
||||
<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'}
|
||||
<Loader2 class="size-3.5 animate-spin" />
|
||||
{:else if status === 'error'}
|
||||
<X class="size-3.5" />
|
||||
{:else}
|
||||
<Check class="size-3.5" />
|
||||
{/if}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{#if expanded}
|
||||
<div class="border-t border-border/40 px-3 py-2 space-y-2">
|
||||
{#if tool.args}
|
||||
<div>
|
||||
<div class="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground mb-1">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>
|
||||
</div>
|
||||
{/if}
|
||||
{#if tool.result !== undefined && tool.result !== null}
|
||||
<div>
|
||||
<div class="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground mb-1">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>
|
||||
</div>
|
||||
{/if}
|
||||
{#if tool.error}
|
||||
<div>
|
||||
<div class="text-[10px] font-semibold uppercase tracking-wider text-destructive mb-1">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>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</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>
|
||||
350
web/src/lib/components/UnifiedTimeline.svelte
Normal file
350
web/src/lib/components/UnifiedTimeline.svelte
Normal file
@@ -0,0 +1,350 @@
|
||||
<script lang="ts">
|
||||
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'
|
||||
|
||||
// Merged plan + activity timeline, designed for the narrow rail:
|
||||
// - 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 up, 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)
|
||||
}
|
||||
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.
|
||||
if (steps.length > 0) {
|
||||
out.push({ kind: 'step', step: s, tools: [], ts: Number.MAX_SAFE_INTEGER - s.seq })
|
||||
}
|
||||
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)
|
||||
const ts = stepEntry?.timestamp ?? tools[0]?.timestamp ?? Date.now()
|
||||
out.push({ kind: 'step', step: s, tools, 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 })
|
||||
}
|
||||
|
||||
out.sort((a, b) => a.ts - b.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)
|
||||
|
||||
function onScroll() {
|
||||
if (!container) return
|
||||
follow = container.scrollHeight - container.scrollTop - container.clientHeight < 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 = container.scrollHeight
|
||||
})
|
||||
|
||||
// ── 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)}
|
||||
<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) ? 'cursor-pointer hover:bg-muted/20' : 'cursor-default'}"
|
||||
onclick={() => (tool.args || tool.detail) && 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>
|
||||
<span class="shrink-0 text-[9px] tabular-nums text-muted-foreground/50">{hhmm(tool.timestamp)}</span>
|
||||
</button>
|
||||
{#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.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>
|
||||
<span class="shrink-0 text-[9px] tabular-nums text-muted-foreground/60">{hhmm(e.timestamp)}</span>
|
||||
</button>
|
||||
{#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>
|
||||
@@ -20,6 +20,7 @@ export interface ChatMessage {
|
||||
text: string
|
||||
tools: ToolCallResult[]
|
||||
pendingApprovals: PendingApproval[]
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
const APPROVAL_RE = /execution\s+([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i
|
||||
@@ -122,7 +123,8 @@ function toChatMessages(msgs: Message[]): ChatMessage[] {
|
||||
role: m.role as 'user' | 'assistant',
|
||||
text: content?.text ?? '',
|
||||
tools,
|
||||
pendingApprovals: extractApprovals(tools)
|
||||
pendingApprovals: extractApprovals(tools),
|
||||
created_at: m.created_at
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -209,7 +211,7 @@ export function sendMessage(text: string) {
|
||||
}
|
||||
messages.update((ms) => [...ms, assistantMsg])
|
||||
|
||||
let activeTools: Map<string, ToolCallResult> = new Map()
|
||||
const activeTools: Map<string, ToolCallResult> = new Map()
|
||||
|
||||
// Multiple tasks can stream concurrently (the backend runs each turn as its
|
||||
// own goroutine — nothing serializes them), but `messages`/`currentSession`
|
||||
@@ -262,7 +264,7 @@ export function sendMessage(text: string) {
|
||||
messages.update((ms) => {
|
||||
const last = ms[ms.length - 1]
|
||||
if (last && last.role === 'assistant') {
|
||||
last.tools = [...last.tools, tr]
|
||||
ms[ms.length - 1] = { ...last, tools: [...last.tools, tr] }
|
||||
}
|
||||
return [...ms]
|
||||
})
|
||||
@@ -279,10 +281,10 @@ export function sendMessage(text: string) {
|
||||
messages.update((ms) => {
|
||||
const last = ms[ms.length - 1]
|
||||
if (last && last.role === 'assistant') {
|
||||
last.tools = last.tools.map((t) =>
|
||||
const tools = last.tools.map((t) =>
|
||||
t.id === ev.data.id ? updated : t
|
||||
)
|
||||
last.pendingApprovals = extractApprovals(last.tools)
|
||||
ms[ms.length - 1] = { ...last, tools, pendingApprovals: extractApprovals(tools) }
|
||||
}
|
||||
return [...ms]
|
||||
})
|
||||
@@ -291,7 +293,7 @@ export function sendMessage(text: string) {
|
||||
messages.update((ms) => {
|
||||
const last = ms[ms.length - 1]
|
||||
if (last && last.role === 'assistant') {
|
||||
last.text += ev.data
|
||||
ms[ms.length - 1] = { ...last, text: last.text + ev.data }
|
||||
}
|
||||
return [...ms]
|
||||
})
|
||||
@@ -300,7 +302,7 @@ export function sendMessage(text: string) {
|
||||
messages.update((ms) => {
|
||||
const last = ms[ms.length - 1]
|
||||
if (last && last.role === 'assistant') {
|
||||
last.text = ev.data
|
||||
ms[ms.length - 1] = { ...last, text: ev.data }
|
||||
}
|
||||
return [...ms]
|
||||
})
|
||||
@@ -310,7 +312,7 @@ export function sendMessage(text: string) {
|
||||
messages.update((ms) => {
|
||||
const last = ms[ms.length - 1]
|
||||
if (last && last.role === 'assistant') {
|
||||
last.pendingApprovals = extractApprovals(last.tools)
|
||||
ms[ms.length - 1] = { ...last, pendingApprovals: extractApprovals(last.tools) }
|
||||
}
|
||||
return [...ms]
|
||||
})
|
||||
@@ -580,7 +582,7 @@ export function sendSessionMessage(sessionId: string, text: string) {
|
||||
const assistantMsg: ChatMessage = { id: mid(), role: 'assistant', text: '', tools: [], pendingApprovals: [] }
|
||||
chat.messages.update((ms) => [...ms, assistantMsg])
|
||||
|
||||
let activeTools: Map<string, ToolCallResult> = new Map()
|
||||
const activeTools: Map<string, ToolCallResult> = new Map()
|
||||
let receivedDone = false
|
||||
|
||||
const controller = streamChat(
|
||||
@@ -593,7 +595,9 @@ export function sendSessionMessage(sessionId: string, text: string) {
|
||||
activeTools.set(ev.data.id, tr)
|
||||
chat.messages.update((ms) => {
|
||||
const last = ms[ms.length - 1]
|
||||
if (last && last.role === 'assistant') last.tools = [...last.tools, tr]
|
||||
if (last && last.role === 'assistant') {
|
||||
ms[ms.length - 1] = { ...last, tools: [...last.tools, tr] }
|
||||
}
|
||||
return [...ms]
|
||||
})
|
||||
} else if (ev.type === 'tool_result') {
|
||||
@@ -604,8 +608,8 @@ export function sendSessionMessage(sessionId: string, text: string) {
|
||||
chat.messages.update((ms) => {
|
||||
const last = ms[ms.length - 1]
|
||||
if (last && last.role === 'assistant') {
|
||||
last.tools = last.tools.map((t) => (t.id === ev.data.id ? updated : t))
|
||||
last.pendingApprovals = extractApprovals(last.tools)
|
||||
const tools = last.tools.map((t) => (t.id === ev.data.id ? updated : t))
|
||||
ms[ms.length - 1] = { ...last, tools, pendingApprovals: extractApprovals(tools) }
|
||||
}
|
||||
return [...ms]
|
||||
})
|
||||
@@ -613,13 +617,17 @@ export function sendSessionMessage(sessionId: string, text: string) {
|
||||
} else if (ev.type === 'text_delta') {
|
||||
chat.messages.update((ms) => {
|
||||
const last = ms[ms.length - 1]
|
||||
if (last && last.role === 'assistant') last.text += ev.data
|
||||
if (last && last.role === 'assistant') {
|
||||
ms[ms.length - 1] = { ...last, text: last.text + ev.data }
|
||||
}
|
||||
return [...ms]
|
||||
})
|
||||
} else if (ev.type === 'text') {
|
||||
chat.messages.update((ms) => {
|
||||
const last = ms[ms.length - 1]
|
||||
if (last && last.role === 'assistant') last.text = ev.data
|
||||
if (last && last.role === 'assistant') {
|
||||
ms[ms.length - 1] = { ...last, text: ev.data }
|
||||
}
|
||||
return [...ms]
|
||||
})
|
||||
} else if (ev.type === 'done') {
|
||||
@@ -627,7 +635,9 @@ export function sendSessionMessage(sessionId: string, text: string) {
|
||||
chat.connectionState.set('connected')
|
||||
chat.messages.update((ms) => {
|
||||
const last = ms[ms.length - 1]
|
||||
if (last && last.role === 'assistant') last.pendingApprovals = extractApprovals(last.tools)
|
||||
if (last && last.role === 'assistant') {
|
||||
ms[ms.length - 1] = { ...last, pendingApprovals: extractApprovals(last.tools) }
|
||||
}
|
||||
return [...ms]
|
||||
})
|
||||
startSessionPolling(sessionId)
|
||||
@@ -694,7 +704,9 @@ export function startTask(text: string, onSession: (sessionId: string) => void):
|
||||
activeTools.set(ev.data.id, tr)
|
||||
c.messages.update((ms) => {
|
||||
const last = ms[ms.length - 1]
|
||||
if (last && last.role === 'assistant') last.tools = [...last.tools, tr]
|
||||
if (last && last.role === 'assistant') {
|
||||
ms[ms.length - 1] = { ...last, tools: [...last.tools, tr] }
|
||||
}
|
||||
return [...ms]
|
||||
})
|
||||
} else if (ev.type === 'tool_result') {
|
||||
@@ -705,8 +717,8 @@ export function startTask(text: string, onSession: (sessionId: string) => void):
|
||||
c.messages.update((ms) => {
|
||||
const last = ms[ms.length - 1]
|
||||
if (last && last.role === 'assistant') {
|
||||
last.tools = last.tools.map((t) => (t.id === ev.data.id ? updated : t))
|
||||
last.pendingApprovals = extractApprovals(last.tools)
|
||||
const tools = last.tools.map((t) => (t.id === ev.data.id ? updated : t))
|
||||
ms[ms.length - 1] = { ...last, tools, pendingApprovals: extractApprovals(tools) }
|
||||
}
|
||||
return [...ms]
|
||||
})
|
||||
@@ -714,13 +726,17 @@ export function startTask(text: string, onSession: (sessionId: string) => void):
|
||||
} else if (ev.type === 'text_delta') {
|
||||
c.messages.update((ms) => {
|
||||
const last = ms[ms.length - 1]
|
||||
if (last && last.role === 'assistant') last.text += ev.data
|
||||
if (last && last.role === 'assistant') {
|
||||
ms[ms.length - 1] = { ...last, text: last.text + ev.data }
|
||||
}
|
||||
return [...ms]
|
||||
})
|
||||
} else if (ev.type === 'text') {
|
||||
c.messages.update((ms) => {
|
||||
const last = ms[ms.length - 1]
|
||||
if (last && last.role === 'assistant') last.text = ev.data
|
||||
if (last && last.role === 'assistant') {
|
||||
ms[ms.length - 1] = { ...last, text: ev.data }
|
||||
}
|
||||
return [...ms]
|
||||
})
|
||||
} else if (ev.type === 'done') {
|
||||
@@ -728,7 +744,9 @@ export function startTask(text: string, onSession: (sessionId: string) => void):
|
||||
c.connectionState.set('connected')
|
||||
c.messages.update((ms) => {
|
||||
const last = ms[ms.length - 1]
|
||||
if (last && last.role === 'assistant') last.pendingApprovals = extractApprovals(last.tools)
|
||||
if (last && last.role === 'assistant') {
|
||||
ms[ms.length - 1] = { ...last, pendingApprovals: extractApprovals(last.tools) }
|
||||
}
|
||||
return [...ms]
|
||||
})
|
||||
startSessionPolling(sessionId)
|
||||
|
||||
@@ -122,5 +122,5 @@ export function openTaskWindow(sessionId: string | null, title: string): void {
|
||||
wm.focus(id)
|
||||
return
|
||||
}
|
||||
wm.open({ id, title, width: 900, height: 640 })
|
||||
wm.open({ id, title, width: 900, height: 640, minWidth: 600, minHeight: 400 })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user