feat(nomos): per-session turn serialization + chat reliability/UX fixes
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

The agent could run two turns for one session at once (a reconnect resumed
while the live turn was still going), and their interleaved tool calls
corrupted the activity panel, fabricated a confusing "parallel/nested"
sequence, and made tasks feel stuck/never-ending. Several UX gaps compounded it.

Turn serialization (F1):
- turnGate: at most one in-flight turn per session. Background resume paths
  (continuation worker, idle sweep, answer-question, /resume, reconnect)
  skip non-blocking when busy; the live chat path waits briefly then bails
  cleanly instead of stacking a second turn.
- resumeSession returns whether it ran; continueSession marks an execution
  "continued" only after a real run (review P0) so a busy-skip can't lose a
  finished-execution result. Idle nudge bumps only after delivery (P1).

Connection state (F2/F3, web):
- humanize/bucket raw errors ("model connection dropped..."); one surface
  per drop; a terminal task.status event clears stuck streaming/disconnected
  state and dismisses the connection toast. Reconnect no longer spawns turns.

Streaming where you look (F4, web):
- live command output in the global activity timeline and in the inline
  tool card (auto-opened, tail-pinned) -- not just the per-window rail.

Other (web): artifact/knowledge deep links (F5); step-first stable
"thinking" headline (F6); stable chat layout, no empty->content reflow (F7);
lazy event sync (P2.2); reconnect skips a terminal session (P2.1).

VERSION: 0.14.2 -> 0.15.0
This commit is contained in:
2026-08-03 15:42:10 +02:00
parent bb05f215c6
commit 39e9227fdb
18 changed files with 1197 additions and 153 deletions

View File

@@ -17,6 +17,7 @@
import { marked } from 'marked'
import DOMPurify from 'dompurify'
import type { ChatMessage } from '$lib/stores/chat'
import type { ToolCallResult } from '$lib/types'
import type { SessionQuestion } from '$lib/api'
let {
@@ -83,8 +84,15 @@
const indicatorLabel = $derived.by(() => {
if (error) return error
if (!streaming && indicatorDone) return 'Done'
const running = $activityLogProp.find((e: ActivityEntry) => e.status === 'running')
if (running) return running.description
// 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
// the current tool only when there's no active step (a plan-less Q&A or
// between steps), and to a plain "thinking…" otherwise.
const runningStep = $activityLogProp.find((e: ActivityEntry) => e.type === 'step_running')
if (runningStep) return runningStep.description
const runningTool = $activityLogProp.find((e: ActivityEntry) => e.type === 'tool_running')
if (runningTool) return runningTool.description
return 'Agent is thinking…'
})
@@ -195,6 +203,27 @@
if (streaming) return
onSend(q)
}
// Merge live streaming output (from the activity log's `run` entry) onto the
// in-flight turn's tool calls so the inline tool card shows command output as
// it arrives — the place the operator naturally "checks the tool". Only the
// last assistant message can be streaming, so only it gets enriched; history
// is untouched (and has no live output anyway). (F4)
function toolsWithLive(
tools: ToolCallResult[],
entries: ActivityEntry[],
isLiveTurn: boolean
): ToolCallResult[] {
if (!isLiveTurn) return tools
const liveById = new Map<string, string>()
for (const e of entries) {
if (e.liveOutput && e.id) liveById.set(e.id, e.liveOutput)
}
if (liveById.size === 0) return tools
return tools.map((t) =>
t.id && liveById.has(t.id) ? { ...t, liveOutput: liveById.get(t.id) } : t
)
}
</script>
<div class="flex h-full min-h-0 min-w-0 flex-col" bind:clientHeight={threadHeight}>
@@ -274,7 +303,7 @@
the text below it. -->
{#if msg.tools.length > 0 || traceStatus !== 'idle'}
<AgentTrace
tools={msg.tools}
tools={toolsWithLive(msg.tools, $activityLogProp, isLast && traceStatus !== 'idle')}
status={traceStatus}
label={traceStatus === 'idle' ? null : indicatorLabel}
/>
@@ -306,9 +335,10 @@
<div
class="mb-2 flex items-center gap-2 rounded-md border border-warning/50 bg-warning/10 px-3 py-2 text-xs"
>
<RefreshCwIcon class="size-3 shrink-0" aria-hidden="true" />
<RefreshCwIcon class="size-3 shrink-0 animate-spin text-warning" aria-hidden="true" />
<span class="text-warning-foreground flex-1"
>Agent connection lost. The task may still be running.</span
>Connection dropped — the task is still running and will catch up here automatically.
Reconnect to refresh now.</span
>
<Button size="xs" variant="outline" class="h-6 text-[11px]" onclick={onReconnect}
>Reconnect</Button