diff --git a/VERSION b/VERSION
index f374f66..78bc1ab 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.9.1
+0.10.0
diff --git a/cmd/nomos/main.go b/cmd/nomos/main.go
index 6378b3a..8707020 100644
--- a/cmd/nomos/main.go
+++ b/cmd/nomos/main.go
@@ -214,6 +214,7 @@ func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
+ w.Header().Set("X-Accel-Buffering", "no") // disable proxy buffering
w.WriteHeader(200)
ctx := r.Context()
diff --git a/cmd/nomos/store.go b/cmd/nomos/store.go
index 6fb70bb..6139742 100644
--- a/cmd/nomos/store.go
+++ b/cmd/nomos/store.go
@@ -1100,9 +1100,9 @@ func (s *store) completeTask(ctx context.Context, sessionID, outcome, summary st
}
if _, err := s.pool.Exec(ctx, `
UPDATE session_plan_steps
- SET status = $3, finished_at = COALESCE(finished_at, now())
+ SET status = $2, finished_at = COALESCE(finished_at, now())
WHERE session_id = $1 AND status IN ('pending', 'running')`,
- sessionID, "", closeStatus); err != nil {
+ sessionID, closeStatus); err != nil {
slog.Warn("nomos: completeTask failed to auto-close in-flight steps", "session", sessionID, "error", err)
}
diff --git a/plans/2026-07-21-chat-full-polish.md b/plans/2026-07-21-chat-full-polish.md
new file mode 100644
index 0000000..3faa32d
--- /dev/null
+++ b/plans/2026-07-21-chat-full-polish.md
@@ -0,0 +1,79 @@
+# 2026-07-21 Chat window full polish
+
+## Context
+
+After fixing the streaming reactivity bug and merging the double thinking
+indicator, the chat window still has structural UX gaps: no streaming
+affordance while text flows, tools never rendered inline, no timestamps,
+no code copy, cross-session store leaks in floating windows, and minor
+overflow/style holes.
+
+## Decisions
+
+| Question | Answer |
+|---|---|
+| Streaming feel | Typing cursor (blinking ▍) + inline indicator |
+| Tool calls | Expandable inline tool cards in message flow |
+| Empty state | Minimal — title + tagline, no suggestions |
+| Dark theme | Keep neutral (skip) |
+| Scope | Full polish — everything |
+
+## Changes
+
+### P0.1 Streaming cursor
+- **File:** `web/src/lib/components/ChatThread.svelte`
+- Add a blinking block-cursor (▍) appended after rendered markdown when
+ `streaming` is true and the last assistant message has text.
+- Keep the inline spinner + activity label for the empty-text state.
+- CSS: `@keyframes` blink, `0.8s` cycle, `primary` color, `inline-block`.
+
+### P0.2 Tool call cards
+- **New:** `web/src/lib/components/ToolCallCard.svelte`
+- **Modify:** `ChatThread.svelte`
+- Render `msg.tools` as collapsible cards between text blocks.
+- Collapsed: tool icon + name + status (running/done/error).
+- Expanded: pretty-printed args + result/error in `pre` blocks.
+- Keep it minimal — one card per tool call, no grouping.
+- Wire `pendingApprovals` from `msg.pendingApprovals` as approval
+ cards below the tool list.
+
+### P1.3 Timestamps + role labels
+- **Modify:** `ChatThread.svelte`, `ChatMessage` interface
+- Add `created_at?: string` to `ChatMessage` (populated from `Message.created_at`).
+- Show small muted timestamp (HH:MM) on hover or inline next to role label.
+- Add tiny "You" / "Nomos" labels above bubbles (subtle, muted).
+
+### P1.4 Code copy button
+- **Modify:** `ChatThread.svelte` prose styles
+- Wrap `pre` blocks in a relative container; add a copy button
+ (clipboard icon, top-right, opacity-0 → visible on hover).
+- Use `navigator.clipboard.writeText`.
+
+### P1.5 Table overflow + user bubble fix
+- **Modify:** `ChatThread.svelte` prose styles
+- Wrap tables in `overflow-x-auto` container.
+- Add `overflow-wrap: break-word` to user bubbles.
+
+### P2.6 Cross-session fixes
+- **Modify:** `web/src/lib/stores/chat.ts`, `SessionChatWindow.svelte`
+- `chatErrors`: keep global for now (session-scoped errors are rare
+ and the dismiss is manual anyway).
+- `activityLog`: **per-session** — the store in `activity.ts` already
+ derives from messages; make `computeActivityLog` session-scoped
+ so each floating window only sees its own activity.
+
+### P2.7 Min window size
+- **Modify:** `web/src/lib/stores/windows.ts` (openTaskWindow)
+- Add `minWidth: 600, minHeight: 400` to chat window open call.
+
+### P2.8 Cleanup
+- Delete `web/src/lib/components/AgentIndicator.svelte` (dead code).
+- Update stale comments in `SessionChatWindow.svelte` and
+ `TaskContextPanel.svelte` that reference a "main Chat page."
+- Fix prose heading hierarchy: h1 = 1.15em, h2 = 1.1em, h3 = 1.05em.
+
+## Verification
+
+- `npx eslint` on all changed files
+- `go vet ./cmd/nomos/...`
+- `go build -o /dev/null ./cmd/nomos/...`
diff --git a/web/src/lib/components/ActivityTimeline.svelte b/web/src/lib/components/ActivityTimeline.svelte
deleted file mode 100644
index 996853e..0000000
--- a/web/src/lib/components/ActivityTimeline.svelte
+++ /dev/null
@@ -1,154 +0,0 @@
-
-
-
-
- {#if entries.length === 0}
-
-
-
Waiting for activity…
-
- {:else}
-
- {#each entries as entry, i (entry.id)}
- {@const isLast = i === entries.length - 1}
- {@const icon = typeIcon(entry.type)}
- {@const isOpen = expanded.has(entry.id)}
- {@const time = formatTime(entry.timestamp)}
-
-
- {#if !isLast}
-
- {/if}
-
-
- {#if isOpen}
-
-
- {entry.status}
- {#if time}·{time}{/if}
- {#if entry.toolName}·{entry.toolName}{/if}
-
- {#if entry.args}
-
-
Called with
-
{prettyPrint(entry.args)}
-
- {/if}
- {#if entry.detail}
-
- {#if entry.args}
{entry.status === 'failed' ? 'Error' : 'Result'}
{/if}
-
{prettyPrint(entry.detail)}
-
- {/if}
- {#if !entry.args && !entry.detail}
-
No further detail for this step.
- {/if}
-
- {/if}
-
- {/each}
-
- {/if}
-
-
diff --git a/web/src/lib/components/AgentIndicator.svelte b/web/src/lib/components/AgentIndicator.svelte
deleted file mode 100644
index 64b78de..0000000
--- a/web/src/lib/components/AgentIndicator.svelte
+++ /dev/null
@@ -1,42 +0,0 @@
-
-
-{#if active || done || error}
-
-
- {#if error}
-
- {:else if done}
-
- {:else}
-
- {/if}
-
- {label}
-
-{/if}
diff --git a/web/src/lib/components/ChatThread.svelte b/web/src/lib/components/ChatThread.svelte
index f081824..0ecdb85 100644
--- a/web/src/lib/components/ChatThread.svelte
+++ b/web/src/lib/components/ChatThread.svelte
@@ -5,11 +5,15 @@
// through this, so the message-bubble/markdown styling lives in one place
// instead of being copy-pasted between the two.
import { Pane, Splitpanes } from 'svelte-splitpanes'
- import { activityLog } from '$lib/stores/activity'
- import AgentIndicator from '$lib/components/AgentIndicator.svelte'
+ import { activityLog, type ActivityEntry } from '$lib/stores/activity'
+ import type { Readable } from 'svelte/store'
import { Button } from '$lib/components/ui/button'
import { Textarea } from '$lib/components/ui/textarea'
+ import Spinner from './Spinner.svelte'
+ import ToolCallCard from './ToolCallCard.svelte'
import CornerDownLeftIcon from '@lucide/svelte/icons/corner-down-left'
+ import CheckIcon from '@lucide/svelte/icons/check'
+ import XIcon from '@lucide/svelte/icons/x'
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw'
import SquareIcon from '@lucide/svelte/icons/square'
import { marked } from 'marked'
@@ -26,7 +30,8 @@
onCancel,
onReconnect,
onDismissError,
- suggestions = []
+ suggestions = [],
+ activityLog: activityLogProp = activityLog
}: {
messages: ChatMessage[]
streaming: boolean
@@ -38,6 +43,7 @@
onReconnect: () => void
onDismissError: (id: string) => void
suggestions?: string[]
+ activityLog?: Readable
} = $props()
let input = $state('')
@@ -45,6 +51,26 @@
let scrolledUp = $state(false)
let container = $state(null)
+ let indicatorDone = $state(false)
+ let wasStreaming = $state(false)
+
+ $effect(() => {
+ if (streaming) { indicatorDone = false; wasStreaming = true }
+ if (!streaming && wasStreaming) {
+ indicatorDone = true
+ const t = setTimeout(() => { indicatorDone = false; wasStreaming = false }, 3000)
+ return () => clearTimeout(t)
+ }
+ })
+
+ 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
+ return 'Agent is thinking…'
+ })
+
// Resizable input area — drag the splitter above it to grow the textarea,
// capped so it can't swallow the whole thread. Both the minimum and the
// default are exactly one line: measured from the textarea's own
@@ -102,7 +128,26 @@
})
function render(text: string): string {
- return DOMPurify.sanitize(marked.parse(text, { async: false }) as string)
+ const renderer = new marked.Renderer()
+ renderer.code = function ({ text, lang }) {
+ const escaped = text.replace(/&/g, '&').replace(//g, '>')
+ return ``
+ }
+ renderer.table = function (token) {
+ const header = token.header.map((c: { text: string }) => `${c.text} | `).join('')
+ const body = token.rows.map((r: { text: string }[]) => `${r.map((c) => `| ${c.text} | `).join('')}
`).join('')
+ return ``
+ }
+ return DOMPurify.sanitize(marked.parse(text, { async: false, renderer }) as string)
+ }
+
+ function formatTime(iso: string): string {
+ try {
+ const d = new Date(iso)
+ return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
+ } catch {
+ return ''
+ }
}
function submit() {
@@ -149,27 +194,56 @@
{/if}
- {#each messages as msg (msg.id)}
+ {#each messages as msg, idx (msg.id)}
{#if msg.role === 'user'}
+
+ You
+ {#if msg.created_at}
+ {formatTime(msg.created_at)}
+ {/if}
+
{msg.text}
{:else}
+
+ Nomos
+ {#if msg.created_at}
+ {formatTime(msg.created_at)}
+ {/if}
+
{#if msg.text}
{@html render(msg.text)}
+ {#if idx === messages.length - 1 && streaming}
+
+ {/if}
+
+ {/if}
+ {#if msg.tools.length > 0}
+
+ {#each msg.tools as tool (tool.id)}
+
+ {/each}
+
+ {/if}
+ {#if idx === messages.length - 1 && msg.text === '' && (streaming || indicatorDone || error)}
+
+ {#if error}
+
+ {:else if indicatorDone}
+
+ {:else}
+
+ {/if}
+ {indicatorLabel}
{/if}
{/if}
{/each}
- e.status === 'running')}
- lastActivity={$activityLog.find((e) => e.status === 'running') ?? null}
- {error}
- />
@@ -269,6 +343,7 @@
/* User message — soft terracotta bubble, gentle lift */
.user-msg {
box-shadow: 0 1px 8px -4px var(--primary);
+ overflow-wrap: break-word;
}
/* Prose overrides */
@@ -337,12 +412,26 @@
/* Section headings — serif (Inknut) with a short accent rule. Extra top
margin separates sections; the first heading in a message doesn't. */
- .prose-chat :global(h1),
- .prose-chat :global(h2),
- .prose-chat :global(h3) {
+ .prose-chat :global(h1) {
+ font-size: 1.15em;
+ font-weight: 600;
+ margin: 1.15rem 0 0.4rem;
+ letter-spacing: 0.01em;
+ position: relative;
+ display: inline-block;
+ }
+ .prose-chat :global(h2) {
+ font-size: 1.08em;
+ font-weight: 600;
+ margin: 1.15rem 0 0.4rem;
+ letter-spacing: 0.01em;
+ position: relative;
+ display: inline-block;
+ }
+ .prose-chat :global(h3) {
+ font-size: 1.02em;
font-weight: 600;
margin: 1.15rem 0 0.4rem;
- font-size: 1.03em;
letter-spacing: 0.01em;
position: relative;
display: inline-block;
@@ -370,6 +459,13 @@
margin: 0 0 0.5rem;
font-size: 0.8125rem;
}
+ .prose-chat :global(.table-wrapper) {
+ overflow-x: auto;
+ margin: 0 0 0.5rem;
+ }
+ .prose-chat :global(.table-wrapper table) {
+ margin: 0;
+ }
.prose-chat :global(th) {
background: var(--muted);
font-weight: 600;
@@ -433,4 +529,51 @@
background: linear-gradient(to right, transparent, var(--primary), transparent);
opacity: 0.3;
}
+
+ /* Code copy button — global: injected via render() into {html} blocks */
+ .prose-chat :global(.code-block-wrapper) {
+ position: relative;
+ }
+ .prose-chat :global(.code-copy-btn) {
+ position: absolute;
+ top: 0.375rem;
+ right: 0.375rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 1.5rem;
+ height: 1.5rem;
+ border-radius: 0.375rem;
+ color: var(--muted-foreground);
+ opacity: 0;
+ transition: opacity 0.15s, color 0.15s;
+ cursor: pointer;
+ border: none;
+ background: transparent;
+ }
+ .prose-chat :global(.code-block-wrapper:hover .code-copy-btn) {
+ opacity: 1;
+ }
+ .prose-chat :global(.code-copy-btn:hover) {
+ color: var(--foreground);
+ background: var(--muted);
+ }
+
+ /* Streaming cursor — blinking block appended after streaming text */
+ .stream-cursor {
+ display: inline-block;
+ width: 0.55em;
+ height: 1.1em;
+ background: var(--primary);
+ opacity: 0.75;
+ border-radius: 1px;
+ margin-left: 1px;
+ vertical-align: text-bottom;
+ animation: cursor-blink 0.9s ease-in-out infinite;
+ }
+
+ @keyframes cursor-blink {
+ 0%, 100% { opacity: 0.75; }
+ 50% { opacity: 0; }
+ }
diff --git a/web/src/lib/components/SessionChatWindow.svelte b/web/src/lib/components/SessionChatWindow.svelte
index 35268d6..143ccc2 100644
--- a/web/src/lib/components/SessionChatWindow.svelte
+++ b/web/src/lib/components/SessionChatWindow.svelte
@@ -1,13 +1,12 @@
@@ -116,150 +96,35 @@
{/if}
-
-
+
+
- {#if planOpen}
-
- {#if $taskStore?.goal}
-
-
- {$taskStore.goal}
-
- {/if}
- {#if planTotal > 0}
-
-
- {planDone} of {planTotal} done
- {planPct}%
-
-
-
-
- {#each $planStepsStore as step, i (step.id)}
- {@const isDone = step.status === 'done'}
- {@const isRunning = step.status === 'running'}
- -
- {#if i < $planStepsStore.length - 1}
-
- {/if}
-
- {#if isDone}
-
- {:else if isRunning}
-
- {:else if step.status === 'failed'}
-
- {:else if step.status === 'blocked'}
-
- {:else if step.status === 'skipped' || step.status === 'replaced'}
-
- {:else}
-
- {/if}
-
- {step.title}
-
- {/each}
-
- {:else if planPhase === 'drafting'}
-
-
-
Drafting a plan…
-
- {:else}
-
-
-
- {planPhase === 'none' ? 'Handled directly — no plan needed' : 'No plan for this task yet'}
-
-
- {/if}
-
- {/if}
-
-
-
-
-
{#if activityOpen}
{/if}
diff --git a/web/src/lib/components/ToolCallCard.svelte b/web/src/lib/components/ToolCallCard.svelte
new file mode 100644
index 0000000..f002737
--- /dev/null
+++ b/web/src/lib/components/ToolCallCard.svelte
@@ -0,0 +1,85 @@
+
+
+
+
+
diff --git a/web/src/lib/components/UnifiedTimeline.svelte b/web/src/lib/components/UnifiedTimeline.svelte
new file mode 100644
index 0000000..793d080
--- /dev/null
+++ b/web/src/lib/components/UnifiedTimeline.svelte
@@ -0,0 +1,350 @@
+
+
+
+
+ {#if items.length === 0}
+
+
+
Waiting for activity…
+
+ {:else}
+
+ {#each items as item, i (item.kind === 'step' ? item.step.id : item.entry.id)}
+ {@const isFirst = i === 0}
+ {@const isLast = i === items.length - 1}
+ {#if item.kind === 'step'}
+ {@const st = item.step.status}
+ {@const open = stepOpen(item.step)}
+ {@const hasDetail = !!item.step.detail?.trim()}
+ {@const expandable = item.tools.length > 0 || hasDetail}
+ {@const expandedWithTools = open && item.tools.length > 0}
+
+
+
+
+
+ {#if expandedWithTools}
+
+ {#each item.tools as tool (tool.id)}
+ {@const tOpen = expandedTools.has(tool.id)}
+
+
+
+
+ {#if tOpen}
+
+
+ {tool.status}
+ ·
+ {hhmmss(tool.timestamp)}
+ {#if tool.toolName}·{tool.toolName}{/if}
+
+ {#if tool.args}
+
{prettyPrint(tool.args)}
+ {/if}
+ {#if tool.detail}
+
{prettyPrint(tool.detail)}
+ {/if}
+
+ {/if}
+
+ {/each}
+
+ {/if}
+
+ {:else}
+
+ {@const e = item.entry}
+ {@const Icon = entryIcon(e)}
+ {@const eOpen = expandedTools.has(e.id)}
+
+
+
+ {#if eOpen}
+
+
+ {e.status}
+ ·
+ {hhmmss(e.timestamp)}
+ {#if e.toolName}·{e.toolName}{/if}
+
+ {#if e.args}
+
{prettyPrint(e.args)}
+ {/if}
+ {#if e.detail}
+
{prettyPrint(e.detail)}
+ {/if}
+
+ {/if}
+
+ {/if}
+ {/each}
+
+ {/if}
+
+
diff --git a/web/src/lib/stores/chat.ts b/web/src/lib/stores/chat.ts
index 6539c63..93498c5 100644
--- a/web/src/lib/stores/chat.ts
+++ b/web/src/lib/stores/chat.ts
@@ -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 = new Map()
+ const activeTools: Map = 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 = new Map()
+ const activeTools: Map = 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)
diff --git a/web/src/lib/stores/windows.ts b/web/src/lib/stores/windows.ts
index b6c6df3..6c28c24 100644
--- a/web/src/lib/stores/windows.ts
+++ b/web/src/lib/stores/windows.ts
@@ -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 })
}