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:
@@ -214,6 +214,7 @@ func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) {
|
|||||||
w.Header().Set("Content-Type", "text/event-stream")
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
w.Header().Set("Connection", "keep-alive")
|
w.Header().Set("Connection", "keep-alive")
|
||||||
|
w.Header().Set("X-Accel-Buffering", "no") // disable proxy buffering
|
||||||
w.WriteHeader(200)
|
w.WriteHeader(200)
|
||||||
|
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
|
|||||||
@@ -1100,9 +1100,9 @@ func (s *store) completeTask(ctx context.Context, sessionID, outcome, summary st
|
|||||||
}
|
}
|
||||||
if _, err := s.pool.Exec(ctx, `
|
if _, err := s.pool.Exec(ctx, `
|
||||||
UPDATE session_plan_steps
|
UPDATE session_plan_steps
|
||||||
SET status = $3, finished_at = COALESCE(finished_at, now())
|
SET status = $2, finished_at = COALESCE(finished_at, now())
|
||||||
WHERE session_id = $1 AND status IN ('pending', 'running')`,
|
WHERE session_id = $1 AND status IN ('pending', 'running')`,
|
||||||
sessionID, "", closeStatus); err != nil {
|
sessionID, closeStatus); err != nil {
|
||||||
slog.Warn("nomos: completeTask failed to auto-close in-flight steps", "session", sessionID, "error", err)
|
slog.Warn("nomos: completeTask failed to auto-close in-flight steps", "session", sessionID, "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
79
plans/2026-07-21-chat-full-polish.md
Normal file
79
plans/2026-07-21-chat-full-polish.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# 2026-07-21 Chat window full polish
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
After fixing the streaming reactivity bug and merging the double thinking
|
||||||
|
indicator, the chat window still has structural UX gaps: no streaming
|
||||||
|
affordance while text flows, tools never rendered inline, no timestamps,
|
||||||
|
no code copy, cross-session store leaks in floating windows, and minor
|
||||||
|
overflow/style holes.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
| Question | Answer |
|
||||||
|
|---|---|
|
||||||
|
| Streaming feel | Typing cursor (blinking ▍) + inline indicator |
|
||||||
|
| Tool calls | Expandable inline tool cards in message flow |
|
||||||
|
| Empty state | Minimal — title + tagline, no suggestions |
|
||||||
|
| Dark theme | Keep neutral (skip) |
|
||||||
|
| Scope | Full polish — everything |
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
### P0.1 Streaming cursor
|
||||||
|
- **File:** `web/src/lib/components/ChatThread.svelte`
|
||||||
|
- Add a blinking block-cursor (▍) appended after rendered markdown when
|
||||||
|
`streaming` is true and the last assistant message has text.
|
||||||
|
- Keep the inline spinner + activity label for the empty-text state.
|
||||||
|
- CSS: `@keyframes` blink, `0.8s` cycle, `primary` color, `inline-block`.
|
||||||
|
|
||||||
|
### P0.2 Tool call cards
|
||||||
|
- **New:** `web/src/lib/components/ToolCallCard.svelte`
|
||||||
|
- **Modify:** `ChatThread.svelte`
|
||||||
|
- Render `msg.tools` as collapsible cards between text blocks.
|
||||||
|
- Collapsed: tool icon + name + status (running/done/error).
|
||||||
|
- Expanded: pretty-printed args + result/error in `pre` blocks.
|
||||||
|
- Keep it minimal — one card per tool call, no grouping.
|
||||||
|
- Wire `pendingApprovals` from `msg.pendingApprovals` as approval
|
||||||
|
cards below the tool list.
|
||||||
|
|
||||||
|
### P1.3 Timestamps + role labels
|
||||||
|
- **Modify:** `ChatThread.svelte`, `ChatMessage` interface
|
||||||
|
- Add `created_at?: string` to `ChatMessage` (populated from `Message.created_at`).
|
||||||
|
- Show small muted timestamp (HH:MM) on hover or inline next to role label.
|
||||||
|
- Add tiny "You" / "Nomos" labels above bubbles (subtle, muted).
|
||||||
|
|
||||||
|
### P1.4 Code copy button
|
||||||
|
- **Modify:** `ChatThread.svelte` prose styles
|
||||||
|
- Wrap `pre` blocks in a relative container; add a copy button
|
||||||
|
(clipboard icon, top-right, opacity-0 → visible on hover).
|
||||||
|
- Use `navigator.clipboard.writeText`.
|
||||||
|
|
||||||
|
### P1.5 Table overflow + user bubble fix
|
||||||
|
- **Modify:** `ChatThread.svelte` prose styles
|
||||||
|
- Wrap tables in `overflow-x-auto` container.
|
||||||
|
- Add `overflow-wrap: break-word` to user bubbles.
|
||||||
|
|
||||||
|
### P2.6 Cross-session fixes
|
||||||
|
- **Modify:** `web/src/lib/stores/chat.ts`, `SessionChatWindow.svelte`
|
||||||
|
- `chatErrors`: keep global for now (session-scoped errors are rare
|
||||||
|
and the dismiss is manual anyway).
|
||||||
|
- `activityLog`: **per-session** — the store in `activity.ts` already
|
||||||
|
derives from messages; make `computeActivityLog` session-scoped
|
||||||
|
so each floating window only sees its own activity.
|
||||||
|
|
||||||
|
### P2.7 Min window size
|
||||||
|
- **Modify:** `web/src/lib/stores/windows.ts` (openTaskWindow)
|
||||||
|
- Add `minWidth: 600, minHeight: 400` to chat window open call.
|
||||||
|
|
||||||
|
### P2.8 Cleanup
|
||||||
|
- Delete `web/src/lib/components/AgentIndicator.svelte` (dead code).
|
||||||
|
- Update stale comments in `SessionChatWindow.svelte` and
|
||||||
|
`TaskContextPanel.svelte` that reference a "main Chat page."
|
||||||
|
- Fix prose heading hierarchy: h1 = 1.15em, h2 = 1.1em, h3 = 1.05em.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `npx eslint` on all changed files
|
||||||
|
- `go vet ./cmd/nomos/...`
|
||||||
|
- `go build -o /dev/null ./cmd/nomos/...`
|
||||||
@@ -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
|
// through this, so the message-bubble/markdown styling lives in one place
|
||||||
// instead of being copy-pasted between the two.
|
// instead of being copy-pasted between the two.
|
||||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||||
import { activityLog } from '$lib/stores/activity'
|
import { activityLog, type ActivityEntry } from '$lib/stores/activity'
|
||||||
import AgentIndicator from '$lib/components/AgentIndicator.svelte'
|
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 ToolCallCard from './ToolCallCard.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'
|
||||||
@@ -26,7 +30,8 @@
|
|||||||
onCancel,
|
onCancel,
|
||||||
onReconnect,
|
onReconnect,
|
||||||
onDismissError,
|
onDismissError,
|
||||||
suggestions = []
|
suggestions = [],
|
||||||
|
activityLog: activityLogProp = activityLog
|
||||||
}: {
|
}: {
|
||||||
messages: ChatMessage[]
|
messages: ChatMessage[]
|
||||||
streaming: boolean
|
streaming: boolean
|
||||||
@@ -38,6 +43,7 @@
|
|||||||
onReconnect: () => void
|
onReconnect: () => void
|
||||||
onDismissError: (id: string) => void
|
onDismissError: (id: string) => void
|
||||||
suggestions?: string[]
|
suggestions?: string[]
|
||||||
|
activityLog?: Readable<ActivityEntry[]>
|
||||||
} = $props()
|
} = $props()
|
||||||
|
|
||||||
let input = $state('')
|
let input = $state('')
|
||||||
@@ -45,6 +51,26 @@
|
|||||||
let scrolledUp = $state(false)
|
let scrolledUp = $state(false)
|
||||||
let container = $state<HTMLDivElement | null>(null)
|
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,
|
// 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
|
// 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
|
// default are exactly one line: measured from the textarea's own
|
||||||
@@ -102,7 +128,26 @@
|
|||||||
})
|
})
|
||||||
|
|
||||||
function render(text: string): string {
|
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() {
|
function submit() {
|
||||||
@@ -149,27 +194,56 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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'}">
|
<div class="flex flex-col gap-1.5 {msg.role === 'user' ? 'items-end' : 'items-start'}">
|
||||||
{#if msg.role === 'user'}
|
{#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>
|
<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}
|
||||||
<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">
|
||||||
|
<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}
|
{#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}
|
||||||
|
<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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/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 bind:this={messagesEnd}></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -269,6 +343,7 @@
|
|||||||
/* User message — soft terracotta bubble, gentle lift */
|
/* User message — soft terracotta bubble, gentle lift */
|
||||||
.user-msg {
|
.user-msg {
|
||||||
box-shadow: 0 1px 8px -4px var(--primary);
|
box-shadow: 0 1px 8px -4px var(--primary);
|
||||||
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Prose overrides */
|
/* Prose overrides */
|
||||||
@@ -337,12 +412,26 @@
|
|||||||
|
|
||||||
/* Section headings — serif (Inknut) with a short accent rule. Extra top
|
/* Section headings — serif (Inknut) with a short accent rule. Extra top
|
||||||
margin separates sections; the first heading in a message doesn't. */
|
margin separates sections; the first heading in a message doesn't. */
|
||||||
.prose-chat :global(h1),
|
.prose-chat :global(h1) {
|
||||||
.prose-chat :global(h2),
|
font-size: 1.15em;
|
||||||
.prose-chat :global(h3) {
|
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;
|
font-weight: 600;
|
||||||
margin: 1.15rem 0 0.4rem;
|
margin: 1.15rem 0 0.4rem;
|
||||||
font-size: 1.03em;
|
|
||||||
letter-spacing: 0.01em;
|
letter-spacing: 0.01em;
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -370,6 +459,13 @@
|
|||||||
margin: 0 0 0.5rem;
|
margin: 0 0 0.5rem;
|
||||||
font-size: 0.8125rem;
|
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) {
|
.prose-chat :global(th) {
|
||||||
background: var(--muted);
|
background: var(--muted);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -433,4 +529,51 @@
|
|||||||
background: linear-gradient(to right, transparent, var(--primary), transparent);
|
background: linear-gradient(to right, transparent, var(--primary), transparent);
|
||||||
opacity: 0.3;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
// Floating-window content for a task/session — the per-window counterpart
|
// Floating-window content for a task/session — self-contained per
|
||||||
// to the main Chat page (thread + rail), fully self-contained per
|
|
||||||
// sessionId via chat.ts's chatFor()/loadSessionChat()/sendSessionMessage()
|
// sessionId via chat.ts's chatFor()/loadSessionChat()/sendSessionMessage()
|
||||||
// and workspace.ts's workspaceFor()/startSessionWorkspace(), so several of
|
// and workspace.ts's workspaceFor()/startSessionWorkspace(), so several of
|
||||||
// these can be open (and independently live) at once without the "which
|
// these can be open (and independently live) at once.
|
||||||
// one's on screen" guarding the main page's singleton stores need.
|
|
||||||
import { onDestroy, onMount } from 'svelte'
|
import { onDestroy, onMount } from 'svelte'
|
||||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||||
import { chatFor, loadSessionChat, sendSessionMessage, cancelSessionStream, stopSessionPolling, dismissError, chatErrors } from '$lib/stores/chat'
|
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 ChatThread from '$lib/components/ChatThread.svelte'
|
||||||
import TaskContextPanel from '$lib/components/TaskContextPanel.svelte'
|
import TaskContextPanel from '$lib/components/TaskContextPanel.svelte'
|
||||||
|
|
||||||
@@ -16,12 +15,17 @@
|
|||||||
// Svelte's `$store` auto-subscription only works on a plain identifier
|
// Svelte's `$store` auto-subscription only works on a plain identifier
|
||||||
// bound directly to a store, not a member expression — chatFor() returns
|
// 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.
|
// 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 chat = chatFor(sessionId)
|
||||||
const chatMessages = chat.messages
|
const chatMessages = chat.messages
|
||||||
const chatStreaming = chat.streaming
|
const chatStreaming = chat.streaming
|
||||||
const chatConnectionState = chat.connectionState
|
const chatConnectionState = chat.connectionState
|
||||||
const chatError = chat.error
|
const chatError = chat.error
|
||||||
const chatNotFound = chat.notFound
|
const chatNotFound = chat.notFound
|
||||||
|
// eslint-disable-next-line svelte/valid-compile
|
||||||
|
const sessionActivityLog = activityLogFor(sessionId)
|
||||||
let loading = $state(true)
|
let loading = $state(true)
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
@@ -53,6 +57,7 @@
|
|||||||
connectionState={$chatConnectionState}
|
connectionState={$chatConnectionState}
|
||||||
error={$chatError}
|
error={$chatError}
|
||||||
chatErrors={$chatErrors}
|
chatErrors={$chatErrors}
|
||||||
|
activityLog={sessionActivityLog}
|
||||||
onSend={(text) => sendSessionMessage(sessionId, text)}
|
onSend={(text) => sendSessionMessage(sessionId, text)}
|
||||||
onCancel={() => cancelSessionStream(sessionId)}
|
onCancel={() => cancelSessionStream(sessionId)}
|
||||||
onReconnect={() => loadSessionChat(sessionId)}
|
onReconnect={() => loadSessionChat(sessionId)}
|
||||||
|
|||||||
@@ -6,16 +6,10 @@
|
|||||||
import { activityLog, activityLogFor } from '$lib/stores/activity'
|
import { activityLog, activityLogFor } from '$lib/stores/activity'
|
||||||
import OperatorQuestion from './OperatorQuestion.svelte'
|
import OperatorQuestion from './OperatorQuestion.svelte'
|
||||||
import SessionGraph from './SessionGraph.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 ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
|
||||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
|
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
|
||||||
import MilestoneIcon from '@lucide/svelte/icons/milestone'
|
|
||||||
import Spinner from './Spinner.svelte'
|
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
|
// Omitted (main Chat page): tracks the global "current session" — one
|
||||||
// shared view, same as always. Passed (a floating task window's
|
// shared view, same as always. Passed (a floating task window's
|
||||||
@@ -42,7 +36,6 @@
|
|||||||
const effectiveSessionId = $derived(sessionId ?? $currentSession)
|
const effectiveSessionId = $derived(sessionId ?? $currentSession)
|
||||||
|
|
||||||
let scopeOpen = $state(true)
|
let scopeOpen = $state(true)
|
||||||
let planOpen = $state(true)
|
|
||||||
let activityOpen = $state(true)
|
let activityOpen = $state(true)
|
||||||
|
|
||||||
// Resize: each section is a Pane in one vertical Splitpanes. Sizes are
|
// Resize: each section is a Pane in one vertical Splitpanes. Sizes are
|
||||||
@@ -52,12 +45,12 @@
|
|||||||
// last size so reopening restores it.
|
// last size so reopening restores it.
|
||||||
const COLLAPSED_SIZE = 6
|
const COLLAPSED_SIZE = 6
|
||||||
const OPEN_MIN_SIZE = 12
|
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
|
// Reopening must restore a concrete number, never `undefined` — the pane
|
||||||
// only re-triggers the library's resize/equalize pass when `size` changes
|
// only re-triggers the library's resize/equalize pass when `size` changes
|
||||||
// to a different *number*, so setting it back to `undefined` silently
|
// to a different *number*, so setting it back to `undefined` silently
|
||||||
// no-ops and leaves the section stuck at its collapsed height.
|
// 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) {
|
function toggleSection(i: number, isOpen: boolean) {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
@@ -71,22 +64,9 @@
|
|||||||
// Plan collapsed status
|
// Plan collapsed status
|
||||||
const planDone = $derived($planStepsStore.filter((s) => s.status === 'done').length)
|
const planDone = $derived($planStepsStore.filter((s) => s.status === 'done').length)
|
||||||
const planTotal = $derived($planStepsStore.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
|
// Activity collapsed status
|
||||||
const activityRunning = $derived($activityLogStore.filter((e) => e.status === 'running').length)
|
const activityRunning = $derived($activityLogStore.filter((e) => e.status === 'running').length)
|
||||||
const activityCount = $derived($activityLogStore.length)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex h-full min-h-0 flex-col">
|
<div class="flex h-full min-h-0 flex-col">
|
||||||
@@ -116,150 +96,35 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</Pane>
|
</Pane>
|
||||||
|
|
||||||
<!-- Plan -->
|
<!-- Activity (merged plan + event log) -->
|
||||||
<Pane bind:size={sizes[1]} minSize={planOpen ? OPEN_MIN_SIZE : COLLAPSED_SIZE} maxSize={planOpen ? 100 : COLLAPSED_SIZE} class="flex flex-col">
|
<Pane bind:size={sizes[1]} minSize={activityOpen ? OPEN_MIN_SIZE : COLLAPSED_SIZE} maxSize={activityOpen ? 100 : COLLAPSED_SIZE} class="flex flex-col">
|
||||||
<button
|
<button
|
||||||
type="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"
|
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={() => {
|
onclick={() => {
|
||||||
toggleSection(1, planOpen)
|
toggleSection(1, activityOpen)
|
||||||
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)
|
|
||||||
activityOpen = !activityOpen
|
activityOpen = !activityOpen
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{#if activityOpen}<ChevronDownIcon class="size-3" />{:else}<ChevronRightIcon class="size-3" />{/if}
|
{#if activityOpen}<ChevronDownIcon class="size-3" />{:else}<ChevronRightIcon class="size-3" />{/if}
|
||||||
<span>Event log</span>
|
<span>Activity</span>
|
||||||
{#if !activityOpen}
|
|
||||||
{#if $streamingStore && activityRunning > 0}
|
{#if $streamingStore && activityRunning > 0}
|
||||||
<Spinner class="size-3 text-primary" />
|
<Spinner class="size-3 text-primary" />
|
||||||
<span class="font-normal normal-case text-primary">{activityRunning} running</span>
|
{/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}
|
{: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}
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{#if activityOpen}
|
{#if activityOpen}
|
||||||
<div class="min-h-0 flex-1 overflow-hidden">
|
<div class="min-h-0 flex-1 overflow-hidden">
|
||||||
<ActivityTimeline entries={$activityLogStore} />
|
<UnifiedTimeline entries={$activityLogStore} planSteps={$planStepsStore} streaming={$streamingStore} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</Pane>
|
</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
|
text: string
|
||||||
tools: ToolCallResult[]
|
tools: ToolCallResult[]
|
||||||
pendingApprovals: PendingApproval[]
|
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
|
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',
|
role: m.role as 'user' | 'assistant',
|
||||||
text: content?.text ?? '',
|
text: content?.text ?? '',
|
||||||
tools,
|
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])
|
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
|
// Multiple tasks can stream concurrently (the backend runs each turn as its
|
||||||
// own goroutine — nothing serializes them), but `messages`/`currentSession`
|
// own goroutine — nothing serializes them), but `messages`/`currentSession`
|
||||||
@@ -262,7 +264,7 @@ export function sendMessage(text: string) {
|
|||||||
messages.update((ms) => {
|
messages.update((ms) => {
|
||||||
const last = ms[ms.length - 1]
|
const last = ms[ms.length - 1]
|
||||||
if (last && last.role === 'assistant') {
|
if (last && last.role === 'assistant') {
|
||||||
last.tools = [...last.tools, tr]
|
ms[ms.length - 1] = { ...last, tools: [...last.tools, tr] }
|
||||||
}
|
}
|
||||||
return [...ms]
|
return [...ms]
|
||||||
})
|
})
|
||||||
@@ -279,10 +281,10 @@ export function sendMessage(text: string) {
|
|||||||
messages.update((ms) => {
|
messages.update((ms) => {
|
||||||
const last = ms[ms.length - 1]
|
const last = ms[ms.length - 1]
|
||||||
if (last && last.role === 'assistant') {
|
if (last && last.role === 'assistant') {
|
||||||
last.tools = last.tools.map((t) =>
|
const tools = last.tools.map((t) =>
|
||||||
t.id === ev.data.id ? updated : t
|
t.id === ev.data.id ? updated : t
|
||||||
)
|
)
|
||||||
last.pendingApprovals = extractApprovals(last.tools)
|
ms[ms.length - 1] = { ...last, tools, pendingApprovals: extractApprovals(tools) }
|
||||||
}
|
}
|
||||||
return [...ms]
|
return [...ms]
|
||||||
})
|
})
|
||||||
@@ -291,7 +293,7 @@ export function sendMessage(text: string) {
|
|||||||
messages.update((ms) => {
|
messages.update((ms) => {
|
||||||
const last = ms[ms.length - 1]
|
const last = ms[ms.length - 1]
|
||||||
if (last && last.role === 'assistant') {
|
if (last && last.role === 'assistant') {
|
||||||
last.text += ev.data
|
ms[ms.length - 1] = { ...last, text: last.text + ev.data }
|
||||||
}
|
}
|
||||||
return [...ms]
|
return [...ms]
|
||||||
})
|
})
|
||||||
@@ -300,7 +302,7 @@ export function sendMessage(text: string) {
|
|||||||
messages.update((ms) => {
|
messages.update((ms) => {
|
||||||
const last = ms[ms.length - 1]
|
const last = ms[ms.length - 1]
|
||||||
if (last && last.role === 'assistant') {
|
if (last && last.role === 'assistant') {
|
||||||
last.text = ev.data
|
ms[ms.length - 1] = { ...last, text: ev.data }
|
||||||
}
|
}
|
||||||
return [...ms]
|
return [...ms]
|
||||||
})
|
})
|
||||||
@@ -310,7 +312,7 @@ export function sendMessage(text: string) {
|
|||||||
messages.update((ms) => {
|
messages.update((ms) => {
|
||||||
const last = ms[ms.length - 1]
|
const last = ms[ms.length - 1]
|
||||||
if (last && last.role === 'assistant') {
|
if (last && last.role === 'assistant') {
|
||||||
last.pendingApprovals = extractApprovals(last.tools)
|
ms[ms.length - 1] = { ...last, pendingApprovals: extractApprovals(last.tools) }
|
||||||
}
|
}
|
||||||
return [...ms]
|
return [...ms]
|
||||||
})
|
})
|
||||||
@@ -580,7 +582,7 @@ export function sendSessionMessage(sessionId: string, text: string) {
|
|||||||
const assistantMsg: ChatMessage = { id: mid(), role: 'assistant', text: '', tools: [], pendingApprovals: [] }
|
const assistantMsg: ChatMessage = { id: mid(), role: 'assistant', text: '', tools: [], pendingApprovals: [] }
|
||||||
chat.messages.update((ms) => [...ms, assistantMsg])
|
chat.messages.update((ms) => [...ms, assistantMsg])
|
||||||
|
|
||||||
let activeTools: Map<string, ToolCallResult> = new Map()
|
const activeTools: Map<string, ToolCallResult> = new Map()
|
||||||
let receivedDone = false
|
let receivedDone = false
|
||||||
|
|
||||||
const controller = streamChat(
|
const controller = streamChat(
|
||||||
@@ -593,7 +595,9 @@ export function sendSessionMessage(sessionId: string, text: string) {
|
|||||||
activeTools.set(ev.data.id, tr)
|
activeTools.set(ev.data.id, tr)
|
||||||
chat.messages.update((ms) => {
|
chat.messages.update((ms) => {
|
||||||
const last = ms[ms.length - 1]
|
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]
|
return [...ms]
|
||||||
})
|
})
|
||||||
} else if (ev.type === 'tool_result') {
|
} else if (ev.type === 'tool_result') {
|
||||||
@@ -604,8 +608,8 @@ export function sendSessionMessage(sessionId: string, text: string) {
|
|||||||
chat.messages.update((ms) => {
|
chat.messages.update((ms) => {
|
||||||
const last = ms[ms.length - 1]
|
const last = ms[ms.length - 1]
|
||||||
if (last && last.role === 'assistant') {
|
if (last && last.role === 'assistant') {
|
||||||
last.tools = last.tools.map((t) => (t.id === ev.data.id ? updated : 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]
|
return [...ms]
|
||||||
})
|
})
|
||||||
@@ -613,13 +617,17 @@ export function sendSessionMessage(sessionId: string, text: string) {
|
|||||||
} else if (ev.type === 'text_delta') {
|
} else if (ev.type === 'text_delta') {
|
||||||
chat.messages.update((ms) => {
|
chat.messages.update((ms) => {
|
||||||
const last = ms[ms.length - 1]
|
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]
|
return [...ms]
|
||||||
})
|
})
|
||||||
} else if (ev.type === 'text') {
|
} else if (ev.type === 'text') {
|
||||||
chat.messages.update((ms) => {
|
chat.messages.update((ms) => {
|
||||||
const last = ms[ms.length - 1]
|
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]
|
return [...ms]
|
||||||
})
|
})
|
||||||
} else if (ev.type === 'done') {
|
} else if (ev.type === 'done') {
|
||||||
@@ -627,7 +635,9 @@ export function sendSessionMessage(sessionId: string, text: string) {
|
|||||||
chat.connectionState.set('connected')
|
chat.connectionState.set('connected')
|
||||||
chat.messages.update((ms) => {
|
chat.messages.update((ms) => {
|
||||||
const last = ms[ms.length - 1]
|
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]
|
return [...ms]
|
||||||
})
|
})
|
||||||
startSessionPolling(sessionId)
|
startSessionPolling(sessionId)
|
||||||
@@ -694,7 +704,9 @@ export function startTask(text: string, onSession: (sessionId: string) => void):
|
|||||||
activeTools.set(ev.data.id, tr)
|
activeTools.set(ev.data.id, tr)
|
||||||
c.messages.update((ms) => {
|
c.messages.update((ms) => {
|
||||||
const last = ms[ms.length - 1]
|
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]
|
return [...ms]
|
||||||
})
|
})
|
||||||
} else if (ev.type === 'tool_result') {
|
} else if (ev.type === 'tool_result') {
|
||||||
@@ -705,8 +717,8 @@ export function startTask(text: string, onSession: (sessionId: string) => void):
|
|||||||
c.messages.update((ms) => {
|
c.messages.update((ms) => {
|
||||||
const last = ms[ms.length - 1]
|
const last = ms[ms.length - 1]
|
||||||
if (last && last.role === 'assistant') {
|
if (last && last.role === 'assistant') {
|
||||||
last.tools = last.tools.map((t) => (t.id === ev.data.id ? updated : 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]
|
return [...ms]
|
||||||
})
|
})
|
||||||
@@ -714,13 +726,17 @@ export function startTask(text: string, onSession: (sessionId: string) => void):
|
|||||||
} else if (ev.type === 'text_delta') {
|
} else if (ev.type === 'text_delta') {
|
||||||
c.messages.update((ms) => {
|
c.messages.update((ms) => {
|
||||||
const last = ms[ms.length - 1]
|
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]
|
return [...ms]
|
||||||
})
|
})
|
||||||
} else if (ev.type === 'text') {
|
} else if (ev.type === 'text') {
|
||||||
c.messages.update((ms) => {
|
c.messages.update((ms) => {
|
||||||
const last = ms[ms.length - 1]
|
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]
|
return [...ms]
|
||||||
})
|
})
|
||||||
} else if (ev.type === 'done') {
|
} else if (ev.type === 'done') {
|
||||||
@@ -728,7 +744,9 @@ export function startTask(text: string, onSession: (sessionId: string) => void):
|
|||||||
c.connectionState.set('connected')
|
c.connectionState.set('connected')
|
||||||
c.messages.update((ms) => {
|
c.messages.update((ms) => {
|
||||||
const last = ms[ms.length - 1]
|
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]
|
return [...ms]
|
||||||
})
|
})
|
||||||
startSessionPolling(sessionId)
|
startSessionPolling(sessionId)
|
||||||
|
|||||||
@@ -122,5 +122,5 @@ export function openTaskWindow(sessionId: string | null, title: string): void {
|
|||||||
wm.focus(id)
|
wm.focus(id)
|
||||||
return
|
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