@mjasnikovs/pi-task 0.7.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,6 @@
1
- import { broadcast } from './broadcast.js';
2
1
  import { getBridge, dispatchRemoteLine, dispatchRemoteNewSession, makeShimmedCtx } from './bridge.js';
3
2
  import { setupEvents } from './events.js';
4
- import { HistoryBuffer } from './history.js';
3
+ import { reset, addUserTurn } from './session-state.js';
5
4
  import { html } from './ui.js';
6
5
  import { qrLines } from './qr.js';
7
6
  import { startServer, formatAddresses } from './server.js';
@@ -12,7 +11,6 @@ if (!_g.__piRemote)
12
11
  _g.__piRemote = { server: null, send: null, serveResult: null };
13
12
  const S = _g.__piRemote;
14
13
  export function registerRemote(pi) {
15
- const history = new HistoryBuffer(20);
16
14
  async function ensureServer() {
17
15
  if (S.server)
18
16
  return S.server;
@@ -29,7 +27,7 @@ export function registerRemote(pi) {
29
27
  }
30
28
  dispatchRemoteLine(text, {
31
29
  onPlain: plain => {
32
- history.addUserMessage(plain);
30
+ addUserTurn(plain);
33
31
  if (isAgentIdle()) {
34
32
  S.send?.(plain);
35
33
  }
@@ -38,7 +36,7 @@ export function registerRemote(pi) {
38
36
  }
39
37
  }
40
38
  });
41
- }, wsUrl => html(wsUrl), () => history.getEntries());
39
+ }, wsUrl => html(wsUrl));
42
40
  // Hands-off HTTPS: point Tailscale serve at our port so phones get a
43
41
  // secure context. Best-effort — any failure degrades to the http URL.
44
42
  S.serveResult = await ensureTailscaleServe(S.server.port).catch(() => ({ state: 'unavailable' }));
