feat(nomos): chat working-visibility, message queue, generation-aware timeline
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:
@@ -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) => {
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
chatErrors
|
||||
} from '$lib/stores/chat'
|
||||
import { activityLogFor } from '$lib/stores/activity'
|
||||
import { workspaceFor, startSessionWorkspace } from '$lib/stores/workspace'
|
||||
import { workspaceFor, startSessionWorkspace, taskWorking } from '$lib/stores/workspace'
|
||||
import ChatThread from '$lib/components/ChatThread.svelte'
|
||||
import TaskContextPanel from '$lib/components/TaskContextPanel.svelte'
|
||||
|
||||
@@ -30,6 +30,8 @@
|
||||
const chat = chatFor(sessionId)
|
||||
const chatMessages = chat.messages
|
||||
const chatStreaming = chat.streaming
|
||||
// eslint-disable-next-line svelte/valid-compile
|
||||
const chatWorking = taskWorking(sessionId)
|
||||
const chatConnectionState = chat.connectionState
|
||||
const chatError = chat.error
|
||||
const chatNotFound = chat.notFound
|
||||
@@ -90,6 +92,7 @@
|
||||
<ChatThread
|
||||
messages={$chatMessages}
|
||||
streaming={$chatStreaming}
|
||||
working={$chatWorking}
|
||||
connectionState={$chatConnectionState}
|
||||
error={$chatError}
|
||||
chatErrors={$chatErrors}
|
||||
|
||||
@@ -5,12 +5,14 @@
|
||||
startWorkspace,
|
||||
planSteps,
|
||||
currentTask,
|
||||
currentWorking,
|
||||
touched,
|
||||
healthDiffs,
|
||||
workspaceFor,
|
||||
taskFor
|
||||
taskFor,
|
||||
taskWorking
|
||||
} from '$lib/stores/workspace'
|
||||
import { streaming, messages, chatFor } from '$lib/stores/chat'
|
||||
import { messages, chatFor } from '$lib/stores/chat'
|
||||
import { activityLog, activityLogFor } from '$lib/stores/activity'
|
||||
import SessionGraph from './SessionGraph.svelte'
|
||||
import UnifiedTimeline from './UnifiedTimeline.svelte'
|
||||
@@ -37,7 +39,7 @@
|
||||
const healthDiffsStore = $derived(ws ? ws.healthDiffs : healthDiffs)
|
||||
const taskStore = $derived(sessionId ? taskFor(sessionId) : currentTask)
|
||||
const chat = $derived(sessionId ? chatFor(sessionId) : null)
|
||||
const streamingStore = $derived(chat ? chat.streaming : streaming)
|
||||
const workingStore = $derived(sessionId ? taskWorking(sessionId) : currentWorking)
|
||||
const messagesStore = $derived(chat ? chat.messages : messages)
|
||||
const activityLogStore = $derived(sessionId ? activityLogFor(sessionId) : activityLog)
|
||||
|
||||
@@ -134,7 +136,7 @@
|
||||
class="size-3"
|
||||
/>{/if}
|
||||
<span>Activity</span>
|
||||
{#if $streamingStore && activityRunning > 0}
|
||||
{#if $workingStore && activityRunning > 0}
|
||||
<Spinner class="size-3 text-primary" />
|
||||
{/if}
|
||||
{#if planTotal > 0}
|
||||
@@ -161,7 +163,7 @@
|
||||
<UnifiedTimeline
|
||||
entries={$activityLogStore}
|
||||
planSteps={$planStepsStore}
|
||||
streaming={$streamingStore}
|
||||
streaming={$workingStore}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
{initialDraft}
|
||||
messages={[]}
|
||||
streaming={false}
|
||||
working={false}
|
||||
connectionState="connected"
|
||||
{onSend}
|
||||
onCancel={() => {}}
|
||||
|
||||
Reference in New Issue
Block a user