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:
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>
|
||||
Reference in New Issue
Block a user