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>
126 lines
4.8 KiB
Svelte
126 lines
4.8 KiB
Svelte
<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)
|
||
// 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, 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
|
||
const key = `${sid}:${status}`
|
||
if (loadedKey === key) return
|
||
loadedKey = key
|
||
fetchSessionDigest(sid).then((d) => (digest = d))
|
||
})
|
||
|
||
function statusVariant(status: string): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||
if (['failed', 'denied', 'revoked', 'cancelled'].includes(status)) return 'destructive'
|
||
if (status === 'completed') return 'default'
|
||
if (['running', 'approved'].includes(status)) return 'secondary'
|
||
return 'outline'
|
||
}
|
||
</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
|
||
type="button"
|
||
class="flex w-full items-center justify-between gap-2 px-3 py-2 text-left text-xs font-medium hover:bg-muted/50"
|
||
onclick={() => (open = !open)}
|
||
>
|
||
<span class="flex items-center gap-1.5">
|
||
{#if open}<ChevronDownIcon class="size-3.5" />{:else}<ChevronRightIcon class="size-3.5" />{/if}
|
||
This session
|
||
</span>
|
||
<span class="flex items-center gap-1.5 text-muted-foreground">
|
||
{digest.total_executions} action{digest.total_executions === 1 ? '' : 's'}
|
||
{#if digest.knowledge_created.length}
|
||
<span class="flex items-center gap-0.5 text-primary">
|
||
<SparklesIcon class="size-3" />{digest.knowledge_created.length}
|
||
</span>
|
||
{/if}
|
||
</span>
|
||
</button>
|
||
|
||
{#if open}
|
||
<div class="flex flex-col gap-3 px-3 pb-3 text-xs">
|
||
<div class="flex flex-wrap gap-1">
|
||
{#each Object.entries(digest.by_status) as [status, count]}
|
||
<Badge variant={statusVariant(status)}>{status} × {count}</Badge>
|
||
{/each}
|
||
</div>
|
||
|
||
{#if digest.entities_touched.length}
|
||
<div>
|
||
<div class="mb-1 text-muted-foreground">Entities touched</div>
|
||
<div class="flex flex-wrap gap-1">
|
||
{#each digest.entities_touched as target}
|
||
<span class="rounded bg-muted px-1.5 py-0.5 font-mono text-[11px]">{target}</span>
|
||
{/each}
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
|
||
<div class="flex flex-col gap-1">
|
||
{#each digest.executions as ex}
|
||
<div class="flex items-start justify-between gap-2 rounded border px-2 py-1">
|
||
<div class="min-w-0">
|
||
<div class="font-mono text-[11px] text-muted-foreground">{ex.target}</div>
|
||
<div class="truncate">{ex.summary || ex.verb}</div>
|
||
</div>
|
||
<Badge variant={statusVariant(ex.status)} class="shrink-0">{ex.status}</Badge>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
|
||
{#if digest.knowledge_created.length}
|
||
<div>
|
||
<div class="mb-1 flex items-center gap-1 text-primary">
|
||
<SparklesIcon class="size-3" />Learned this session
|
||
</div>
|
||
<ul class="list-inside list-disc">
|
||
{#each digest.knowledge_created as title}
|
||
<li>{title}</li>
|
||
{/each}
|
||
</ul>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{/if}
|