From 991e7d0900bc8f5daae88ec070f035e08fb52159 Mon Sep 17 00:00:00 2001 From: dtoro Date: Sat, 11 Jul 2026 13:52:36 +0200 Subject: [PATCH] =?UTF-8?q?feat(tasks):=20phase=206=20=E2=80=94=20live=20T?= =?UTF-8?q?askContextPanel=20(goal,=20plan,=20question,=20entities)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 "; 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 --- cmd/nomos/main.go | 26 +++ cmd/nomos/store.go | 82 +++++++ web/src/lib/api.ts | 45 ++++ web/src/lib/components/GoalHeader.svelte | 38 ++++ .../lib/components/OperatorQuestion.svelte | 76 +++++++ web/src/lib/components/PlanProgress.svelte | 77 +++++++ web/src/lib/components/SessionDigest.svelte | 37 +++- web/src/lib/components/SessionGraph.svelte | 44 ++++ .../lib/components/TaskContextPanel.svelte | 29 +++ web/src/lib/stores/workspace.ts | 201 ++++++++++++++++++ web/src/pages/Chat.svelte | 10 +- 11 files changed, 652 insertions(+), 13 deletions(-) create mode 100644 web/src/lib/components/GoalHeader.svelte create mode 100644 web/src/lib/components/OperatorQuestion.svelte create mode 100644 web/src/lib/components/PlanProgress.svelte create mode 100644 web/src/lib/components/TaskContextPanel.svelte create mode 100644 web/src/lib/stores/workspace.ts diff --git a/cmd/nomos/main.go b/cmd/nomos/main.go index 5b70592..1bc5f66 100644 --- a/cmd/nomos/main.go +++ b/cmd/nomos/main.go @@ -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 { diff --git a/cmd/nomos/store.go b/cmd/nomos/store.go index 686f705..b6c515b 100644 --- a/cmd/nomos/store.go +++ b/cmd/nomos/store.go @@ -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. diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 92f8c43..77a8353 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -44,6 +44,51 @@ export async function deleteSession(sessionId: string): Promise { 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 { + 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 { + 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 { + 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 diff --git a/web/src/lib/components/GoalHeader.svelte b/web/src/lib/components/GoalHeader.svelte new file mode 100644 index 0000000..eed55cf --- /dev/null +++ b/web/src/lib/components/GoalHeader.svelte @@ -0,0 +1,38 @@ + + +{#if $currentTask} + {@const st = statusStyle($currentTask.status, $currentTask.outcome)} +
+
+ + {st.label} +
+

+ {$currentTask.goal || $currentTask.title || 'Untitled task'} +

+
+{/if} diff --git a/web/src/lib/components/OperatorQuestion.svelte b/web/src/lib/components/OperatorQuestion.svelte new file mode 100644 index 0000000..d739578 --- /dev/null +++ b/web/src/lib/components/OperatorQuestion.svelte @@ -0,0 +1,76 @@ + + +{#if $openQuestion} + {@const q = $openQuestion} +
+
+ +
+

{q.prompt}

+ {#if q.context.why} +

{q.context.why}

+ {/if} +
+
+ + {#if q.context.entities?.length} +
+ {#each q.context.entities as slug} + {slug} + {/each} +
+ {/if} + + {#if q.context.options?.length} +
+ {#each q.context.options as opt} + + {/each} +
+ {/if} + +
+