feat(nomos): chat working-visibility, message queue, generation-aware timeline
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled

Make background/long/desynced turns visible and queueable, fixing the four
symptoms that survived the v0.15.0 chat reliability pass.

F1 - status-driven working signal (workspace.ts taskWorking/currentWorking =
streaming OR status in {planning,executing}). Drives the chat trace, indicator,
and activity spinner so a turn with no live stream (background resume, a dropped
SSE, an idle-close mid long turn) still looks alive.

F2 - operator messages sent during an in-flight turn are now QUEUED and
auto-run when the gate frees, replacing the "still finishing a previous step...
send it again" rejection. Per-session in-memory FIFO (messagequeue.go, capped at
20) drained one-at-a-time under the turn gate; a `queued` SSE event drives a
"Queued" hint. drainQueued releases via a per-iteration deferred closure so a
runChatTurn panic can't deadlock the session's gate.

F3 - SSE keepalive (12s `:keepalive` comment) in handleChat so 20-40s
inter-iteration gaps no longer trip a proxy/browser idle close (the desync root
cause). All SSE writes serialized through one mutex.

F4 - generation-aware activity timeline (only the last propose_plan renders;
superseded ones collapse to one "Earlier plan revised" marker; step-attribution
follows only the current generation) + debounced plan refetch on lifecycle
events so a missed plan.proposed self-heals.

Verified against the last session (23da10db: 6m33s turn, operator "status"
deferred at 19:48:05). go test ./cmd/nomos/ green (new messagequeue tests);
web vitest 72/72 (new F4 generation tests); vite build clean.

VERSION: 0.16.0 -> 0.17.0
This commit is contained in:
2026-08-03 22:34:14 +02:00
parent 757ef2f34b
commit 5b68bdc16c
17 changed files with 877 additions and 136 deletions

View File

@@ -149,13 +149,45 @@ export function computeActivityLog(
// Tool calls (from messages). Tag each tool with the plan step that's
// currently running when it fires.
//
// Generation awareness (plan 2026-08-03 F4): a re-proposed task persists
// every generation's propose_plan/update_plan_step calls. Without scoping,
// the timeline rendered N "Proposed plan" entries and inferred
// currentStepSeq from superseded generations — tools landed under the wrong
// (current-gen) step and it read as "several plans, some never run." So:
// only the LAST propose_plan is the live plan; earlier ones collapse to a
// single "Earlier plan revised" marker, and update_plan_step step-tracking
// only applies to the current generation.
let lastPlanMi = -1
let lastPlanTi = -1
let planCount = 0
for (let mi = 0; mi < $msgs.length; mi++) {
for (let ti = 0; ti < $msgs[mi].tools.length; ti++) {
if ($msgs[mi].tools[ti].name === 'propose_plan') {
planCount++
lastPlanMi = mi
lastPlanTi = ti
}
}
}
const revised = planCount > 1
let currentStepSeq = 0
let entryIdx = 0
// No plan at all (plan-less Q&A) → treat the whole transcript as current.
let sawCurrentPlan = planCount === 0
let emittedRevised = false
for (let mi = 0; mi < $msgs.length; mi++) {
const msgTs = tsOf($msgs[mi].created_at)
for (const t of $msgs[mi].tools) {
// Track current step from update_plan_step calls
if (t.type === 'tool_use' && t.name === 'update_plan_step') {
for (let ti = 0; ti < $msgs[mi].tools.length; ti++) {
const t = $msgs[mi].tools[ti]
const isLastPlan = mi === lastPlanMi && ti === lastPlanTi
if (isLastPlan) sawCurrentPlan = true
// Track current step ONLY from the current generation's
// update_plan_step calls; a superseded generation's seqs would tag
// tools with the wrong (current-gen) step.
if (sawCurrentPlan && t.type === 'tool_use' && t.name === 'update_plan_step') {
const s = typeof t.args?.seq === 'number' ? t.args.seq : undefined
const status = typeof t.args?.status === 'string' ? t.args.status : undefined
if (s && status === 'running') currentStepSeq = s
@@ -163,6 +195,23 @@ export function computeActivityLog(
currentStepSeq = 0
}
// Skip superseded-generation propose_plan entries; emit one collapsed
// "revised" marker so a re-proposal stays visible without reading as a
// second active plan.
if (t.name === 'propose_plan' && !isLastPlan) {
if (revised && !emittedRevised) {
emittedRevised = true
entries.push({
id: `plan_revised_${mi}_${ti}`,
type: 'plan',
description: 'Earlier plan revised',
timestamp: freeze(`plan_revised_${mi}_${ti}`, msgTs),
status: 'done'
})
}
continue
}
const label = toolActivityLabel(t)
const stepTag = currentStepSeq > 0 ? currentStepSeq : undefined
const id = t.id ?? `tool_${mi}_${entryIdx++}`