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:
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
38
web/src/lib/components/GoalHeader.svelte
Normal file
38
web/src/lib/components/GoalHeader.svelte
Normal 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}
|
||||
76
web/src/lib/components/OperatorQuestion.svelte
Normal file
76
web/src/lib/components/OperatorQuestion.svelte
Normal 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}
|
||||
77
web/src/lib/components/PlanProgress.svelte
Normal file
77
web/src/lib/components/PlanProgress.svelte
Normal 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} />
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
29
web/src/lib/components/TaskContextPanel.svelte
Normal file
29
web/src/lib/components/TaskContextPanel.svelte
Normal 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>
|
||||
201
web/src/lib/stores/workspace.ts
Normal file
201
web/src/lib/stores/workspace.ts
Normal 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)
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user