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