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

@@ -24,6 +24,7 @@
messages,
streaming,
connectionState,
working = false,
error = null,
chatErrors = [],
onSend,
@@ -39,6 +40,12 @@
messages: ChatMessage[]
streaming: boolean
connectionState: 'connected' | 'disconnected' | 'reconnecting'
/** True while a turn is running for this session — a live stream OR the
* server-side status says planning/executing. Drives the "working"
* indicator so a background/long/desynced turn still looks alive. The
* literal `streaming` (live deltas) is still used for the cursor + input
* lock. See plan 2026-08-03 F1. */
working?: boolean
error?: string | null
chatErrors?: { id: string; message: string; action?: string }[]
onSend: (text: string) => void
@@ -63,18 +70,18 @@
let container = $state<HTMLDivElement | null>(null)
let indicatorDone = $state(false)
let wasStreaming = $state(false)
let wasWorking = $state(false)
$effect(() => {
if (streaming) {
if (working) {
indicatorDone = false
wasStreaming = true
wasWorking = true
}
if (!streaming && wasStreaming) {
if (!working && wasWorking) {
indicatorDone = true
const t = setTimeout(() => {
indicatorDone = false
wasStreaming = false
wasWorking = false
}, 3000)
return () => clearTimeout(t)
}
@@ -82,7 +89,7 @@
const indicatorLabel = $derived.by(() => {
if (error) return error
if (!streaming && indicatorDone) return 'Done'
if (!working && indicatorDone) return 'Done'
// Prefer the running PLAN STEP as the headline — it's stable across the
// step's many tool calls, so the line stops rewriting itself on every
// command (the "thinking overwrites itself" complaint, F6). Falls back to
@@ -282,13 +289,22 @@
>
{msg.text}
</div>
{#if idx === messages.length - 1 && working && !streaming}
<!-- The last message is this user bubble and the agent is
working but not live-streaming → the message was queued
behind an in-flight turn (plan 2026-08-03 F2). It'll run
when the current step finishes. -->
<span class="px-1 text-[10px] text-muted-foreground"
>Queued — Nomos will run this when it finishes the current step.</span
>
{/if}
{:else}
{@const isLast = idx === messages.length - 1}
{@const traceStatus = !isLast
? 'idle'
: error
? 'error'
: streaming
: working
? 'running'
: indicatorDone
? 'done'
@@ -399,6 +415,14 @@
class="flex h-full min-h-0 flex-col border-t bg-card/50 p-3 input-ornament relative"
bind:this={inputWrapperRef}
>
{#if working && !streaming}
<!-- Background/autonomous turn in progress (no live stream to watch):
keep the composer open so the operator can queue a follow-up
(plan 2026-08-03 F1/F2). -->
<div class="mb-1 px-1 text-[10px] text-muted-foreground">
Nomos is working in the background — your message will queue and run when it's free.
</div>
{/if}
<form
class="relative mx-auto flex h-full w-full max-w-3xl"
onsubmit={(e) => {