feat(tasks): phase 6 — live TaskContextPanel (goal, plan, question, entities)

Replaces the chat right rail's ad-hoc Digest+Graph stack with a single
TaskContextPanel that renders the task's live working state, driven by the
always-on events stream (not the per-turn chat SSE) so it keeps updating
during server-side auto-continuation/resume:

- GoalHeader: goal + status pill (planning/executing/awaiting_input/done/
  failed), sourced from the sessions list.
- PlanProgress: ordered steps with live status icons + progress bar, hydrated
  via new GET /sessions/{id}/plan; clicking a step with a target opens its
  EntitySheet (no fake "jump to transcript" — bits-ui Collapsible content
  isn't force-mounted, so a DOM-scroll jump would silently no-op for
  collapsed tool groups).
- OperatorQuestion: the pinned structured question card (prompt/why/entity
  chips/option buttons/free-text), hydrated via new GET /sessions/{id}/
  questions; answering POSTs to the existing answer endpoint.
- SessionGraph upgraded to a live entity panel: entity.touched pulses the
  node (animated ring) and shows "Now touching <slug>"; health.changed shows
  a transient diff badge for touched entities.
- SessionDigest gains a success/failure/partial outcome banner and now also
  refetches when the task's status changes, not just on session switch.

Two bugs found and fixed while wiring this up:
- workspace.ts's status-refresh trigger only covered goal.set/task.status;
  question.raised/answered didn't refresh the sessions list, so GoalHeader's
  pill went stale after answering via the panel (resumeSession runs entirely
  server-side — no client 'done' event to piggyback a refresh on). Now every
  status-affecting event triggers the (debounced) refetch.
- Forgot to rebuild the nomos container after adding the /plan and
  /questions endpoints, so they silently fell through to the old default GET
  handler — caught via a live curl diff against the running container,
  not a code read.

Verified end-to-end against the live stack: goal/plan/question all update
without a reload as the agent works; answering a question via the panel
resumes the agent and the header pill correctly flips to Executing;
entity.touched pulses the live graph.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 13:52:36 +02:00
parent 413bf54daf
commit 991e7d0900
11 changed files with 652 additions and 13 deletions

View File

