feat(web+nomos): fix chat streaming reactivity, unified activity timeline, tool cards in chat
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

- fix(web): Svelte 5 identity-based reactivity broke text_delta streaming —
  immutable message objects in all three chat handlers so text streams live
- feat(web): streaming cursor + inline status indicator merged into message flow
- feat(web): expandable inline tool call cards in chat thread
- feat(web): merge Plan + Event log into one backbone Activity timeline —
  filled status nodes, branch stubs, auto-scroll follow mode, per-session
  activityLog, compact for the rail
- fix(nomos): add X-Accel-Buffering:no to /chat SSE (proxy buffering)
- fix(nomos): plan step auto-close SQL param bug (store.go)
- polish: timestamps, role labels, code copy button, table overflow, min
  window size, delete AgentIndicator/ActivityTimeline dead code
This commit is contained in:
2026-07-21 07:49:02 +02:00
parent 55b93c59ef
commit ce34cfeac7
13 changed files with 742 additions and 392 deletions

View File

@@ -20,6 +20,7 @@ export interface ChatMessage {
text: string
tools: ToolCallResult[]
pendingApprovals: PendingApproval[]
created_at?: string
}
const APPROVAL_RE = /execution\s+([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i
@@ -122,7 +123,8 @@ function toChatMessages(msgs: Message[]): ChatMessage[] {
role: m.role as 'user' | 'assistant',
text: content?.text ?? '',
tools,
pendingApprovals: extractApprovals(tools)
pendingApprovals: extractApprovals(tools),
created_at: m.created_at
}
})
}
@@ -209,7 +211,7 @@ export function sendMessage(text: string) {
}
messages.update((ms) => [...ms, assistantMsg])
let activeTools: Map<string, ToolCallResult> = new Map()
const activeTools: Map<string, ToolCallResult> = new Map()
// Multiple tasks can stream concurrently (the backend runs each turn as its
// own goroutine — nothing serializes them), but `messages`/`currentSession`
@@ -262,7 +264,7 @@ export function sendMessage(text: string) {
messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') {
last.tools = [...last.tools, tr]
ms[ms.length - 1] = { ...last, tools: [...last.tools, tr] }
}
return [...ms]
})
@@ -279,10 +281,10 @@ export function sendMessage(text: string) {
messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') {
last.tools = last.tools.map((t) =>
const tools = last.tools.map((t) =>
t.id === ev.data.id ? updated : t
)
last.pendingApprovals = extractApprovals(last.tools)
ms[ms.length - 1] = { ...last, tools, pendingApprovals: extractApprovals(tools) }
}
return [...ms]
})
@@ -291,7 +293,7 @@ export function sendMessage(text: string) {
messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') {
last.text += ev.data
ms[ms.length - 1] = { ...last, text: last.text + ev.data }
}
return [...ms]
})
@@ -300,7 +302,7 @@ export function sendMessage(text: string) {
messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') {
last.text = ev.data
ms[ms.length - 1] = { ...last, text: ev.data }
}
return [...ms]
})
@@ -310,7 +312,7 @@ export function sendMessage(text: string) {
messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') {
last.pendingApprovals = extractApprovals(last.tools)
ms[ms.length - 1] = { ...last, pendingApprovals: extractApprovals(last.tools) }
}
return [...ms]
})
@@ -580,7 +582,7 @@ export function sendSessionMessage(sessionId: string, text: string) {
const assistantMsg: ChatMessage = { id: mid(), role: 'assistant', text: '', tools: [], pendingApprovals: [] }
chat.messages.update((ms) => [...ms, assistantMsg])
let activeTools: Map<string, ToolCallResult> = new Map()
const activeTools: Map<string, ToolCallResult> = new Map()
let receivedDone = false
const controller = streamChat(
@@ -593,7 +595,9 @@ export function sendSessionMessage(sessionId: string, text: string) {
activeTools.set(ev.data.id, tr)
chat.messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') last.tools = [...last.tools, tr]
if (last && last.role === 'assistant') {
ms[ms.length - 1] = { ...last, tools: [...last.tools, tr] }
}
return [...ms]
})
} else if (ev.type === 'tool_result') {
@@ -604,8 +608,8 @@ export function sendSessionMessage(sessionId: string, text: string) {
chat.messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') {
last.tools = last.tools.map((t) => (t.id === ev.data.id ? updated : t))
last.pendingApprovals = extractApprovals(last.tools)
const tools = last.tools.map((t) => (t.id === ev.data.id ? updated : t))
ms[ms.length - 1] = { ...last, tools, pendingApprovals: extractApprovals(tools) }
}
return [...ms]
})
@@ -613,13 +617,17 @@ export function sendSessionMessage(sessionId: string, text: string) {
} else if (ev.type === 'text_delta') {
chat.messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') last.text += ev.data
if (last && last.role === 'assistant') {
ms[ms.length - 1] = { ...last, text: last.text + ev.data }
}
return [...ms]
})
} else if (ev.type === 'text') {
chat.messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') last.text = ev.data
if (last && last.role === 'assistant') {
ms[ms.length - 1] = { ...last, text: ev.data }
}
return [...ms]
})
} else if (ev.type === 'done') {
@@ -627,7 +635,9 @@ export function sendSessionMessage(sessionId: string, text: string) {
chat.connectionState.set('connected')
chat.messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') last.pendingApprovals = extractApprovals(last.tools)
if (last && last.role === 'assistant') {
ms[ms.length - 1] = { ...last, pendingApprovals: extractApprovals(last.tools) }
}
return [...ms]
})
startSessionPolling(sessionId)
@@ -694,7 +704,9 @@ export function startTask(text: string, onSession: (sessionId: string) => void):
activeTools.set(ev.data.id, tr)
c.messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') last.tools = [...last.tools, tr]
if (last && last.role === 'assistant') {
ms[ms.length - 1] = { ...last, tools: [...last.tools, tr] }
}
return [...ms]
})
} else if (ev.type === 'tool_result') {
@@ -705,8 +717,8 @@ export function startTask(text: string, onSession: (sessionId: string) => void):
c.messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') {
last.tools = last.tools.map((t) => (t.id === ev.data.id ? updated : t))
last.pendingApprovals = extractApprovals(last.tools)
const tools = last.tools.map((t) => (t.id === ev.data.id ? updated : t))
ms[ms.length - 1] = { ...last, tools, pendingApprovals: extractApprovals(tools) }
}
return [...ms]
})
@@ -714,13 +726,17 @@ export function startTask(text: string, onSession: (sessionId: string) => void):
} else if (ev.type === 'text_delta') {
c.messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') last.text += ev.data
if (last && last.role === 'assistant') {
ms[ms.length - 1] = { ...last, text: last.text + ev.data }
}
return [...ms]
})
} else if (ev.type === 'text') {
c.messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') last.text = ev.data
if (last && last.role === 'assistant') {
ms[ms.length - 1] = { ...last, text: ev.data }
}
return [...ms]
})
} else if (ev.type === 'done') {
@@ -728,7 +744,9 @@ export function startTask(text: string, onSession: (sessionId: string) => void):
c.connectionState.set('connected')
c.messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') last.pendingApprovals = extractApprovals(last.tools)
if (last && last.role === 'assistant') {
ms[ms.length - 1] = { ...last, pendingApprovals: extractApprovals(last.tools) }
}
return [...ms]
})
startSessionPolling(sessionId)

View File

@@ -122,5 +122,5 @@ export function openTaskWindow(sessionId: string | null, title: string): void {
wm.focus(id)
return
}
wm.open({ id, title, width: 900, height: 640 })
wm.open({ id, title, width: 900, height: 640, minWidth: 600, minHeight: 400 })
}