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 `
${escaped}
` + } + 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 `
${header}${body}
` + } + 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'} -
  1. - {#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} -
  2. - {/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 @@ + + +
+ + + {#if expanded} +
+ {#if tool.args} +
+
Args
+
{JSON.stringify(tool.args, null, 2)}
+
+ {/if} + {#if tool.result !== undefined && tool.result !== null} +
+
Result
+
{JSON.stringify(tool.result, null, 2)}
+
+ {/if} + {#if tool.error} +
+
Error
+
{tool.error}
+
+ {/if} +
+ {/if} +
+ + 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 }) }