@@ -255,6 +255,32 @@ func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *store, a *a
return
}
// GET /sessions/{id}/plan and /sessions/{id}/questions — REST hydration for
// the context panel when it first opens a task; live events carry deltas
// from there.
if len(parts) == 2 && r.Method == http.MethodGet {
switch parts[1] {
case "plan":
steps, err := st.getPlanSteps(r.Context(), id)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"steps": steps})
return
case "questions":
questions, err := st.getQuestions(r.Context(), id)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"questions": questions})
return
}
}
switch r.Method {
case http.MethodDelete:
if err := st.deleteSession(r.Context(), id); err != nil {

View File

@@ -432,6 +432,88 @@ func (s *store) completeTask(ctx context.Context, sessionID, outcome, summary st
return nil
}
// planStep is a persisted plan step, as returned to the frontend for hydration
// (the panel otherwise only sees steps live via plan.proposed/plan.step.*).
type planStep struct {
ID string `json:"id"`
Seq int `json:"seq"`
Title string `json:"title"`
Detail string `json:"detail"`
Status string `json:"status"`
ExecutionID *string `json:"execution_id,omitempty"`
TargetSlug *string `json:"target_slug,omitempty"`
StartedAt *string `json:"started_at,omitempty"`
FinishedAt *string `json:"finished_at,omitempty"`
}
// getPlanSteps returns a task's plan in order — REST hydration for the context
// panel when it first opens a task (live events only carry deltas from then on).
func (s *store) getPlanSteps(ctx context.Context, sessionID string) ([]planStep, error) {
if s == nil {
return nil, nil
}
rows, err := s.pool.Query(ctx, `
SELECT id::text, seq, title, detail, status,
execution_id::text, target_slug,
started_at::text, finished_at::text
FROM session_plan_steps WHERE session_id = $1 ORDER BY seq`, sessionID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []planStep
for rows.Next() {
var st planStep
var execID, target, started, finished *string
if err := rows.Scan(&st.ID, &st.Seq, &st.Title, &st.Detail, &st.Status,
&execID, &target, &started, &finished); err != nil {
return nil, err
}
st.ExecutionID, st.TargetSlug, st.StartedAt, st.FinishedAt = execID, target, started, finished
out = append(out, st)
}
return out, rows.Err()
}
// sessionQuestion is a persisted question, as returned to the frontend.
type sessionQuestion struct {
ID string `json:"id"`
Prompt string `json:"prompt"`
Context map[string]any `json:"context"`
Status string `json:"status"`
Answer *string `json:"answer,omitempty"`
CreatedAt string `json:"created_at"`
AnsweredAt *string `json:"answered_at,omitempty"`
}
// getQuestions returns a task's questions (open and answered) newest-first —
// REST hydration for the context panel's pinned question card and history.
func (s *store) getQuestions(ctx context.Context, sessionID string) ([]sessionQuestion, error) {
if s == nil {
return nil, nil
}
rows, err := s.pool.Query(ctx, `
SELECT id::text, prompt, context, status, answer, created_at::text, answered_at::text
FROM session_questions WHERE session_id = $1 ORDER BY created_at DESC`, sessionID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []sessionQuestion
for rows.Next() {
var q sessionQuestion
var ctxJSON []byte
var answer, answeredAt *string
if err := rows.Scan(&q.ID, &q.Prompt, &ctxJSON, &q.Status, &answer, &q.CreatedAt, &answeredAt); err != nil {
return nil, err
}
json.Unmarshal(ctxJSON, &q.Context)
q.Answer, q.AnsweredAt = answer, answeredAt
out = append(out, q)
}
return out, rows.Err()
}
// askOperator records a structured decision the agent needs from the operator,
// moves the task to awaiting_input, and emits question.raised so the context
// panel pins it. qctx carries {why, options, entities}. Returns the question id.

View File

@@ -44,6 +44,51 @@ export async function deleteSession(sessionId: string): Promise<boolean> {
return res.ok
}
export interface PlanStep {
id: string
seq: number
title: string
detail: string
status: 'pending' | 'running' | 'done' | 'failed' | 'skipped' | 'blocked'
execution_id?: string
target_slug?: string
started_at?: string
finished_at?: string
}
export async function fetchPlan(sessionId: string): Promise<PlanStep[]> {
const res = await fetch(`${BASE}/sessions/${sessionId}/plan`)
if (!res.ok) return []
const data = await res.json()
return data.steps ?? []
}
export interface SessionQuestion {
id: string
prompt: string
context: { why?: string; options?: string[]; entities?: string[] }
status: 'open' | 'answered' | 'dismissed'
answer?: string
created_at: string
answered_at?: string
}
export async function fetchQuestions(sessionId: string): Promise<SessionQuestion[]> {
const res = await fetch(`${BASE}/sessions/${sessionId}/questions`)
if (!res.ok) return []
const data = await res.json()
return data.questions ?? []
}
export async function answerQuestion(sessionId: string, questionId: string, answer: string): Promise<boolean> {
const res = await fetch(`${BASE}/sessions/${sessionId}/questions/${questionId}/answer`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ answer })
})
return res.ok
}
export interface ChatEvent {
type: string
data: any

View File

@@ -0,0 +1,38 @@
<script lang="ts">
import { currentTask } from '$lib/stores/workspace'
import { Badge } from '$lib/components/ui/badge'
type StatusStyle = { label: string; dot: string; pulse: boolean; variant: 'default' | 'secondary' | 'destructive' | 'outline' }
function statusStyle(status: string | undefined, outcome: string | undefined): StatusStyle {
switch (status) {
case 'awaiting_input':
return { label: 'Needs your input', dot: 'bg-warning', pulse: true, variant: 'secondary' }
case 'done':
return outcome === 'partial'
? { label: 'Done · partial', dot: 'bg-warning', pulse: false, variant: 'secondary' }
: { label: 'Done', dot: 'bg-success', pulse: false, variant: 'default' }
case 'failed':
return { label: 'Failed', dot: 'bg-destructive', pulse: false, variant: 'destructive' }
case 'planning':
return { label: 'Planning', dot: 'bg-primary', pulse: true, variant: 'secondary' }
case 'executing':
return { label: 'Executing', dot: 'bg-primary', pulse: true, variant: 'secondary' }
default:
return { label: 'Active', dot: 'bg-muted-foreground', pulse: false, variant: 'outline' }
}
}
</script>
{#if $currentTask}
{@const st = statusStyle($currentTask.status, $currentTask.outcome)}
<div class="flex shrink-0 flex-col gap-1.5 border-b px-3 py-2.5">
<div class="flex items-center gap-1.5">
<span class="size-2 rounded-full {st.dot} {st.pulse ? 'animate-pulse' : ''}"></span>
<Badge variant={st.variant} class="text-[10px]">{st.label}</Badge>
</div>
<p class="text-sm font-medium leading-snug">
{$currentTask.goal || $currentTask.title || 'Untitled task'}
</p>
</div>
{/if}

View File

@@ -0,0 +1,76 @@
<script lang="ts">
import { openQuestion } from '$lib/stores/workspace'
import { currentSession } from '$lib/stores/chat'
import { answerQuestion as postAnswer } from '$lib/api'
import { Button } from '$lib/components/ui/button'
import { Textarea } from '$lib/components/ui/textarea'
import CircleHelpIcon from '@lucide/svelte/icons/circle-help'
let freeText = $state('')
let submitting = $state(false)
async function submit(answer: string) {
const sid = $currentSession
const q = $openQuestion
if (!sid || !q || !answer.trim() || submitting) return
submitting = true
const ok = await postAnswer(sid, q.id, answer.trim())
submitting = false
if (ok) freeText = ''
// No local optimistic clear: the question.answered event (which the POST
// triggers server-side) updates the store — this stays truthful if the
// POST reports ok but the event is somehow delayed.
}
</script>
{#if $openQuestion}
{@const q = $openQuestion}
<div class="flex shrink-0 flex-col gap-2 border-b bg-warning/5 px-3 py-2.5">
<div class="flex items-start gap-2">
<CircleHelpIcon class="mt-0.5 size-4 shrink-0 text-warning" />
<div class="min-w-0 flex-1">
<p class="text-sm font-medium leading-snug">{q.prompt}</p>
{#if q.context.why}
<p class="mt-0.5 text-xs text-muted-foreground">{q.context.why}</p>
{/if}
</div>
</div>
{#if q.context.entities?.length}
<div class="ml-6 flex flex-wrap gap-1">
{#each q.context.entities as slug}
<span class="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px]">{slug}</span>
{/each}
</div>
{/if}
{#if q.context.options?.length}
<div class="ml-6 flex flex-wrap gap-1.5">
{#each q.context.options as opt}
<Button size="sm" variant="outline" class="h-7 px-2.5 text-xs" disabled={submitting} onclick={() => submit(opt)}>
{opt}
</Button>
{/each}
</div>
{/if}
<div class="ml-6 flex items-end gap-1.5">
<Textarea
bind:value={freeText}
placeholder="Or type an answer…"
rows={1}
class="max-h-24 min-h-0 resize-none text-xs"
disabled={submitting}
onkeydown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
submit(freeText)
}
}}
/>
<Button size="sm" class="h-7 px-2.5 text-xs" disabled={!freeText.trim() || submitting} onclick={() => submit(freeText)}>
Send
</Button>
</div>
</div>
{/if}

View File

@@ -0,0 +1,77 @@
<script lang="ts">
import { planSteps } from '$lib/stores/workspace'
import EntitySheet from '$lib/components/EntitySheet.svelte'
import CircleIcon from '@lucide/svelte/icons/circle'
import LoaderCircleIcon from '@lucide/svelte/icons/loader-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'
const done = $derived($planSteps.filter((s) => s.status === 'done').length)
const total = $derived($planSteps.length)
const pct = $derived(total > 0 ? Math.round((done / total) * 100) : 0)
let sheetSlug = $state<string | null>(null)
let sheetOpen = $state(false)
// Tool calls don't carry a step id, so a step can't be linked to its exact
// transcript entry — but its target entity IS known, and EntitySheet already
// gives a real, working detail view for any slug. Clicking a step with a
// target opens that, rather than a fake "scroll to it" that would silently
// no-op for a collapsed tool-call group.
function openStep(targetSlug: string | undefined) {
if (!targetSlug) return
sheetSlug = targetSlug
sheetOpen = true
}
</script>
{#if total > 0}
<div class="flex shrink-0 flex-col gap-2 border-b px-3 py-2.5">
<div class="flex items-center justify-between text-[11px] text-muted-foreground">
<span class="font-semibold uppercase tracking-wider">Plan</span>
<span>{done}/{total}</span>
</div>
<div class="h-1 w-full overflow-hidden rounded-full bg-muted">
<div class="h-full rounded-full bg-primary transition-all duration-500" style="width: {pct}%"></div>
</div>
<ol class="flex flex-col gap-1">
{#each $planSteps as step (step.id)}
<li>
<button
type="button"
class="flex w-full items-start gap-2 rounded px-1 py-1 text-left text-xs {step.target_slug ? 'hover:bg-muted/50' : 'cursor-default'}"
onclick={() => openStep(step.target_slug)}
>
<span class="mt-0.5 shrink-0">
{#if step.status === 'done'}
<CircleCheckIcon class="size-3.5 text-success" />
{:else if step.status === 'failed'}
<CircleXIcon class="size-3.5 text-destructive" />
{:else if step.status === 'running'}
<LoaderCircleIcon class="size-3.5 animate-spin text-primary" />
{:else if step.status === 'skipped'}
<CircleSlashIcon class="size-3.5 text-muted-foreground" />
{:else if step.status === 'blocked'}
<CirclePauseIcon class="size-3.5 text-warning" />
{:else}
<CircleIcon class="size-3.5 text-muted-foreground" />
{/if}
</span>
<span class="min-w-0 flex-1">
<span class="block leading-snug {step.status === 'done' ? 'text-muted-foreground line-through decoration-muted-foreground/40' : ''}">
{step.title}
</span>
{#if step.target_slug}
<span class="font-mono text-[10px] text-muted-foreground">{step.target_slug}</span>
{/if}
</span>
</button>
</li>
{/each}
</ol>
</div>
{/if}
<EntitySheet slug={sheetSlug} bind:open={sheetOpen} />

View File

@@ -1,24 +1,32 @@
<script lang="ts">
import { fetchSessionDigest, type SessionDigest } from '$lib/api'
import { currentSession, streaming } from '$lib/stores/chat'
import { currentTask } from '$lib/stores/workspace'
import { Badge } from '$lib/components/ui/badge'
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
import SparklesIcon from '@lucide/svelte/icons/sparkles'
import CircleCheckIcon from '@lucide/svelte/icons/circle-check'
import CircleXIcon from '@lucide/svelte/icons/circle-x'
let digest = $state<SessionDigest | null>(null)
let open = $state(false)
let loadedFor = $state<string | null>(null)
// Keyed on session id AND status: a task that completes mid-view (via
// resumeSession running server-side, with $streaming never true here) must
// still refetch once outcome/summary land, not just on session switch.
let loadedKey = $state<string | null>(null)
// Reload the digest whenever the session changes or a stream finishes —
// "what did this session actually do" is only meaningful once executions
// have had a chance to land.
// Reload the digest whenever the session changes, the task's status changes
// (e.g. it just completed), or a stream finishes — "what did this session
// actually do" is only meaningful once executions have had a chance to land.
$effect(() => {
const sid = $currentSession
const busy = $streaming
const status = $currentTask?.status ?? ''
if (!sid || busy) return
if (loadedFor === sid) return
loadedFor = sid
const key = `${sid}:${status}`
if (loadedKey === key) return
loadedKey = key
fetchSessionDigest(sid).then((d) => (digest = d))
})
@@ -30,6 +38,23 @@
}
</script>
{#if $currentTask?.outcome}
<div
class="flex items-start gap-2 border-b px-3 py-2 text-xs {$currentTask.outcome === 'failure'
? 'bg-destructive/5 text-destructive'
: $currentTask.outcome === 'partial'
? 'bg-warning/5 text-warning'
: 'bg-success/5 text-success'}"
>
{#if $currentTask.outcome === 'failure'}
<CircleXIcon class="mt-0.5 size-3.5 shrink-0" />
{:else}
<CircleCheckIcon class="mt-0.5 size-3.5 shrink-0" />
{/if}
<span class="leading-snug">{$currentTask.summary || `Task ${$currentTask.outcome}.`}</span>
</div>
{/if}
{#if digest && digest.total_executions > 0}
<div class="border-b">
<button

View File

@@ -12,6 +12,7 @@
} from 'd3-force'
import { fetchGraph, type Entity } from '$lib/api'
import { messages } from '$lib/stores/chat'
import { touched, healthDiffs } from '$lib/stores/workspace'
import { relativeTime } from '$lib/utils'
import { Badge } from '$lib/components/ui/badge'
import { Button } from '$lib/components/ui/button'
@@ -226,6 +227,21 @@
return slug.split(':').pop() ?? slug
}
// Live touch/health-diff lookups, keyed by slug for O(1) per-node checks
// during render. Kept as plain objects (not Maps) since Svelte 5 runes track
// object identity fine and this is small (≤12 touched, ≤8 diffs).
const touchedBySlug = $derived.by(() => {
const m: Record<string, true> = {}
for (const t of $touched) m[t.slug] = true
return m
})
const diffBySlug = $derived.by(() => {
const m: Record<string, { from: string; to: string }> = {}
for (const d of $healthDiffs) if (!(d.slug in m)) m[d.slug] = d
return m
})
const nowTouching = $derived($touched[0] ?? null)
function endpoint(end: string | Node): Node | undefined {
return typeof end === 'object' ? end : nodes.find((n) => n.slug === end)
}
@@ -290,6 +306,12 @@
<span class="text-[11px] text-muted-foreground">{nodes.length} {nodes.length === 1 ? 'entity' : 'entities'}</span>
{/if}
</div>
{#if nowTouching}
<div class="flex shrink-0 items-center gap-1.5 border-b bg-primary/5 px-3 py-1.5 text-[11px] text-primary">
<span class="size-1.5 animate-pulse rounded-full bg-primary"></span>
Now touching <code class="font-mono">{nowTouching.slug}</code>
</div>
{/if}
<div bind:this={container} class="relative min-h-0 flex-1 overflow-hidden">
{#if nodes.length === 0}
@@ -353,6 +375,8 @@
{@const r = nodeRadius(node)}
{@const isSel = selected?.slug === node.slug}
{@const dim = selected && !isSel && !selectedRelations.some((rel) => rel.other === node.slug)}
{@const isTouched = node.slug in touchedBySlug}
{@const diff = diffBySlug[node.slug]}
<g
transform="translate({node.x},{node.y})"
class="cursor-pointer"
@@ -365,6 +389,12 @@
{#if isSel}
<circle r={r + 5} fill={nodeColor(node)} opacity="0.25" />
{/if}
{#if isTouched}
<circle r={r + 4} fill="none" stroke="var(--primary)" stroke-width="1.5" opacity="0.8">
<animate attributeName="r" values="{r + 3};{r + 8};{r + 3}" dur="1.6s" repeatCount="indefinite" />
<animate attributeName="opacity" values="0.8;0.1;0.8" dur="1.6s" repeatCount="indefinite" />
</circle>
{/if}
<circle r={r} fill={nodeColor(node)} stroke={isSel ? 'var(--foreground)' : 'var(--background)'} stroke-width={isSel ? 2 : 1.5} />
<text
y={r + 10}
@@ -378,6 +408,20 @@
>
{shortName(node.slug)}
</text>
{#if diff}
<text
y={-r - 6}
text-anchor="middle"
font-size="8"
fill="var(--warning)"
paint-order="stroke"
stroke="var(--background)"
stroke-width="2.5"
class="pointer-events-none"
>
{diff.from}{diff.to}
</text>
{/if}
</g>
{/if}
{/each}

View File

@@ -0,0 +1,29 @@
<script lang="ts">
import { onMount } from 'svelte'
import { startWorkspace } from '$lib/stores/workspace'
import GoalHeader from './GoalHeader.svelte'
import PlanProgress from './PlanProgress.svelte'
import OperatorQuestion from './OperatorQuestion.svelte'
import SessionGraph from './SessionGraph.svelte'
import SessionDigest from './SessionDigest.svelte'
onMount(() => startWorkspace())
</script>
<!--
The task's live control panel: goal + status, plan progress, a pinned
question when the agent needs a decision, the live entity graph (pulses what
the agent is touching, flags health changes), and the outcome/knowledge
record once the task completes. Driven by the always-on events stream
(see workspace.ts) so it keeps updating during server-side auto-continuation,
not just while a chat turn is streaming.
-->
<div class="flex h-full min-h-0 flex-col">
<GoalHeader />
<PlanProgress />
<OperatorQuestion />
<div class="min-h-0 flex-1">
<SessionGraph />
</div>
<SessionDigest />
</div>

View File

@@ -0,0 +1,201 @@
import { writable, derived, get } from 'svelte/store'
import { liveEvents, subscribeEvents } from './events'
import { currentSession, sessions, loadSessions } from './chat'
import { fetchPlan, fetchQuestions, type PlanStep, type SessionQuestion } from '$lib/api'
// workspace.ts is the live "what is this task doing right now" surface for the
// TaskContextPanel: plan progress, the pinned operator question, and entities
// the agent is touching or whose health just changed. It is deliberately driven
// by the ALWAYS-ON global events stream (subscribeEvents), not the per-turn
// chat SSE — the auto-continuation worker and resumeSession run entirely
// server-side with no chat turn open, so a chat-bound panel would go stale
// exactly when the agent is working autonomously. This also means the panel
// keeps updating across a tab reload: hydrate() re-fetches REST state, then
// live events carry deltas from there.
export const planSteps = writable<PlanStep[]>([])
export const questions = writable<SessionQuestion[]>([])
export const openQuestion = derived(questions, (qs) => qs.find((q) => q.status === 'open') ?? null)
export interface TouchedEntity {
slug: string
tool: string
ts: number
}
export const touched = writable<TouchedEntity[]>([])
const TOUCHED_MAX = 12
const TOUCHED_PULSE_MS = 6000
export interface HealthDiff {
slug: string
from: string
to: string
ts: number
}
export const healthDiffs = writable<HealthDiff[]>([])
const HEALTH_DIFF_MS = 8000
// The task's own fields (goal/status/outcome/summary) live on the session row.
// Rather than a dedicated endpoint, derive from the sessions list (already
// fetched for the task board) and keep it fresh here on task-lifecycle events.
export const currentTask = derived([sessions, currentSession], ([$sessions, $id]) =>
$sessions.find((s) => s.id === $id) ?? null
)
// Events that can change agent_sessions.status/goal/outcome — see applyEvent.
const STATUS_AFFECTING = new Set([
'goal.set', 'task.status', 'plan.proposed', 'question.raised', 'question.answered'
])
let hydratedFor: string | null = null
let unsubStream: (() => void) | null = null
let unsubLive: (() => void) | null = null
let refreshTimer: ReturnType<typeof setTimeout> | null = null
let lastSeenId = 0
async function hydrate(sessionId: string) {
hydratedFor = sessionId
planSteps.set([])
questions.set([])
touched.set([])
healthDiffs.set([])
const [steps, qs] = await Promise.all([fetchPlan(sessionId), fetchQuestions(sessionId)])
if (get(currentSession) !== sessionId) return // switched away while loading
planSteps.set(steps)
questions.set(qs)
}
function applyPlanStepEvent(sessionId: string, type: string, data: any) {
const stepID = data?.step_id as string | undefined
const seq = data?.seq as number | undefined
planSteps.update((steps) => {
const i = steps.findIndex((s) => (stepID && s.id === stepID) || (seq != null && s.seq === seq))
if (i === -1) return steps
const next = [...steps]
next[i] = { ...next[i], status: data.status ?? next[i].status, execution_id: data.execution_id ?? next[i].execution_id }
return next
})
}
function applyEvent(ev: { type: string; correlation_id?: string | null; data?: unknown }) {
const sid = get(currentSession)
if (!sid || ev.correlation_id !== sid) return
const data = (ev.data ?? {}) as any
// Task fields (status/goal/outcome) live on the session row — refetch the
// (cheap) session list so GoalHeader picks up the change without a
// dedicated endpoint. Every event that can change agent_sessions.status
// (goal.set → planning, propose_plan → executing, ask_operator →
// awaiting_input, answerQuestion → executing, complete_task → done/failed)
// must trigger this, not just goal.set/task.status — otherwise the status
// pill goes stale exactly when resumeSession runs the next turn entirely
// server-side, with no client-streaming 'done' event to piggyback a refresh
// on (found live: answering a question via the panel left the header stuck
// on "Needs your input" after the agent had already resumed). Debounced
// since several of these can land in one burst.
if (STATUS_AFFECTING.has(ev.type)) {
if (refreshTimer) clearTimeout(refreshTimer)
refreshTimer = setTimeout(() => loadSessions(), 300)
}
switch (ev.type) {
case 'plan.proposed':
if (Array.isArray(data.steps)) {
planSteps.set(
data.steps.map((s: any) => ({
id: s.id, seq: s.seq, title: s.title, detail: s.detail ?? '',
status: 'pending', target_slug: s.target_slug || undefined
}))
)
}
break
case 'plan.step.started':
case 'plan.step.finished':
applyPlanStepEvent(sid, ev.type, data)
break
case 'question.raised':
questions.update((qs) => [
{
id: data.question_id, prompt: data.prompt ?? '',
context: { why: data.why, options: data.options, entities: data.entities },
status: 'open', created_at: new Date().toISOString()
},
...qs.filter((q) => q.id !== data.question_id)
])
break
case 'question.answered':
questions.update((qs) =>
qs.map((q) => (q.id === data.question_id ? { ...q, status: 'answered', answer: data.answer } : q))
)
break
case 'entity.touched':
if (data.slug) {
const now = Date.now()
touched.update((t) => [{ slug: data.slug, tool: data.tool ?? '', ts: now }, ...t].slice(0, TOUCHED_MAX))
}
break
case 'knowledge.recorded':
// No dedicated store yet — the outcome/knowledge card reads this task's
// digest (fetchSessionDigest) on completion, which already lists it.
break
}
}
// health.changed is task-agnostic (fleet-wide), so it's matched separately:
// show the diff whenever the changed entity is one this task has touched, not
// by correlation_id (health events don't carry one).
function applyHealthChanged(ev: { type: string; data?: unknown }) {
if (ev.type !== 'health.changed') return
const data = (ev.data ?? {}) as any
if (!data.slug) return
const isRelevant = get(touched).some((t) => t.slug === data.slug)
if (!isRelevant) return
healthDiffs.update((d) => [{ slug: data.slug, from: data.from, to: data.to, ts: Date.now() }, ...d].slice(0, 8))
}
// startWorkspace opens the global event subscription and begins tracking the
// active session. Call once from the panel's onMount; call the returned
// cleanup on unmount. Safe to call multiple times (ref-counted underneath).
export function startWorkspace(): () => void {
unsubStream = subscribeEvents()
const unsubSession = currentSession.subscribe((sid) => {
if (sid && sid !== hydratedFor) hydrate(sid)
if (!sid) {
hydratedFor = null
planSteps.set([])
questions.set([])
touched.set([])
healthDiffs.set([])
}
})
unsubLive = liveEvents.subscribe((evs) => {
if (evs.length === 0) return
const maxId = evs[0].id
if (maxId <= lastSeenId) {
return
}
const fresh = evs.filter((e) => e.id > lastSeenId)
lastSeenId = maxId
// Oldest-first application so ordering (e.g. plan.step.started before
// .finished) is preserved.
for (const e of fresh.slice().reverse()) {
applyEvent(e)
applyHealthChanged(e)
}
})
return () => {
unsubSession()
unsubLive?.()
unsubStream?.()
}
}
// Sweep expired pulses/diffs on an interval so old touches stop glowing.
setInterval(() => {
const now = Date.now()
touched.update((t) => t.filter((e) => now - e.ts < TOUCHED_PULSE_MS))
healthDiffs.update((d) => d.filter((e) => now - e.ts < HEALTH_DIFF_MS))
}, 1000)

View File

@@ -1,8 +1,7 @@
<script lang="ts">
import { messages, streaming, sendMessage, cancelStream, error } from '$lib/stores/chat'
import SessionRail from '$lib/components/SessionRail.svelte'
import SessionGraph from '$lib/components/SessionGraph.svelte'
import SessionDigest from '$lib/components/SessionDigest.svelte'
import TaskContextPanel from '$lib/components/TaskContextPanel.svelte'
import ToolCallGroup from '$lib/components/ToolCallGroup.svelte'
import InlineApproval from '$lib/components/InlineApproval.svelte'
import { Button } from '$lib/components/ui/button'
@@ -181,7 +180,7 @@
type="button"
class="group/rz relative w-1.5 shrink-0 cursor-col-resize touch-none"
onpointerdown={startResize}
aria-label="Resize session graph"
aria-label="Resize task panel"
>
<span
class="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 transition-colors {resizing
@@ -190,10 +189,7 @@
></span>
</button>
<div class="flex min-w-0 flex-1 flex-col">
<SessionDigest />
<div class="min-h-0 flex-1">
<SessionGraph />
</div>
<TaskContextPanel />
</div>
</div>
{/if}