feat(nomos): per-session turn serialization + chat reliability/UX fixes
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:
@@ -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
|
||||
|
||||
@@ -37,30 +37,21 @@
|
||||
const sessionActivityLog = activityLogFor(sessionId)
|
||||
// Started here (rather than left to TaskContextPanel's own onMount) so the
|
||||
// workspace is already tracking touched entities/plan/questions before the
|
||||
// context rail ever mounts — it needs that live even while the rail stays
|
||||
// hidden (see hasContext below).
|
||||
// context rail mounts.
|
||||
// eslint-disable-next-line svelte/valid-compile
|
||||
const workspace = workspaceFor(sessionId)
|
||||
// eslint-disable-next-line svelte/valid-compile
|
||||
const touchedEntities = workspace.touched
|
||||
// eslint-disable-next-line svelte/valid-compile
|
||||
const openQuestion = workspace.openQuestion
|
||||
let loading = $state(true)
|
||||
|
||||
// The context rail (Scope/Activity) is only worth its screen space once
|
||||
// there's something in it — a brand-new task otherwise opens to an empty
|
||||
// "entities appear here" placeholder next to an equally empty activity
|
||||
// list. Show it the moment either has real content, and keep it shown
|
||||
// from then on (no flicker back to hidden if e.g. touched entities later
|
||||
// expire). An open question does NOT gate this anymore — it renders
|
||||
// inline in the chat thread itself (see ChatThread's `question` prop
|
||||
// below), not in this rail.
|
||||
let hasContext = $state(false)
|
||||
$effect(() => {
|
||||
if (!hasContext && ($sessionActivityLog.length > 0 || $touchedEntities.length > 0)) {
|
||||
hasContext = true
|
||||
}
|
||||
})
|
||||
// F7 (plan 2026-08-03): the rail used to mount on demand (hasContext gate),
|
||||
// which DESTROYED and remounted the ChatThread — losing the input draft and
|
||||
// scroll position — and reflowed the chat column the moment the first
|
||||
// activity/touched entity landed ("layout looks off when a chat goes from
|
||||
// empty to content"). The layout is now stable from the moment the window
|
||||
// opens: one Splitpanes, one ChatThread, the rail always present showing
|
||||
// its own empty state ("Waiting for activity…") until there's something to
|
||||
// show. A stable-but-initially-quiet rail is a better trade than a jumping
|
||||
// layout.
|
||||
|
||||
// startSessionWorkspace's cleanup is registered via onDestroy below rather
|
||||
// than returned from this callback — onMount ignores a returned function
|
||||
@@ -93,7 +84,7 @@
|
||||
<p class="text-sm text-muted-foreground">Task not found.</p>
|
||||
<p class="text-xs text-muted-foreground/70">It may have been deleted.</p>
|
||||
</div>
|
||||
{:else if hasContext}
|
||||
{:else}
|
||||
<Splitpanes theme="oikos-theme" dblClickSplitter={false}>
|
||||
<Pane>
|
||||
<ChatThread
|
||||
@@ -115,20 +106,5 @@
|
||||
<TaskContextPanel {sessionId} />
|
||||
</Pane>
|
||||
</Splitpanes>
|
||||
{:else}
|
||||
<ChatThread
|
||||
messages={$chatMessages}
|
||||
streaming={$chatStreaming}
|
||||
connectionState={$chatConnectionState}
|
||||
error={$chatError}
|
||||
chatErrors={$chatErrors}
|
||||
activityLog={sessionActivityLog}
|
||||
{sessionId}
|
||||
question={$openQuestion}
|
||||
onSend={(text) => sendSessionMessage(sessionId, text)}
|
||||
onCancel={() => cancelSessionStream(sessionId)}
|
||||
onReconnect={() => loadSessionChat(sessionId)}
|
||||
onDismissError={dismissError}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
let { tool }: { tool: ToolCallResult } = $props()
|
||||
let expanded = $state(false)
|
||||
let liveEl = $state<HTMLPreElement | null>(null)
|
||||
|
||||
const status = $derived.by(() => {
|
||||
if (tool.type === 'tool_use') return 'running'
|
||||
@@ -16,6 +17,15 @@
|
||||
return 'done'
|
||||
})
|
||||
|
||||
// Auto-open while a command is streaming its output, so the operator sees it
|
||||
// without an extra click — mirrors UnifiedTimeline. Once the tool_result
|
||||
// lands (status flips off running) liveOutput clears and the card respects
|
||||
// the manual toggle again. (F4)
|
||||
const open = $derived(expanded || !!tool.liveOutput)
|
||||
$effect(() => {
|
||||
if (tool.liveOutput && liveEl) liveEl.scrollTop = liveEl.scrollHeight
|
||||
})
|
||||
|
||||
const label = $derived(toolActivityLabel(tool))
|
||||
|
||||
const argsSummary = $derived.by(() => {
|
||||
@@ -36,8 +46,8 @@
|
||||
<button
|
||||
class="flex w-full items-start gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-muted/40 disabled:cursor-default"
|
||||
onclick={() => (expanded = !expanded)}
|
||||
aria-expanded={expanded}
|
||||
disabled={!hasDetail}
|
||||
aria-expanded={open}
|
||||
disabled={!hasDetail && !tool.liveOutput}
|
||||
>
|
||||
<span class="mt-px shrink-0 {status === 'error' ? 'text-destructive' : 'text-primary'}">
|
||||
{#if status === 'running'}
|
||||
@@ -57,17 +67,30 @@
|
||||
{/if}
|
||||
</span>
|
||||
<span class="shrink-0 font-mono text-[10px] text-muted-foreground/50">{tool.name}</span>
|
||||
{#if hasDetail}
|
||||
{#if hasDetail || tool.liveOutput}
|
||||
<ChevronRight
|
||||
class="mt-px size-3 shrink-0 text-muted-foreground/50 transition-transform {expanded
|
||||
class="mt-px size-3 shrink-0 text-muted-foreground/50 transition-transform {open
|
||||
? 'rotate-90'
|
||||
: ''}"
|
||||
/>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if expanded}
|
||||
{#if open}
|
||||
<div class="space-y-2 px-2 pb-2 pl-7">
|
||||
{#if tool.liveOutput}
|
||||
<div>
|
||||
<div
|
||||
class="mb-1 flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wider text-primary"
|
||||
>
|
||||
<Loader2 class="size-2.5 animate-spin" />
|
||||
Live output
|
||||
</div>
|
||||
<pre
|
||||
bind:this={liveEl}
|
||||
class="max-h-48 overflow-auto whitespace-pre-wrap break-words rounded-md bg-muted/60 p-2 font-mono text-[11px] text-foreground/90">{tool.liveOutput}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
{#if tool.args}
|
||||
<div>
|
||||
<div
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
import SparklesIcon from '@lucide/svelte/icons/sparkles'
|
||||
import HelpCircleIcon from '@lucide/svelte/icons/help-circle'
|
||||
import FlagIcon from '@lucide/svelte/icons/flag'
|
||||
import ExternalLinkIcon from '@lucide/svelte/icons/external-link'
|
||||
import { openEntityWindow } from '$lib/stores/windows'
|
||||
|
||||
// Merged plan + activity timeline, designed for the narrow rail:
|
||||
// - ordered newest-first: what the agent is doing right now is at the top,
|
||||
@@ -423,10 +425,23 @@
|
||||
>
|
||||
{tool.description}
|
||||
</span>
|
||||
<span class="shrink-0 text-[9px] tabular-nums text-muted-foreground/50"
|
||||
>{hhmm(tool.timestamp)}</span
|
||||
>
|
||||
{#if !tool.link}
|
||||
<span class="shrink-0 text-[9px] tabular-nums text-muted-foreground/50"
|
||||
>{hhmm(tool.timestamp)}</span
|
||||
>
|
||||
{/if}
|
||||
</button>
|
||||
{#if tool.link}
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-1.5 top-1/2 z-10 flex size-5 -translate-y-1/2 items-center justify-center rounded text-muted-foreground/70 transition-colors hover:bg-muted/60 hover:text-foreground"
|
||||
title="Open {tool.link.kind === 'knowledge' ? 'knowledge doc' : 'entity'}"
|
||||
aria-label="Open {tool.link.kind === 'knowledge' ? 'knowledge doc' : 'entity'}"
|
||||
onclick={() => openEntityWindow(tool.link!.slug)}
|
||||
>
|
||||
<ExternalLinkIcon class="size-3" />
|
||||
</button>
|
||||
{/if}
|
||||
{#if tOpen}
|
||||
<div
|
||||
transition:slide={{ duration: 120 }}
|
||||
@@ -516,10 +531,23 @@
|
||||
>
|
||||
{e.description}
|
||||
</span>
|
||||
<span class="shrink-0 text-[9px] tabular-nums text-muted-foreground/60"
|
||||
>{hhmm(e.timestamp)}</span
|
||||
>
|
||||
{#if !e.link}
|
||||
<span class="shrink-0 text-[9px] tabular-nums text-muted-foreground/60"
|
||||
>{hhmm(e.timestamp)}</span
|
||||
>
|
||||
{/if}
|
||||
</button>
|
||||
{#if e.link}
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-1.5 top-1/2 z-10 flex size-5 -translate-y-1/2 items-center justify-center rounded text-muted-foreground/70 transition-colors hover:bg-muted/60 hover:text-foreground"
|
||||
title="Open {e.link.kind === 'knowledge' ? 'knowledge doc' : 'entity'}"
|
||||
aria-label="Open {e.link.kind === 'knowledge' ? 'knowledge doc' : 'entity'}"
|
||||
onclick={() => openEntityWindow(e.link!.slug)}
|
||||
>
|
||||
<ExternalLinkIcon class="size-3" />
|
||||
</button>
|
||||
{/if}
|
||||
{#if eOpen}
|
||||
<div
|
||||
transition:slide={{ duration: 120 }}
|
||||
|
||||
Reference in New Issue
Block a user