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:
@@ -72,6 +72,21 @@ function mid(): string {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
|
||||
// dropOptimisticAssistantBubble removes the trailing empty assistant message
|
||||
// that sendSessionMessage/startTask optimistically append — used when a turn is
|
||||
// QUEUED behind an in-flight one (plan 2026-08-03 F2): no live assistant stream
|
||||
// is attached, so the empty placeholder must go (otherwise it lingers as a
|
||||
// blank bubble). Shared so the guard can't drift between the two call sites.
|
||||
function dropOptimisticAssistantBubble(messages: Writable<ChatMessage[]>): void {
|
||||
messages.update((ms) => {
|
||||
const last = ms[ms.length - 1]
|
||||
if (last && last.role === 'assistant' && last.text === '' && last.tools.length === 0) {
|
||||
return ms.slice(0, -1)
|
||||
}
|
||||
return ms
|
||||
})
|
||||
}
|
||||
|
||||
export const messages = writable<ChatMessage[]>([])
|
||||
export const streaming = writable(false)
|
||||
export const connectionState = writable<'connected' | 'disconnected' | 'reconnecting'>('connected')
|
||||
@@ -657,6 +672,16 @@ export function sendSessionMessage(sessionId: string, text: string) {
|
||||
sessionId,
|
||||
(ev: ChatEvent) => {
|
||||
if (ev.type === 'session') return // sessionId is already known for a window
|
||||
if (ev.type === 'queued') {
|
||||
// This message was queued behind an in-flight turn (plan 2026-08-03
|
||||
// F2): no assistant stream is attached to this response. Drop the
|
||||
// optimistic empty assistant bubble so the user message is the last
|
||||
// thing on screen — the thread then shows a "Queued" hint while the
|
||||
// session is working, and the poller surfaces the queued turn's
|
||||
// result once it runs server-side.
|
||||
dropOptimisticAssistantBubble(chat.messages)
|
||||
return
|
||||
}
|
||||
if (ev.type === 'tool_use') {
|
||||
const tr: ToolCallResult = {
|
||||
type: 'tool_use',
|
||||
@@ -791,6 +816,13 @@ export function startTask(text: string, onSession: (sessionId: string) => void):
|
||||
function apply(ev: ChatEvent) {
|
||||
const c = chat
|
||||
if (!c || !sessionId) return
|
||||
if (ev.type === 'queued') {
|
||||
// Defensive: a brand-new task won't normally queue (its session has no
|
||||
// in-flight turn), but handle it symmetrically with sendSessionMessage —
|
||||
// drop the optimistic empty assistant bubble. See plan 2026-08-03 F2.
|
||||
dropOptimisticAssistantBubble(c.messages)
|
||||
return
|
||||
}
|
||||
if (ev.type === 'tool_use') {
|
||||
const tr: ToolCallResult = {
|
||||
type: 'tool_use',
|
||||
|
||||
Reference in New Issue
Block a user