@@ -47,13 +45,11 @@ export function registerRemote(pi) {
47
45
  pi.on('session_start', (_event, ctx) => {
48
46
  S.send = (text, opts) => (opts ? pi.sendUserMessage(text, opts) : pi.sendUserMessage(text));
49
47
  // A new session (incl. /new and the /task handoff's newSession) means the
50
- // browser is showing a stale transcript/widgets — wipe both ends.
48
+ // browser is showing a stale transcript/widgets — wipe the authoritative
49
+ // state and tell connected clients to clear.
51
50
  const bridge = getBridge();
52
- bridge.activeWidgets.clear();
53
- bridge.activePrompt = null;
54
- bridge.lastContextUsage = null;
55
- broadcast({ type: 'reset' });
56
- setupEvents(pi, history, broadcast);
51
+ reset();
52
+ setupEvents(pi);
57
53
  // Seed a shimmed ctx so commands that don't need newSession (/task-list,
58
54
  // /task-cancel, /task-auto-cancel) work immediately from the remote without
59
55
  // any terminal interaction. Only overwrite if null or already shimmed —
@@ -1,4 +1,3 @@
1
- import type { Turn } from './history.js';
2
1
  export interface LocalIPs {
3
2
  /** Tailscale (tailscale0) IPv4 address, if the interface is up. */
4
3
  tailscale?: string;
@@ -27,5 +26,5 @@ export declare function getLocalIPs(nets?: NodeJS.Dict<import("node:os").Network
27
26
  /** Build the labeled URL lines shown under the QR code. Both Tailscale and LAN
28
27
  * when present; a single unlabeled primary URL when neither resolves. */
29
28
  export declare function formatAddresses(ips: LocalIPs, port: number): AddressLine[];
30
- export declare function startServer(onMessage: MessageCallback, getHtml: (wsUrl: string) => string, getHistory: () => Turn[]): Promise<ServerHandle>;
29
+ export declare function startServer(onMessage: MessageCallback, getHtml: (wsUrl: string) => string): Promise<ServerHandle>;
31
30
  export {};
@@ -2,7 +2,8 @@ import { createServer } from 'node:http';
2
2
  import { networkInterfaces } from 'node:os';
3
3
  import { WebSocketServer } from 'ws';
4
4
  import { addClient, removeClient, clientCount, broadcast, sendTo } from './broadcast.js';
5
- import { getBridge, answerPrompt } from './bridge.js';
5
+ import { answerPrompt } from './bridge.js';
6
+ import { getState, snapshot } from './session-state.js';
6
7
  import { isClientMessage } from './protocol.js';
7
8
  import { swJs } from './sw.js';
8
9
  import { publicKey, addSubscription, getSubscriptions, logPush } from './push.js';
@@ -59,7 +60,7 @@ async function findPort(start, max) {
59
60
  }
60
61
  throw new Error(`No free port found in range ${start}–${start + max - 1}`);
61
62
  }
62
- export async function startServer(onMessage, getHtml, getHistory) {
63
+ export async function startServer(onMessage, getHtml) {
63
64
  const port = await findPort(8800, 100);
64
65
  const ips = getLocalIPs();
65
66
  const ip = ips.primary;
@@ -118,16 +119,8 @@ export async function startServer(onMessage, getHtml, getHistory) {
118
119
  addClient(ws);
119
120
  handle.onFirstConnect?.();
120
121
  handle.onFirstConnect = null;
121
- sendTo(ws, { type: 'history', turns: getHistory() });
122
- const bridge = getBridge();
123
- for (const [key, lines] of bridge.activeWidgets) {
124
- sendTo(ws, { type: 'widget', key, lines });
125
- }
126
- if (bridge.activePrompt)
127
- sendTo(ws, bridge.activePrompt);
128
- if (bridge.lastContextUsage) {
129
- sendTo(ws, { type: 'context', contextUsage: bridge.lastContextUsage });
130
- }
122
+ // One authoritative snapshot the client replaces its whole view with it.
123
+ sendTo(ws, snapshot());
131
124
  broadcast({ type: 'client_count', count: clientCount() });
132
125
  ws.on('message', data => {
133
126
  let msg;
@@ -145,7 +138,7 @@ export async function startServer(onMessage, getHtml, getHistory) {
145
138
  }
146
139
  // type === 'message': ignore while a prompt is pending (composer is
147
140
  // disabled in the browser; this is the server-side guard).
148
- if (getBridge().activePrompt)
141
+ if (getState().prompt)
149
142
  return;
150
143
  onMessage(msg.text);
151
144
  });
@@ -0,0 +1,54 @@
1
+ import { HistoryBuffer } from './history.js';
2
+ import type { Turn, Part } from './history.js';
3
+ import type { ContextUsage, PromptMessage } from './protocol.js';
4
+ export interface LiveTurn {
5
+ /** Ordered assistant content (text segments + tool calls) for this run. */
6
+ parts: Part[];
7
+ /** Is the trailing text part still accumulating deltas? Closed by text_end /
8
+ * a tool start, so the next text begins a fresh segment (separate bubble). */
9
+ textOpen: boolean;
10
+ }
11
+ export interface SnapshotMessage {
12
+ type: 'snapshot';
13
+ turns: Turn[];
14
+ live: LiveTurn | null;
15
+ agentRunning: boolean;
16
+ taskWidget: string[] | null;
17
+ prompt: PromptMessage | null;
18
+ context: ContextUsage | null;
19
+ }
20
+ interface SessionState {
21
+ history: HistoryBuffer;
22
+ live: LiveTurn | null;
23
+ agentRunning: boolean;
24
+ taskWidget: string[] | null;
25
+ prompt: PromptMessage | null;
26
+ context: ContextUsage | null;
27
+ /** Broadcast sink — swapped in tests via _setSink. */
28
+ sink: (msg: unknown) => void;
29
+ }
30
+ export declare function getState(): SessionState;
31
+ /** @internal Test-only: swap the broadcast sink to capture emitted deltas. */
32
+ export declare function _setSink(fn: (msg: unknown) => void): void;
33
+ export declare function agentStart(): void;
34
+ export declare function appendText(delta: string): void;
35
+ export declare function textEnd(): void;
36
+ export declare function startTool(toolCallId: string, toolName: string, args: unknown): void;
37
+ export declare function updateTool(toolCallId: string, partialResult: unknown): void;
38
+ export declare function endTool(toolCallId: string, toolName: string, result: unknown, isError: boolean): void;
39
+ export declare function agentEnd(context: ContextUsage): void;
40
+ export declare function addUserTurn(text: string): void;
41
+ export declare function addError(message: string): void;
42
+ /** A persistent inline note (committed to the transcript so it survives reconnect)
43
+ * plus a live delta so connected clients render it immediately. */
44
+ export declare function addSystemNote(text: string): void;
45
+ /** The single task-widget slot. Empty/undefined lines clear it. */
46
+ export declare function setTaskWidget(lines: string[] | null | undefined): void;
47
+ export declare function setPrompt(prompt: PromptMessage): void;
48
+ export declare function clearPrompt(id: string): void;
49
+ export declare function setContext(context: ContextUsage): void;
50
+ /** Wipe everything (new session) and tell connected clients to clear. */
51
+ export declare function reset(): void;
52
+ /** Serialize the whole state for a (re)connecting client. */
53
+ export declare function snapshot(): SnapshotMessage;
54
+ export {};
@@ -0,0 +1,179 @@
1
+ // The single authoritative mirror of what a connected browser should be showing.
2
+ //
3
+ // Every change funnels through a mutator that (1) updates this object and (2)
4
+ // broadcasts the matching live delta. On (re)connect the server serializes the
5
+ // whole object with snapshot() and the client replaces its entire view. Because
6
+ // the snapshot and the live deltas read/write the same object, they can never
7
+ // disagree — which is what kills the duplicate-transcript / orphaned-widget /
8
+ // two-task-widget drift the ad-hoc broadcasting used to cause.
9
+ //
10
+ // State lives on globalThis so it survives jiti module re-evaluation on session
11
+ // switches, the same pattern broadcast.ts and bridge.ts use.
12
+ import { broadcast as wsBroadcast } from './broadcast.js';
13
+ import { HistoryBuffer } from './history.js';
14
+ const g = globalThis;
15
+ function fresh() {
16
+ return {
17
+ history: new HistoryBuffer(20),
18
+ live: null,
19
+ agentRunning: false,
20
+ taskWidget: null,
21
+ prompt: null,
22
+ context: null,
23
+ sink: wsBroadcast
24
+ };
25
+ }
26
+ export function getState() {
27
+ if (!g.__piSessionState)
28
+ g.__piSessionState = fresh();
29
+ return g.__piSessionState;
30
+ }
31
+ /** @internal Test-only: swap the broadcast sink to capture emitted deltas. */
32
+ export function _setSink(fn) {
33
+ getState().sink = fn;
34
+ }
35
+ function ensureLive(s) {
36
+ if (!s.live)
37
+ s.live = { parts: [], textOpen: false };
38
+ return s.live;
39
+ }
40
+ // ─── Mutators ────────────────────────────────────────────────────────────────
41
+ export function agentStart() {
42
+ const s = getState();
43
+ s.live = { parts: [], textOpen: false };
44
+ s.agentRunning = true;
45
+ s.sink({ type: 'agent_start' });
46
+ }
47
+ export function appendText(delta) {
48
+ const s = getState();
49
+ const live = ensureLive(s);
50
+ const last = live.parts[live.parts.length - 1];
51
+ if (live.textOpen && last && last.kind === 'text') {
52
+ last.text += delta;
53
+ }
54
+ else {
55
+ live.parts.push({ kind: 'text', text: delta });
56
+ live.textOpen = true;
57
+ }
58
+ s.sink({ type: 'text_delta', delta });
59
+ }
60
+ export function textEnd() {
61
+ const s = getState();
62
+ if (s.live)
63
+ s.live.textOpen = false; // next text starts a new segment
64
+ s.sink({ type: 'text_end' });
65
+ }
66
+ export function startTool(toolCallId, toolName, args) {
67
+ const s = getState();
68
+ const live = ensureLive(s);
69
+ live.textOpen = false;
70
+ live.parts.push({
71
+ kind: 'tool',
72
+ toolCallId,
73
+ toolName,
74
+ args,
75
+ result: undefined,
76
+ isError: false,
77
+ done: false
78
+ });
79
+ s.sink({ type: 'tool_start', toolCallId, toolName, args });
80
+ }
81
+ export function updateTool(toolCallId, partialResult) {
82
+ getState().sink({ type: 'tool_update', toolCallId, partialResult });
83
+ }
84
+ export function endTool(toolCallId, toolName, result, isError) {
85
+ const s = getState();
86
+ const live = ensureLive(s);
87
+ const part = live.parts.find((p) => p.kind === 'tool' && p.toolCallId === toolCallId);
88
+ if (part) {
89
+ part.result = result;
90
+ part.isError = isError;
91
+ part.done = true;
92
+ }
93
+ else {
94
+ // tool_end without a matching start (shouldn't happen) — append a done tool.
95
+ live.parts.push({
96
+ kind: 'tool',
97
+ toolCallId,
98
+ toolName,
99
+ args: undefined,
100
+ result,
101
+ isError,
102
+ done: true
103
+ });
104
+ }
105
+ s.sink({ type: 'tool_end', toolCallId, toolName, result, isError });
106
+ }
107
+ export function agentEnd(context) {
108
+ const s = getState();
109
+ if (s.live)
110
+ s.history.addAssistantTurn(s.live.parts);
111
+ s.live = null;
112
+ s.agentRunning = false;
113
+ s.context = context;
114
+ s.sink({ type: 'agent_end', contextUsage: context });
115
+ }
116
+ export function addUserTurn(text) {
117
+ const s = getState();
118
+ s.history.addUserMessage(text);
119
+ s.sink({ type: 'user_message', text });
120
+ }
121
+ export function addError(message) {
122
+ const s = getState();
123
+ s.history.addError(message);
124
+ s.live = null;
125
+ s.agentRunning = false;
126
+ s.sink({ type: 'agent_error', message });
127
+ }
128
+ /** A persistent inline note (committed to the transcript so it survives reconnect)
129
+ * plus a live delta so connected clients render it immediately. */
130
+ export function addSystemNote(text) {
131
+ const s = getState();
132
+ s.history.addSystemNote(text);
133
+ s.sink({ type: 'system_note', text });
134
+ }
135
+ /** The single task-widget slot. Empty/undefined lines clear it. */
136
+ export function setTaskWidget(lines) {
137
+ const s = getState();
138
+ s.taskWidget = lines && lines.length ? lines : null;
139
+ s.sink({ type: 'widget', lines: s.taskWidget });
140
+ }
141
+ export function setPrompt(prompt) {
142
+ const s = getState();
143
+ s.prompt = prompt;
144
+ s.sink(prompt);
145
+ }
146
+ export function clearPrompt(id) {
147
+ const s = getState();
148
+ s.prompt = null;
149
+ s.sink({ type: 'prompt_resolved', id });
150
+ }
151
+ export function setContext(context) {
152
+ const s = getState();
153
+ s.context = context;
154
+ s.sink({ type: 'context', contextUsage: context });
155
+ }
156
+ /** Wipe everything (new session) and tell connected clients to clear. */
157
+ export function reset() {
158
+ const s = getState();
159
+ s.history = new HistoryBuffer(20);
160
+ s.live = null;
161
+ s.agentRunning = false;
162
+ s.taskWidget = null;
163
+ s.prompt = null;
164
+ s.context = null;
165
+ s.sink({ type: 'reset' });
166
+ }
167
+ /** Serialize the whole state for a (re)connecting client. */
168
+ export function snapshot() {
169
+ const s = getState();
170
+ return {
171
+ type: 'snapshot',
172
+ turns: s.history.getEntries(),
173
+ live: s.live ? { parts: [...s.live.parts], textOpen: s.live.textOpen } : null,
174
+ agentRunning: s.agentRunning,
175
+ taskWidget: s.taskWidget,
176
+ prompt: s.prompt,
177
+ context: s.context
178
+ };
179
+ }
package/dist/remote/ui.js CHANGED
@@ -85,6 +85,13 @@ export function html(wsUrl) {
85
85
  background: var(--crust); color: var(--red); align-self: stretch;
86
86
  max-width: 100%; border: 1px solid var(--red); font-size: 12px;
87
87
  }
88
+ /* Persistent inline system note (e.g. context compaction) — a muted centered
89
+ divider, distinct from chat bubbles. */
90
+ .sysnote {
91
+ align-self: center; color: var(--subtext0); font-size: 11px;
92
+ font-family: ui-monospace, monospace; letter-spacing: 0.5px;
93
+ padding: 2px 10px; opacity: 0.85;
94
+ }
88
95
  .bubble.thinking {
89
96
  display: flex; gap: 5px; align-items: center; padding: 10px 14px;
90
97
  }
@@ -172,11 +179,12 @@ export function html(wsUrl) {
172
179
  font-size: 13px; z-index: 100; letter-spacing: 0.03em;
173
180
  }
174
181
  #reconnect-overlay.visible { display: flex; }
182
+ /* Trailing stream indicator: the same braille spinner as the thinking bubble,
183
+ inline at the end of the streaming text (not a green blinking block). */
175
184
  .cursor {
176
- display: inline-block; width: 7px; height: 1em; vertical-align: text-bottom;
177
- background: var(--green); animation: blink 1s step-end infinite; margin-left: 1px;
185
+ color: var(--mauve); margin-left: 2px;
186
+ font-family: ui-monospace, monospace;
178
187
  }
179
- @keyframes blink { 50% { opacity: 0; } }
180
188
  #status-panel { padding: 6px 12px; border-bottom: 1px solid var(--surface1);
181
189
  color: var(--subtext1); white-space: pre-wrap; font-size: 13px; display: none; }
182
190
  #prompt-card { position: fixed; left: 0; right: 0; bottom: 0; background: var(--mantle);
@@ -276,12 +284,13 @@ export function html(wsUrl) {
276
284
  const statusPanel = document.getElementById('status-panel');
277
285
  // Widgets are keyed (e.g. 'pi-tasks', 'pi-task-auto'); track them per key so a
278
286
  // clear for one key can't be masked by a stale message from another.
279
- const widgets = {};
287
+ // Single authoritative task-widget slot. The snapshot and the live 'widget'
288
+ // delta both set this; null hides the panel. (No more per-key map that could
289
+ // strand an orphaned widget on screen.)
290
+ let taskWidgetLines = null;
280
291
  function renderWidgets() {
281
- let all = [];
282
- for (const k in widgets) if (widgets[k] && widgets[k].length) all = all.concat(widgets[k]);
283
- if (all.length) {
284
- statusPanel.textContent = all.join('\\n');
292
+ if (taskWidgetLines && taskWidgetLines.length) {
293
+ statusPanel.textContent = taskWidgetLines.join('\\n');
285
294
  statusPanel.style.display = 'block';
286
295
  } else {
287
296
  statusPanel.style.display = 'none';
@@ -488,30 +497,46 @@ export function html(wsUrl) {
488
497
  let spinTimer = null;
489
498
  let spinIdx = 0;
490
499
  const SPIN = '\\u280B\\u2819\\u2839\\u2838\\u283C\\u2834\\u2826\\u2827\\u2807\\u280F';
500
+ // One braille ticker drives every '.spin' element — the thinking bubble AND the
501
+ // trailing stream cursor — so they share the same frame and look identical.
502
+ function spinPaint() {
503
+ const g = SPIN[spinIdx % SPIN.length];
504
+ const els = document.getElementsByClassName('spin');
505
+ for (let i = 0; i < els.length; i++) els[i].textContent = g;
506
+ }
507
+ function startSpin() {
508
+ spinPaint();
509
+ if (spinTimer) return;
510
+ spinTimer = setInterval(function () {
511
+ spinIdx = (spinIdx + 1) % SPIN.length;
512
+ spinPaint();
513
+ }, 90);
514
+ }
515
+ // Stop the ticker once nothing on screen needs spinning.
516
+ function stopSpinIfIdle() {
517
+ if (spinTimer && !document.querySelector('.spin')) {
518
+ clearInterval(spinTimer); spinTimer = null;
519
+ }
520
+ }
491
521
  function showThinking() {
492
522
  if (!thinkingEl) {
493
523
  thinkingEl = document.createElement('div');
494
524
  thinkingEl.className = 'bubble assistant thinking';
495
- thinkingEl.innerHTML = '<span class="spinner"></span>';
496
- }
497
- if (!spinTimer) {
498
- var sp = thinkingEl.firstChild;
499
- sp.textContent = SPIN[spinIdx % SPIN.length];
500
- spinTimer = setInterval(function () {
501
- spinIdx = (spinIdx + 1) % SPIN.length;
502
- sp.textContent = SPIN[spinIdx];
503
- }, 90);
525
+ thinkingEl.innerHTML = '<span class="spinner spin"></span>';
504
526
  }
505
527
  chatLog.appendChild(thinkingEl); // append (or move) to bottom
528
+ startSpin();
506
529
  scrollBottom();
507
530
  }
508
531
  function hideThinking() {
509
- if (spinTimer) { clearInterval(spinTimer); spinTimer = null; }
510
532
  if (thinkingEl) thinkingEl.remove();
533
+ stopSpinIfIdle();
511
534
  }
512
535
 
513
536
  function addToolCall(toolName, argsStr, isError) {
514
- const label = (toolName + ': ' + argsStr).slice(0, 64);
537
+ // argsStr can be undefined (no args / JSON.stringify(undefined)); don't let
538
+ // that render as the literal "name: undefined" in the collapsed summary.
539
+ const label = (toolName + (argsStr ? ': ' + argsStr : '')).slice(0, 64);
515
540
  const d = document.createElement('details');
516
541
  d.className = 'tool-call' + (isError ? ' error' : '');
517
542
  const s = document.createElement('summary');
@@ -528,6 +553,82 @@ export function html(wsUrl) {
528
553
  sendBtn.disabled = !allow;
529
554
  }
530
555
 
556
+ // Stringify a tool result safely. A null/undefined result (e.g. a tool that
557
+ // hasn't produced output) must NOT become the JS value undefined, whose
558
+ // .slice() throws — a throw here aborts the whole snapshot rebuild after the
559
+ // log was already cleared, blanking the transcript on reconnect.
560
+ function toolResultText(result) {
561
+ if (result == null) return '';
562
+ const r = typeof result === 'string' ? result : JSON.stringify(result, null, 2);
563
+ return (r == null ? '' : r).slice(0, 8000);
564
+ }
565
+
566
+ // Render one tool part from the ordered parts list (running or finished).
567
+ function renderToolPart(p) {
568
+ const argsStr = typeof p.args === 'string' ? p.args : JSON.stringify(p.args);
569
+ const d = addToolCall(p.toolName, argsStr, p.isError);
570
+ if (p.done) {
571
+ const pre = document.createElement('pre');
572
+ pre.textContent = toolResultText(p.result);
573
+ d.appendChild(pre);
574
+ } else {
575
+ toolCallMap[p.toolCallId] = d; // a later tool_end delta fills the result
576
+ }
577
+ return d;
578
+ }
579
+
580
+ // A muted, centered system note (e.g. "Context compacted").
581
+ function addSystemLine(text) {
582
+ const el = document.createElement('div');
583
+ el.className = 'sysnote';
584
+ el.textContent = text;
585
+ chatLog.appendChild(el);
586
+ scrollBottom();
587
+ return el;
588
+ }
589
+
590
+ // Render one committed transcript turn. Assistant turns are an ordered list of
591
+ // parts (text segments + tool calls), so the layout matches the terminal's
592
+ // interleaving instead of one merged blob with tools dumped at the end.
593
+ function renderTurn(t) {
594
+ if (t.error) { addBubble('error', t.text); return; }
595
+ if (t.role === 'system') { addSystemLine(t.text); return; }
596
+ if (t.role === 'user') { addBubble('user', t.text); return; }
597
+ for (const p of (t.parts || [])) {
598
+ if (p.kind === 'text') { if (p.text) addBubble('assistant', p.text); }
599
+ else renderToolPart(p);
600
+ }
601
+ }
602
+
603
+ // Render the in-progress assistant turn from a snapshot, preserving order. The
604
+ // trailing OPEN text segment becomes the live streaming bubble (cursor + spin)
605
+ // so subsequent text_delta frames keep flowing into it.
606
+ function renderLiveTurn(live) {
607
+ const parts = live.parts || [];
608
+ for (let i = 0; i < parts.length; i++) {
609
+ const p = parts[i];
610
+ const last = i === parts.length - 1;
611
+ if (p.kind === 'text') {
612
+ if (last && live.textOpen) {
613
+ currentBubble = document.createElement('div');
614
+ currentBubble.className = 'bubble assistant';
615
+ const cursor = document.createElement('span');
616
+ cursor.className = 'cursor spin';
617
+ currentBubble.appendChild(cursor);
618
+ if (p.text) currentBubble.insertBefore(document.createTextNode(p.text), cursor);
619
+ chatLog.appendChild(currentBubble);
620
+ streamText = p.text || '';
621
+ startSpin();
622
+ scrollBottom();
623
+ } else if (p.text) {
624
+ addBubble('assistant', p.text);
625
+ }
626
+ } else {
627
+ renderToolPart(p);
628
+ }
629
+ }
630
+ }
631
+
531
632
  function showToast(message, level) {
532
633
  const t = document.createElement('div');
533
634
  t.className = 'toast ' + (level || 'info');
@@ -731,19 +832,27 @@ export function html(wsUrl) {
731
832
 
732
833
  function handleMsg(msg) {
733
834
  switch (msg.type) {
734
- case 'history':
735
- for (const t of (msg.turns || [])) {
736
- if (t.error) { addBubble('error', t.text); continue; }
737
- addBubble(t.role, t.text);
738
- for (const tool of (t.tools || [])) {
739
- const d = addToolCall(tool.toolName, JSON.stringify(tool.args), tool.isError);
740
- const pre = document.createElement('pre');
741
- const r = typeof tool.result === 'string' ? tool.result : JSON.stringify(tool.result, null, 2);
742
- pre.textContent = r.slice(0, 8000);
743
- d.appendChild(pre);
744
- }
745
- }
835
+ case 'snapshot': {
836
+ // Authoritative full state on every (re)connect: replace the WHOLE view.
837
+ // This is what kills duplicated transcript / stale-orphaned widgets —
838
+ // whatever was on screen is discarded and rebuilt from server truth.
839
+ chatLog.innerHTML = '';
840
+ closePrompt();
841
+ hideThinking();
842
+ currentBubble = null; streamText = '';
843
+ for (const k in toolCallMap) delete toolCallMap[k];
844
+ // Per-turn try/catch: one malformed turn must never abort the rebuild
845
+ // and leave the (already-cleared) transcript blank.
846
+ for (const t of (msg.turns || [])) { try { renderTurn(t); } catch (e) {} }
847
+ if (msg.live) { try { renderLiveTurn(msg.live); } catch (e) {} }
848
+ taskWidgetLines = (msg.taskWidget && msg.taskWidget.length) ? msg.taskWidget : null;
849
+ renderWidgets();
850
+ if (msg.context) setContextBar(msg.context); else contextFill.style.width = '0%';
851
+ if (msg.prompt) showPrompt(msg.prompt);
852
+ setEnabled(!msg.agentRunning && !msg.prompt);
853
+ if (msg.agentRunning && !msg.live) showThinking();
746
854
  break;
855
+ }
747
856
  case 'agent_start':
748
857
  autoScroll = true;
749
858
  streamText = '';
@@ -757,9 +866,10 @@ export function html(wsUrl) {
757
866
  currentBubble = document.createElement('div');
758
867
  currentBubble.className = 'bubble assistant';
759
868
  const cursor = document.createElement('span');
760
- cursor.className = 'cursor';
869
+ cursor.className = 'cursor spin';
761
870
  currentBubble.appendChild(cursor);
762
871
  chatLog.appendChild(currentBubble);
872
+ startSpin();
763
873
  }
764
874
  streamText += msg.delta;
765
875
  {
@@ -773,6 +883,11 @@ export function html(wsUrl) {
773
883
  const c = currentBubble.querySelector('.cursor');
774
884
  if (c) c.remove();
775
885
  if (streamText) setContent(currentBubble, streamText);
886
+ // Close this message's bubble so the next text segment (after a tool or
887
+ // the next message) starts a fresh bubble — matching the terminal.
888
+ currentBubble = null;
889
+ streamText = '';
890
+ stopSpinIfIdle();
776
891
  }
777
892
  break;
778
893
  case 'tool_start': {
@@ -786,8 +901,7 @@ export function html(wsUrl) {
786
901
  if (d) {
787
902
  if (msg.isError) d.classList.add('error');
788
903
  const pre = document.createElement('pre');
789
- const r = typeof msg.result === 'string' ? msg.result : JSON.stringify(msg.result, null, 2);
790
- pre.textContent = r.slice(0, 8000);
904
+ pre.textContent = toolResultText(msg.result);
791
905
  d.appendChild(pre);
792
906
  delete toolCallMap[msg.toolCallId];
793
907
  }
@@ -800,6 +914,9 @@ export function html(wsUrl) {
800
914
  case 'user_message':
801
915
  addBubble('user', msg.text);
802
916
  break;
917
+ case 'system_note':
918
+ addSystemLine(msg.text);
919
+ break;
803
920
  case 'agent_error':
804
921
  hideThinking();
805
922
  if (currentBubble) {
@@ -808,6 +925,7 @@ export function html(wsUrl) {
808
925
  if (streamText) setContent(currentBubble, streamText);
809
926
  currentBubble = null;
810
927
  streamText = '';
928
+ stopSpinIfIdle();
811
929
  }
812
930
  addBubble('error', msg.message || 'Error');
813
931
  setEnabled(true);
@@ -833,8 +951,7 @@ export function html(wsUrl) {
833
951
  if (activePromptId === msg.id) closePrompt();
834
952
  break;
835
953
  case 'widget':
836
- if (msg.lines && msg.lines.length) widgets[msg.key] = msg.lines;
837
- else delete widgets[msg.key];
954
+ taskWidgetLines = (msg.lines && msg.lines.length) ? msg.lines : null;
838
955
  renderWidgets();
839
956
  break;
840
957
  case 'notify':
@@ -850,7 +967,7 @@ export function html(wsUrl) {
850
967
  hideThinking();
851
968
  currentBubble = null; streamText = '';
852
969
  closePrompt();
853
- for (const k in widgets) delete widgets[k];
970
+ taskWidgetLines = null;
854
971
  renderWidgets();
855
972
  contextFill.style.width = '0%';
856
973
  break;
@@ -13,5 +13,12 @@ export declare function parseTaskList(body: string): TaskEntry[];
13
13
  export declare function buildAutoBody(feature: string, clarifications: string, titles: string[]): string;
14
14
  /** Check off the Nth checkbox line, stamping the produced TASK_NNNN id. */
15
15
  export declare function checkOffTask(cwd: string, id: string, index: number, producedId: string, title: string): Promise<void>;
16
+ /**
17
+ * Stamp the inner TASK_NNNN id onto the Nth (still-unchecked) entry the moment
18
+ * the inner task is allocated. This links the AUTO entry to its in-progress
19
+ * inner task so /task-auto-resume can continue it from its saved phase instead
20
+ * of starting a brand-new task — matching how /task-resume behaves.
21
+ */
22
+ export declare function stampTaskInProgress(cwd: string, id: string, index: number, producedId: string, title: string): Promise<void>;
16
23
  /** Find the most-recently-updated resumable TASK_AUTO_* file, or null. */
17
24
  export declare function findResumableAuto(cwd: string): Promise<string | null>;