agentgui 1.0.948 → 1.0.950

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.
@@ -28,6 +28,7 @@ const state = {
28
28
  live: { es: null, connected: false, lastEventTs: 0, error: null, eventCount: 0, reconnects: 0 },
29
29
  active: [],
30
30
  activeTimer: null,
31
+ eventsLimit: 300, // how many of the most-recent events to render; grows via "load older"
31
32
  };
32
33
 
33
34
  function readHash() {
@@ -70,12 +71,26 @@ function scheduleRender() {
70
71
  requestAnimationFrame(() => { renderScheduled = false; render(); });
71
72
  }
72
73
 
74
+ // Streaming renders (token deltas, tool events) arrive faster than a frame; a
75
+ // full webjsx diff + scroll per token thrashes. Coalesce both into one rAF tick
76
+ // so a fast stream costs ~60fps of renders, not one render per token.
77
+ let streamRenderScheduled = false;
78
+ function scheduleStreamRender() {
79
+ if (streamRenderScheduled) return;
80
+ streamRenderScheduled = true;
81
+ requestAnimationFrame(() => {
82
+ streamRenderScheduled = false;
83
+ render();
84
+ scrollChatToBottom();
85
+ });
86
+ }
87
+
73
88
  const NARROW_BP = 640; // unified with the CSS touch-target breakpoint in index.html
74
89
  function isNarrow() { return typeof window !== 'undefined' && window.innerWidth < NARROW_BP; }
75
90
  function truncate(str, mobileLen, desktopLen) {
76
91
  const s = String(str ?? '');
77
92
  const max = isNarrow() ? mobileLen : desktopLen;
78
- return s.length > max ? s.slice(0, max) + '...' : s;
93
+ return s.length > max ? s.slice(0, max) + '' : s;
79
94
  }
80
95
  function debounce(fn, ms) {
81
96
  let t;
@@ -115,15 +130,19 @@ function pillButton(key, label, active, title, onClick) {
115
130
  }, label);
116
131
  }
117
132
 
133
+ let _chatScroller = null;
118
134
  function scrollChatToBottom() {
119
- requestAnimationFrame(() => {
120
- // The design system's chat scroll container is .chat-thread; fall back to
121
- // the main region only if it's not mounted yet.
122
- const scroller = document.querySelector('.chat-thread')
135
+ // The kit's real scroll container is .agentchat-thread; the old .chat-thread
136
+ // selector matched nothing, so auto-scroll silently never fired. Cache the
137
+ // node and re-query only if it detached. Callers already run inside a frame
138
+ // (or right after render), so no nested rAF here.
139
+ if (!_chatScroller || !_chatScroller.isConnected) {
140
+ _chatScroller = document.querySelector('.agentchat-thread')
141
+ || document.querySelector('.chat-thread')
123
142
  || document.querySelector('#agentgui-main')
124
143
  || document.getElementById('app');
125
- if (scroller) scroller.scrollTop = scroller.scrollHeight;
126
- });
144
+ }
145
+ if (_chatScroller) _chatScroller.scrollTop = _chatScroller.scrollHeight;
127
146
  }
128
147
 
129
148
  function timeNow() {
@@ -137,6 +156,10 @@ async function selectAgent(id) {
137
156
  if (id === state.selectedAgent && state.agentModels.length) return;
138
157
  state.selectedAgent = id;
139
158
  lsSet('agentgui.agent', id);
159
+ // Resume state is claude-code-only (it threads claude's --resume sid). Switching
160
+ // to any other agent must clear it, or the resume banner keeps showing and a
161
+ // stale sid gets forwarded - which makes agy wrongly run --continue.
162
+ if (id !== 'claude-code') { state.chat.resumeSid = null; state.chat.resumeNote = null; }
140
163
  state.agentModels = [];
141
164
  state.selectedModel = '';
142
165
  state.modelsLoading = true;
@@ -246,7 +269,7 @@ function stopActivePolling() {
246
269
  // Re-render once a minute so relative timestamps ("5s ago") don't sit frozen
247
270
  // between events. Cheap: scheduleRender coalesces via rAF.
248
271
  let _relTick = null;
249
- function startRelTimeTick() { if (!_relTick) _relTick = setInterval(() => scheduleRender(), 30000); }
272
+ function startRelTimeTick() { if (!_relTick) _relTick = setInterval(() => { if (state.tab === 'history' || (Array.isArray(state.active) && state.active.length)) scheduleRender(); }, 30000); }
250
273
  async function stopActiveChat(sid) {
251
274
  try { await B.cancelChat(state.backend, sid); } catch {}
252
275
  refreshActive();
@@ -264,18 +287,26 @@ function openLiveStream() {
264
287
  if (!state.live.connected) state.live.connected = true;
265
288
  if (state.live.error) { state.live.error = null; state.live.reconnects++; }
266
289
  } else if (kind === 'event' && data) {
290
+ // ccsniff's stream frame is { sid, payload: <flattened event> } - the
291
+ // real event (type/isError/i/ts) lives under .payload, not on the
292
+ // wrapper. Reading the wrapper gave every counter `undefined`, so live
293
+ // tool/error tallies never moved. Route by data.sid, read ev for the rest.
294
+ const ev = data.payload || data;
267
295
  if (state.selectedSid && data.sid === state.selectedSid) {
268
- state.events.push(data);
269
- // Cap retained events so a long live session can't grow unbounded.
270
- if (state.events.length > 2000) state.events.splice(0, state.events.length - 2000);
296
+ // Dedupe against the snapshot/prior pushes by event index - a
297
+ // reconnect or overlap would otherwise double-append the same event.
298
+ if (ev.i == null || !state.events.some(e => e.i === ev.i)) {
299
+ state.events.push(ev);
300
+ // Cap retained events so a long live session can't grow unbounded.
301
+ if (state.events.length > 2000) state.events.splice(0, state.events.length - 2000);
302
+ }
271
303
  }
272
- const arr = Array.isArray(state.sessions) ? state.sessions : [];
273
- const sess = arr.find(s => s.sid === data.sid);
304
+ const sess = state.sessionsBySid ? state.sessionsBySid.get(data.sid) : null;
274
305
  if (sess) {
275
306
  sess.events = (sess.events || 0) + 1;
276
- sess.last = data.ts || Date.now();
277
- if (data.type === 'tool_use') sess.tools = (sess.tools || 0) + 1;
278
- if (data.isError) sess.errors = (sess.errors || 0) + 1;
307
+ sess.last = ev.ts || Date.now();
308
+ if (ev.type === 'tool_use') sess.tools = (sess.tools || 0) + 1;
309
+ if (ev.isError) sess.errors = (sess.errors || 0) + 1;
279
310
  } else {
280
311
  // Unknown session: a burst of events for a new session would trigger
281
312
  // a full session-list refetch per event - debounce it into one.
@@ -317,7 +348,7 @@ function view() {
317
348
  const dotLabel = state.tab === 'history'
318
349
  ? (state.live.error
319
350
  ? state.live.error + (state.live.reconnects ? ' · ' + state.live.reconnects + ' reconnects' : '')
320
- : (liveActive ? 'live · ' + state.live.eventCount : (state.live.connected ? 'live' : 'connecting...')))
351
+ : (liveActive ? 'live · ' + state.live.eventCount : (state.live.connected ? 'live' : 'connecting')))
321
352
  : (ok ? (state.health.ws === 'reconnecting' ? 'ws reconnecting' : 'connected') : 'offline');
322
353
  const dotLive = state.tab === 'history' ? (liveActive || state.live.connected) : ok;
323
354
  // The status dot is drawn entirely by CSS (.status-dot::before) - a small
@@ -369,7 +400,7 @@ function view() {
369
400
  ? 'agent: ' + (agentById(state.selectedAgent)?.name || state.selectedAgent) + (state.selectedModel ? ' · ' + state.selectedModel : '')
370
401
  : 'no agent';
371
402
  const status = Status({
372
- left: [state.backend || 'same-origin', ok ? 'live' : 'offline'],
403
+ left: [state.backend || 'same-origin', ok ? 'connected' : 'offline'],
373
404
  right: [agentLabel, 'press ? for shortcuts'],
374
405
  });
375
406
 
@@ -382,7 +413,7 @@ function view() {
382
413
  ? Alert({ key: 'sc', kind: 'info', title: 'Keyboard shortcuts',
383
414
  children: 'g then c/h/s - chat/history/settings · n - new chat · / - focus search/composer · ? - toggle this · Esc - blur field' })
384
415
  : null;
385
- const main = h('div', { id: 'agentgui-main', role: 'main', 'data-chat-scroll': '', class: 'agentgui-main agentgui-main-' + state.tab, style: mainStyle }, [shortcutsHint, ...mainContent()].filter(Boolean));
416
+ const main = h('div', { id: 'agentgui-main', role: 'region', 'aria-label': 'main content', 'data-chat-scroll': '', class: 'agentgui-main agentgui-main-' + state.tab, style: mainStyle }, [shortcutsHint, ...mainContent()].filter(Boolean));
386
417
  // narrow drives the DS main-column class; the history sidebar itself collapses
387
418
  // behind the DS .app-side-toggle hamburger (provided by AppShell when a side
388
419
  // exists), so mobile users get a togglable session list.
@@ -400,22 +431,56 @@ function canSend() {
400
431
  return !!state.selectedAgent && agentAvailable(state.selectedAgent) && !state.chat.busy;
401
432
  }
402
433
 
403
- function toolSummary(block) {
404
- const name = block.name || block.kind || 'tool';
405
- let arg = '';
406
- const inp = block.input || block.rawInput;
434
+ // A short, human one-liner for a tool's most salient argument (used as the
435
+ // collapsible card's label). The full args live in the structured part.
436
+ function toolLabel(inp) {
407
437
  if (inp && typeof inp === 'object') {
408
- arg = inp.command || inp.file_path || inp.path || inp.pattern || inp.query || inp.url || '';
409
- if (!arg) { try { arg = JSON.stringify(inp).slice(0, 120); } catch {} }
438
+ const a = inp.command || inp.file_path || inp.path || inp.pattern || inp.query || inp.url || '';
439
+ if (a) return String(a).slice(0, 160);
410
440
  }
411
- return 'tool: ' + name + (arg ? ' · ' + String(arg).slice(0, 120) : '');
441
+ return '';
442
+ }
443
+
444
+ // Build a structured tool part the kit's ToolCallNode renders as a collapsible
445
+ // card (name + label + full args, status icon). tool_result is matched back to
446
+ // this part by tool_use id so the result lands inside the same card.
447
+ function toolPart(block) {
448
+ const name = block.name || block.kind || 'tool';
449
+ const input = block.input || block.rawInput || {};
450
+ return {
451
+ kind: 'tool',
452
+ _id: block.id || block.tool_use_id || null,
453
+ name,
454
+ label: toolLabel(input),
455
+ args: input,
456
+ status: 'running',
457
+ };
412
458
  }
413
459
 
414
- function toolResultSummary(block) {
415
- const c = block?.content ?? block?.output ?? block;
416
- let s = typeof c === 'string' ? c : (() => { try { return JSON.stringify(c); } catch { return String(c); } })();
417
- s = s.replace(/\s+/g, ' ').trim();
418
- return (block?.is_error ? '[error] ' : '') + s.slice(0, 160);
460
+ // Append a streamed text delta as an interleaved markdown part, coalescing
461
+ // consecutive text into the trailing md part so prose and tool cards keep their
462
+ // arrival order within the turn (text -> tool -> text).
463
+ function appendText(parts, text) {
464
+ const last = parts[parts.length - 1];
465
+ if (last && last.kind === "md") last.text += text;
466
+ else parts.push({ kind: "md", text });
467
+ }
468
+
469
+ // Apply a tool_result to its matching tool part (by id), else append a
470
+ // standalone tool_result part. Sets the card's result + done/error status.
471
+ function applyToolResult(parts, block) {
472
+ const id = block.tool_use_id || block.id || null;
473
+ const content = block?.content ?? block?.output ?? block;
474
+ const isError = !!(block?.is_error);
475
+ const byId = id ? [...parts].reverse().find(p => p && p.kind === 'tool' && p._id === id) : null;
476
+ const target = byId || [...parts].reverse().find(p => p && p.kind === 'tool' && p.status === 'running');
477
+ if (target) {
478
+ target.result = content;
479
+ target.status = isError ? 'error' : 'done';
480
+ target.error = isError || undefined;
481
+ } else {
482
+ parts.push({ kind: 'tool_result', name: 'result', result: content, error: isError || undefined, status: isError ? 'error' : 'done' });
483
+ }
419
484
  }
420
485
 
421
486
  function errText(e) {
@@ -432,12 +497,22 @@ function chatMain() {
432
497
  // stream error) are pre-built here and handed to the kit, which renders them
433
498
  // above the thread. The kit holds no agentgui-specific banner logic.
434
499
  const banners = [];
435
- if (state.chat.resumeSid) {
500
+ // Non-claude-code agents cannot --resume by id, so a multi-turn chat with one
501
+ // silently loses prior context. Warn once the conversation is past its first
502
+ // turn so the user knows this agent is not carrying history forward.
503
+ {
504
+ const userTurns = state.chat.messages.filter(m => m.role === 'user').length;
505
+ if (state.selectedAgent && state.selectedAgent !== 'claude-code' && userTurns > 1 && !state.chat.resumeSid) {
506
+ banners.push(Alert({ key: 'nocontinuity', kind: 'info', title: (agentById(state.selectedAgent)?.name || state.selectedAgent) + ' does not resume across turns',
507
+ children: 'This agent starts fresh each message - it will not remember earlier turns in this chat. Use Claude Code for a continuous conversation.' }));
508
+ }
509
+ }
510
+ if (state.chat.resumeSid && state.selectedAgent === 'claude-code') {
436
511
  // The agent-switched note (if any) lives inside the resume banner rather
437
512
  // than as a separate stacked Alert, so resume context reads as one block.
438
513
  banners.push(h('div', { key: 'rb', class: 'resume-banner', role: 'status' },
439
514
  h('span', { key: 'rbtxt', class: 'lede' },
440
- 'resuming session ' + state.chat.resumeSid.slice(0, 8) + '... via --resume'
515
+ 'resuming session ' + state.chat.resumeSid.slice(0, 8) + ' via --resume'
441
516
  + (state.chat.resumeNote ? ' - ' + state.chat.resumeNote : '')),
442
517
  Btn({ key: 'rclr', onClick: () => { state.chat.resumeSid = null; state.chat.resumeNote = null; render(); }, children: 'clear' })));
443
518
  }
@@ -466,13 +541,14 @@ function chatMain() {
466
541
 
467
542
  const placeholder = !state.selectedAgent
468
543
  ? 'choose an agent first'
469
- : (!agentAvailable(state.selectedAgent) ? agentName + ' is not installed' : 'message...');
544
+ : (!agentAvailable(state.selectedAgent) ? agentName + ' is not installed' : 'message');
470
545
 
471
546
  // The reusable AgentChat kit owns the agent/model picker, cwd bar, transcript
472
547
  // (with AICat-style auto-scroll + thinking), and the caret-stable composer.
473
548
  // agentgui supplies state and wires every server interaction as a callback.
474
549
  return [
475
550
  offlineBanner(),
551
+ runningPanel(),
476
552
  AgentChat({
477
553
  agents: sortedAgents(),
478
554
  selectedAgent: state.selectedAgent,
@@ -483,12 +559,18 @@ function chatMain() {
483
559
  busy: state.chat.busy,
484
560
  draft: state.chat.draft,
485
561
  status: state.chat.busy
486
- ? (state.health.ws === 'reconnecting' ? 'reconnecting...' : 'streaming...')
487
- : (state.modelsLoading ? 'loading models...' : (state.chat.resumeSid ? 'resume' : 'ready')),
562
+ ? (state.health.ws === 'reconnecting' ? 'reconnecting' : 'streaming')
563
+ : (state.modelsLoading ? 'loading models' : ((state.chat.resumeSid && state.selectedAgent === 'claude-code') ? 'resuming…' : 'ready')),
488
564
  agentName,
489
565
  placeholder,
490
566
  canSend: canSend(),
491
567
  banners,
568
+ suggestions: state.chat.messages.length ? [] : [
569
+ 'Summarize the structure of this project',
570
+ 'What are the recent changes on this branch?',
571
+ 'Find and explain the main entry point',
572
+ ],
573
+ onSuggestionClick: (t) => { if (!canSend()) return; state.chat.draft = t; sendChat(); },
492
574
  cwd: state.chatCwd,
493
575
  cwdEditing: !!state.cwdEditing,
494
576
  cwdDraft: state.cwdDraft,
@@ -554,13 +636,27 @@ function cancelChat() {
554
636
  // Clear busy immediately so the composer re-enables and the "stop" button
555
637
  // flips back to "new" without waiting for the stream's finally block.
556
638
  state.chat.abort?.abort();
557
- if (state.chat.busy) { state.chat.busy = false; render(); }
639
+ // Drop a trailing empty assistant shell (aborted before any content) from the
640
+ // live array so the message count + suggestions-empty check stay correct; the
641
+ // paired user message is intentionally kept.
642
+ const msgs = state.chat.messages;
643
+ if (msgs.length && isEmptyTurn(msgs[msgs.length - 1])) msgs.pop();
644
+ if (state.chat.busy) { state.chat.busy = false; }
645
+ render();
558
646
  }
559
647
 
560
648
  const CHAT_KEY = 'agentgui.chat';
649
+ // An assistant turn with no content AND no parts is an empty shell (an aborted
650
+ // turn that produced nothing). The view already renders it as nothing; drop it
651
+ // from persisted/in-memory state so a restored chat never carries blank bubbles.
652
+ function isEmptyTurn(m) {
653
+ return m.role === 'assistant' && !m.content && !(Array.isArray(m.parts) && m.parts.length);
654
+ }
561
655
  function persistChat() {
562
656
  try {
563
- const msgs = state.chat.messages.map(m => ({ id: m.id, role: m.role, content: m.content, time: m.time, parts: m.parts }));
657
+ const msgs = state.chat.messages
658
+ .filter(m => !isEmptyTurn(m))
659
+ .map(m => ({ id: m.id, role: m.role, content: m.content, time: m.time, parts: m.parts }));
564
660
  if (!msgs.length) { lsRemove(CHAT_KEY); return; }
565
661
  lsSet(CHAT_KEY, JSON.stringify({ messages: msgs, resumeSid: state.chat.resumeSid, agent: state.selectedAgent, model: state.selectedModel }));
566
662
  } catch {}
@@ -571,8 +667,14 @@ function restoreChat() {
571
667
  if (!raw) return;
572
668
  const saved = JSON.parse(raw);
573
669
  if (Array.isArray(saved.messages) && saved.messages.length) {
574
- state.chat.messages = saved.messages.map(m => ({ ...m, parts: Array.isArray(m.parts) ? m.parts : [] }));
670
+ state.chat.messages = saved.messages
671
+ .filter(m => !isEmptyTurn(m))
672
+ .map(m => ({ ...m, parts: Array.isArray(m.parts) ? m.parts : [] }));
575
673
  state.chat.resumeSid = saved.resumeSid || null;
674
+ // Restore the agent/model the transcript belongs to, so a restored chat
675
+ // isn't silently shown under whatever agent the picker defaulted to.
676
+ state.chat.restoredAgent = saved.agent || null;
677
+ state.chat.restoredModel = saved.model || null;
576
678
  }
577
679
  } catch {}
578
680
  }
@@ -599,11 +701,22 @@ async function sendChat() {
599
701
  cwd: state.chatCwd || undefined,
600
702
  messages: state.chat.messages.slice(0, -1).map(m => ({ role: m.role, content: m.content })),
601
703
  signal: ctrl.signal,
602
- resumeSid: state.chat.resumeSid || undefined,
704
+ // Only claude-code consumes a resume sid; never forward a stale one to
705
+ // another agent (it makes agy spuriously run --continue).
706
+ resumeSid: (state.selectedAgent === 'claude-code' && state.chat.resumeSid) || undefined,
603
707
  })) {
604
- if (ev.type === 'text') { cur.content += ev.text; render(); scrollChatToBottom(); }
605
- else if (ev.type === 'tool') { cur.parts.push(toolSummary(ev.block)); render(); scrollChatToBottom(); }
606
- else if (ev.type === 'tool_result') { cur.parts.push('-> ' + toolResultSummary(ev.block)); render(); scrollChatToBottom(); }
708
+ if (ev.type === 'session') {
709
+ // Remember the server's session id so the NEXT turn resumes this
710
+ // conversation instead of cold-spawning. Only claude-code supports
711
+ // --resume by id; for other agents we leave resumeSid unset.
712
+ if (state.selectedAgent === 'claude-code' && ev.sessionId) {
713
+ state.chat.resumeSid = ev.sessionId;
714
+ persistChat();
715
+ }
716
+ }
717
+ else if (ev.type === 'text') { appendText(cur.parts, ev.text); scheduleStreamRender(); }
718
+ else if (ev.type === 'tool') { cur.parts.push(toolPart(ev.block)); scheduleStreamRender(); }
719
+ else if (ev.type === 'tool_result') { applyToolResult(cur.parts, ev.block); scheduleStreamRender(); }
607
720
  else if (ev.type === 'result') { /* terminal usage/summary block - already reflected via text */ }
608
721
  else if (ev.type === 'error') { cur.error = errText(ev.error); render(); }
609
722
  }
@@ -670,21 +783,38 @@ function historyMain() {
670
783
  ? h('div', { key: 'noev', class: 'lede empty-state', role: 'status' },
671
784
  h('span', { key: 'noevtxt' }, 'no events in this session - '),
672
785
  Btn({ key: 'reload', onClick: () => loadSession(state.selectedSid), children: 'reload' }))
673
- : h('div', { key: 'loading', class: 'lede empty-state', role: 'status', 'aria-live': 'polite' }, Spinner({ key: 'spin', size: 'sm' }), 'loading events...');
786
+ : h('div', { key: 'loading', class: 'lede empty-state', role: 'status', 'aria-live': 'polite' }, Spinner({ key: 'spin', size: 'sm' }), 'loading events');
674
787
  return [reconnectAlert(), head, actions, Panel({ title: 'events', children: body })].filter(Boolean);
675
788
  }
676
789
 
677
790
  if (!state.expandedEvents) state.expandedEvents = new Set();
678
791
  const total = state.events.length;
679
- const shown = state.events.slice(-300);
792
+ const limit = state.eventsLimit;
793
+ const shown = state.events.slice(-limit);
680
794
  const hiddenCount = total - shown.length;
795
+ // Keys of the currently-shown rows, so expand-all toggles only what's rendered.
796
+ const shownKeys = shown.map((e, i) => {
797
+ const absIdx = total - shown.length + i;
798
+ return e.i != null ? 'ev' + e.i : 'ev-' + (e.ts || 0) + '-' + (e.type || '') + '-' + absIdx;
799
+ });
800
+ const allExpanded = shownKeys.length > 0 && shownKeys.every(k => state.expandedEvents.has(k));
801
+ const eventControls = h('div', { key: 'evctrl', class: 'history-actions', role: 'group', 'aria-label': 'event controls' },
802
+ Btn({ key: 'expall', onClick: () => {
803
+ if (allExpanded) { shownKeys.forEach(k => state.expandedEvents.delete(k)); }
804
+ else { shownKeys.forEach(k => state.expandedEvents.add(k)); }
805
+ render();
806
+ }, children: allExpanded ? 'collapse all' : 'expand all' }),
807
+ hiddenCount > 0
808
+ ? Btn({ key: 'older', onClick: () => { state.eventsLimit += 300; render(); }, children: 'load ' + Math.min(300, hiddenCount) + ' older (' + hiddenCount + ' hidden)' })
809
+ : null,
810
+ );
681
811
  return [
682
812
  reconnectAlert(),
683
813
  head,
684
814
  actions,
685
815
  Panel({
686
- title: total + ' events' + (hiddenCount > 0 ? ' (showing last 300; ' + hiddenCount + ' older not rendered)' : ''),
687
- children: EventList({
816
+ title: total + ' events' + (hiddenCount > 0 ? ' (showing last ' + shown.length + '; ' + hiddenCount + ' older)' : ''),
817
+ children: [eventControls, EventList({
688
818
  items: shown.map((e, i) => {
689
819
  // Stable key: prefer the server-assigned event index, else the
690
820
  // event timestamp + position, never a bare array index (which
@@ -701,10 +831,18 @@ function historyMain() {
701
831
  const raw = e.text || '';
702
832
  const text = raw.replace(/\s+/g, ' ').trim();
703
833
  const expanded = state.expandedEvents.has(key);
704
- const full = e.toolInput ? (text + '\n\n' + JSON.stringify(e.toolInput, null, 2)) : raw;
834
+ // Only build the expanded body (JSON.stringify tool input) when the row is
835
+ // expanded - doing it for all ~300 rows every frame wastes work mid-stream.
836
+ const full = expanded ? (e.toolInput ? (text + '\n\n' + JSON.stringify(e.toolInput, null, 2)) : raw) : '';
837
+ // Rail tone matches the session/agents rail semantics so an event's
838
+ // kind is visible at a glance, consistent across the GUI:
839
+ // flame = error, purple = tool_use, green = normal turn.
840
+ const rail = e.isError ? 'flame' : 'green';
705
841
  return {
706
842
  key,
707
843
  code: String(absIdx + 1).padStart(4, '0'),
844
+ rail,
845
+ expanded, // disclosure state -> kit Row sets aria-expanded
708
846
  title: expanded ? (full || '(' + type + ')') : (text.slice(0, 220) || '(' + type + ')'),
709
847
  // Guard ts: a missing/zero timestamp renders "Invalid Date" otherwise.
710
848
  // Every row is click-to-expand, so always show the affordance word
@@ -713,7 +851,7 @@ function historyMain() {
713
851
  onClick: () => { expanded ? state.expandedEvents.delete(key) : state.expandedEvents.add(key); render(); },
714
852
  };
715
853
  }),
716
- }),
854
+ })],
717
855
  }),
718
856
  ].filter(Boolean);
719
857
  }
@@ -785,6 +923,26 @@ function uniqueProjects() {
785
923
  return Array.from(seen.entries()).sort((a, b) => b[1] - a[1]);
786
924
  }
787
925
 
926
+ // The running-chats panel: in-flight chats with per-session stop. Shown on the
927
+ // history sidebar AND the chat tab, so a chat started then navigated-away-from
928
+ // stays visible and stoppable from anywhere (active polling is global).
929
+ function runningPanel() {
930
+ const running = Array.isArray(state.active) ? state.active : [];
931
+ if (!running.length) return null;
932
+ return Panel({
933
+ key: 'runningPanel',
934
+ title: 'running · ' + running.length,
935
+ children: running.map((r) => {
936
+ const agentName = agentById(r.agentId)?.name || r.agentId || 'agent';
937
+ const elapsed = r.startedAt ? Math.round((Date.now() - r.startedAt) / 1000) : 0;
938
+ return h('div', { key: 'run' + r.sessionId, class: 'resume-banner', role: 'group' },
939
+ h('span', { key: 'rd', class: 'status-dot-disc status-dot-live', 'aria-hidden': 'true' }),
940
+ h('span', { class: 'lede' }, agentName + (r.model ? ' · ' + r.model : '') + ' · ' + elapsed + 's' + (r.cwd ? ' · ' + r.cwd.split(/[/\\]/).filter(Boolean).slice(-1)[0] : '')),
941
+ Btn({ key: 'stop' + r.sessionId, onClick: () => stopActiveChat(r.sessionId), children: 'stop' }));
942
+ }),
943
+ });
944
+ }
945
+
788
946
  function historySide() {
789
947
  const searching = !!state.searchHits;
790
948
  const sessionsView = visibleSessions();
@@ -802,7 +960,9 @@ function historySide() {
802
960
  sub: (r.project || '?') + ' · ' + (r.role || '?') + (r.tool ? ' · ' + r.tool : ''),
803
961
  // Rail carries the same semantics as session rows: error > subagent > normal.
804
962
  rail: r.isError ? 'flame' : (r.isSubagent ? 'purple' : 'green'),
805
- onClick: () => loadSession(r.sid),
963
+ // Carry the matched event's index so loadSession scrolls to + flashes
964
+ // the match, instead of dropping the user at the top of the session.
965
+ onClick: () => loadSession(r.sid, { focusEventI: r.i, focusEventTs: r.ts }),
806
966
  })
807
967
  )
808
968
  : visible.map((s, i) =>
@@ -820,23 +980,9 @@ function historySide() {
820
980
  );
821
981
  const subagentCount = (Array.isArray(state.sessions) ? state.sessions : []).filter(s => s.isSubagent).length;
822
982
  const projects = uniqueProjects();
823
- const running = Array.isArray(state.active) ? state.active : [];
824
983
 
825
984
  return [
826
- running.length
827
- ? Panel({
828
- key: 'runningPanel',
829
- title: 'running · ' + running.length,
830
- children: running.map((r, i) => {
831
- const agentName = agentById(r.agentId)?.name || r.agentId || 'agent';
832
- const elapsed = r.startedAt ? Math.round((Date.now() - r.startedAt) / 1000) : 0;
833
- return h('div', { key: 'run' + r.sessionId, class: 'resume-banner', role: 'group' },
834
- h('span', { key: 'rd', class: 'status-dot-disc status-dot-live', 'aria-hidden': 'true' }),
835
- h('span', { class: 'lede' }, agentName + (r.model ? ' · ' + r.model : '') + ' · ' + elapsed + 's' + (r.cwd ? ' · ' + r.cwd.split(/[/\\]/).filter(Boolean).slice(-1)[0] : '')),
836
- Btn({ key: 'stop' + r.sessionId, onClick: () => stopActiveChat(r.sessionId), children: 'stop' }));
837
- }),
838
- })
839
- : null,
985
+ runningPanel(),
840
986
  Panel({
841
987
  title: searching
842
988
  ? 'matches · ' + (state.searchHits.results?.length || 0)
@@ -853,7 +999,7 @@ function historySide() {
853
999
  onInput: (v) => { state.searchQ = v; debouncedSearch(); },
854
1000
  }),
855
1001
  state.searchBusy
856
- ? h('div', { key: 'searchbusy', class: 'lede empty-state', role: 'status' }, Spinner({ key: 'ss', size: 'sm' }), 'searching...')
1002
+ ? h('div', { key: 'searchbusy', class: 'lede empty-state', role: 'status' }, Spinner({ key: 'ss', size: 'sm' }), 'searching')
857
1003
  : null,
858
1004
  searching && state.searchHits.error
859
1005
  ? Alert({ key: 'searcherr', kind: 'error', title: 'Search failed', children: state.searchHits.error })
@@ -897,8 +1043,19 @@ function historySide() {
897
1043
  // --- settings ---
898
1044
  function isValidUrl(s) {
899
1045
  if (!s) return true; // blank = same-origin is valid
900
- try { new URL(s.startsWith('http') ? s : 'http://' + s); return true; }
901
- catch { return false; }
1046
+ try {
1047
+ const u = new URL(s.startsWith('http') ? s : 'http://' + s);
1048
+ return u.protocol === 'http:' || u.protocol === 'https:'; // reject ftp:/ws:/etc
1049
+ } catch { return false; }
1050
+ }
1051
+
1052
+ // Canonicalize a backend URL to its origin so a schemeless 'localhost:3000'
1053
+ // (which would otherwise be stored raw and resolved against the page origin)
1054
+ // becomes the same string we validated. Blank stays blank (same-origin).
1055
+ function normalizeBackend(s) {
1056
+ if (!s) return '';
1057
+ try { return new URL(s.startsWith('http') ? s : 'http://' + s).origin; }
1058
+ catch { return s; }
902
1059
  }
903
1060
 
904
1061
  async function saveBackend() {
@@ -909,8 +1066,10 @@ async function saveBackend() {
909
1066
  state.confirmingBackend = true; render(); return;
910
1067
  }
911
1068
  state.confirmingBackend = false;
912
- B.setBackend(state.backendDraft);
913
- state.backend = state.backendDraft;
1069
+ const canonical = normalizeBackend(state.backendDraft);
1070
+ state.backendDraft = canonical;
1071
+ B.setBackend(canonical);
1072
+ state.backend = canonical;
914
1073
  state.health = { status: 'unknown' };
915
1074
  state.backendStatus = 'connecting';
916
1075
  render();
@@ -924,11 +1083,21 @@ function healthSummary() {
924
1083
  const ok = hh.status === 'ok';
925
1084
  // Each chip carries a title so its meaning isn't left to inference.
926
1085
  const bits = [];
927
- bits.push([hh.status || 'unknown', 'Backend connection status']);
1086
+ // Use the same connection vocabulary as the crumb status dot
1087
+ // (connected/offline/connecting) so the same state reads the same word
1088
+ // everywhere, instead of the raw health.status ('ok'/'down').
1089
+ const connWord = hh.status === 'ok' ? 'connected'
1090
+ : hh.status === 'unknown' ? 'connecting'
1091
+ : 'offline';
1092
+ bits.push([connWord, 'Backend connection status']);
928
1093
  if (hh.version) bits.push(['v' + hh.version, 'Server version']);
929
1094
  if (typeof hh.agents === 'number') bits.push([hh.agents + ' agents', 'Agents registered on the server']);
930
1095
  if (typeof hh.activeExecutions === 'number') bits.push([hh.activeExecutions + ' active', 'Chats currently executing']);
931
- if (hh.db) bits.push(['db ' + (hh.db.ok ? 'ok' : 'down'), 'History database status']);
1096
+ // db chip uses the same online/offline register as the connection chip
1097
+ // (not ok/down, which the connection chip already translated away). Always
1098
+ // render it: when /health is partial and hh.db is absent, show 'db unknown'
1099
+ // rather than dropping the chip and leaving the db state ambiguous.
1100
+ bits.push(['db ' + (hh.db ? (hh.db.ok ? 'online' : 'offline') : 'unknown'), 'History database status']);
932
1101
  return h('div', { key: 'hp', class: 'health-summary' + (ok ? ' health-ok' : ''), role: 'group', 'aria-label': 'Backend health' },
933
1102
  ...bits.map(([b, t], i) => h('span', { key: 'hb' + i, class: 'health-chip', title: t }, b)));
934
1103
  }
@@ -959,7 +1128,7 @@ function settingsMain() {
959
1128
  onInput: (v) => { state.backendDraft = v; render(); },
960
1129
  }),
961
1130
  !isValid ? h('p', { key: 'err', id: 'backend-url-error', class: 'lede field-error', role: 'alert' }, 'Invalid URL format') : null,
962
- state.backendStatus === 'connecting' ? h('p', { key: 'bst', class: 'lede', role: 'status' }, 'connecting...') : null,
1131
+ state.backendStatus === 'connecting' ? h('p', { key: 'bst', class: 'lede', role: 'status' }, 'connecting') : null,
963
1132
  state.backendStatus === 'ok' ? h('p', { key: 'bst', class: 'lede', role: 'status' }, 'connected') : null,
964
1133
  state.backendStatus === 'failed' ? h('p', { key: 'bst', class: 'lede field-error', role: 'alert' }, 'connection failed - check the URL') : null,
965
1134
  state.confirmingBackend ? h('p', { key: 'bcw', class: 'lede field-error', role: 'alert' }, 'changing backend discards this browser\'s chat transcript - press save again to confirm') : null,
@@ -970,7 +1139,7 @@ function settingsMain() {
970
1139
  primary: true,
971
1140
  disabled: !isValid || state.backendDraft === state.backend || state.backendStatus === 'connecting',
972
1141
  onClick: (e) => { e.preventDefault(); saveBackend(); },
973
- children: state.backendStatus === 'connecting' ? 'connecting...' : 'save + reconnect',
1142
+ children: state.backendStatus === 'connecting' ? 'connecting' : 'save + reconnect',
974
1143
  title: isValid ? 'Save backend URL and reconnect' : 'Fix URL format first',
975
1144
  }),
976
1145
  ]),
@@ -985,10 +1154,14 @@ function clearLocalData() {
985
1154
  if (!state.confirmingClearData) { state.confirmingClearData = true; render(); return; }
986
1155
  state.confirmingClearData = false;
987
1156
  // Wipe agentgui's own localStorage keys (chat transcript, agent/model/cwd,
988
- // backend). Leaves the page reload to re-init from defaults.
1157
+ // backend). Then reload: clearing only the keys but leaving the in-memory
1158
+ // selectedAgent/Model/chatCwd/backend would let the next interaction
1159
+ // re-persist them, so the "clear" wouldn't survive. A reload re-inits from
1160
+ // defaults with the keys gone.
1161
+ state.chat.abort?.abort(); // stop any in-flight stream before we drop the page
989
1162
  for (const k of ['agentgui.chat', 'agentgui.agent', 'agentgui.model', 'agentgui.cwd', 'agentgui.backend']) lsRemove(k);
990
1163
  state.chat = { messages: [], busy: false, abort: null, draft: '', resumeSid: null };
991
- render();
1164
+ location.reload();
992
1165
  }
993
1166
 
994
1167
  function preferencesPanel() {
@@ -999,6 +1172,8 @@ function preferencesPanel() {
999
1172
  h('div', { key: 'ver', class: 'lede' }, 'server ' + (hh.version ? 'v' + hh.version : 'version unknown') + (window.__SERVER_VERSION ? ' · build ' + window.__SERVER_VERSION : '')),
1000
1173
  h('div', { key: 'sc', class: 'lede', style: 'margin:.5em 0' },
1001
1174
  'keyboard: g then c/h/s - switch tabs · n - new chat · / - focus search/composer · ? - toggle hint · Esc - blur field'),
1175
+ h('div', { key: 'cwdnote', class: 'lede', style: 'margin:.5em 0' },
1176
+ 'working directory: set per-chat in the chat composer’s cwd bar' + (state.chatCwd ? ' (current: ' + state.chatCwd + ')' : ' (currently: server default)')),
1002
1177
  state.confirmingClearData
1003
1178
  ? Alert({ key: 'cld', kind: 'warn', title: 'Clear all local data?',
1004
1179
  children: [
@@ -1017,16 +1192,23 @@ function acpStatusFor(agentId) {
1017
1192
 
1018
1193
  function agentsPanel() {
1019
1194
  const installed = state.agents.filter(a => a.available !== false);
1195
+ const hasAcp = (Array.isArray(state.health.acp) ? state.health.acp : []).length > 0;
1020
1196
  return Panel({
1021
1197
  title: 'agents · ' + installed.length + '/' + state.agents.length + ' installed',
1022
- children: state.agents.length
1198
+ children: [
1199
+ // ACP agents (opencode/kilo/codex) are managed automatically: started
1200
+ // on-demand and restarted with backoff by the server, so there are no
1201
+ // manual start/stop controls by design. This note makes that explicit so
1202
+ // a 'stopped' row doesn't read as a missing action.
1203
+ hasAcp ? h('p', { key: 'acpnote', class: 'lede', style: 'margin:0 0 .5em' }, 'ACP agents start on demand and restart automatically; selecting one launches it.') : null,
1204
+ ...(state.agents.length
1023
1205
  ? state.agents.map((a, i) => {
1024
1206
  const acp = acpStatusFor(a.id);
1025
1207
  const avail = a.available !== false;
1026
1208
  const usable = avail || a.npxInstallable; // selectable from this row
1027
1209
  const bits = [a.protocol || 'agent'];
1028
1210
  if (!avail) bits.push(a.npxInstallable ? 'runs via npx' : 'not installed');
1029
- if (acp) bits.push(acp.healthy ? 'running·healthy' : (acp.running ? 'running' : 'stopped'));
1211
+ if (acp) bits.push(acp.healthy ? 'running healthy' : (acp.running ? 'running' : 'stopped'));
1030
1212
  if (acp && acp.port) bits.push('port ' + acp.port);
1031
1213
  if (acp && acp.restartCount) bits.push(acp.restartCount + ' restarts');
1032
1214
  return Row({
@@ -1034,7 +1216,10 @@ function agentsPanel() {
1034
1216
  rank: String(i + 1).padStart(3, '0'),
1035
1217
  title: a.name,
1036
1218
  sub: bits.join(' · '),
1037
- rail: a.id === state.selectedAgent ? 'green' : (avail ? 'purple' : 'flame'),
1219
+ // Rail tone keeps its GUI-wide meaning: green=ok/selected,
1220
+ // flame=error/unavailable. Selection is shown via `active`, not by
1221
+ // borrowing purple (purple is reserved for subagents).
1222
+ rail: (a.id === state.selectedAgent || avail) ? 'green' : 'flame',
1038
1223
  active: a.id === state.selectedAgent,
1039
1224
  // Non-installable agents are genuinely inert: mark them disabled (no
1040
1225
  // click, no button role) instead of looking clickable but doing nothing.
@@ -1042,7 +1227,8 @@ function agentsPanel() {
1042
1227
  onClick: usable ? () => { navTo('chat'); selectAgent(a.id); } : undefined,
1043
1228
  });
1044
1229
  })
1045
- : h('p', { key: 'none', class: 'lede' }, 'no agents loaded'),
1230
+ : [h('p', { key: 'none', class: 'lede' }, 'no agents loaded')]),
1231
+ ].filter(Boolean),
1046
1232
  });
1047
1233
  }
1048
1234
 
@@ -1050,6 +1236,9 @@ function agentsPanel() {
1050
1236
  async function refreshHistory() {
1051
1237
  try {
1052
1238
  state.sessions = await B.listSessions(state.backend);
1239
+ // Index by sid so each live SSE event is an O(1) lookup, not an O(sessions)
1240
+ // linear scan per event during a burst load.
1241
+ state.sessionsBySid = new Map((state.sessions || []).map(s => [s.sid, s]));
1053
1242
  state.historyError = null;
1054
1243
  render();
1055
1244
  } catch (e) {
@@ -1077,14 +1266,19 @@ async function runSearch() {
1077
1266
  }
1078
1267
  const debouncedSearch = debounce(runSearch, 300);
1079
1268
 
1080
- async function loadSession(sid) {
1269
+ async function loadSession(sid, { focusEventI = null, focusEventTs = null } = {}) {
1081
1270
  // Guard against a bad sid from a malformed hash (e.g. "?sid=undefined").
1082
1271
  if (!sid || sid === 'undefined' || sid === 'null') { state.selectedSid = null; render(); return; }
1083
1272
  state.selectedSid = sid;
1084
1273
  state.events = [];
1085
1274
  state.eventsLoaded = false;
1275
+ state.eventsLimit = 300; // reset the render window per session
1086
1276
  state.expandedEvents = new Set(); // don't carry expansion to the new session
1087
1277
  writeHash(sid, { push: true });
1278
+ // Close the mobile sidebar drawer on selection. The DS only auto-closes when
1279
+ // the clicked element is an <a>; agentgui's session rows are onClick divs, so
1280
+ // we close it explicitly here.
1281
+ document.querySelector('.app-body.side-open')?.classList.remove('side-open');
1088
1282
  render();
1089
1283
  // Bring the now-active sidebar row into view (deep-link / back-forward may
1090
1284
  // select a row that's scrolled out of the session list).
@@ -1094,6 +1288,26 @@ async function loadSession(sid) {
1094
1288
  try {
1095
1289
  state.events = await B.getSessionEvents(state.backend, sid);
1096
1290
  state.eventsLoaded = true;
1291
+ // If we arrived from a search hit, make sure the matched event is within the
1292
+ // render window, then scroll to + flash it so the match isn't lost.
1293
+ if (focusEventI != null || focusEventTs != null) {
1294
+ const idx = state.events.findIndex(e => (focusEventI != null && e.i === focusEventI) || (focusEventTs != null && e.ts === focusEventTs));
1295
+ if (idx >= 0) {
1296
+ const fromEnd = state.events.length - idx;
1297
+ if (fromEnd > state.eventsLimit) state.eventsLimit = Math.ceil(fromEnd / 300) * 300;
1298
+ render();
1299
+ // The rendered EventList shows the last eventsLimit events in order, so
1300
+ // the matched event's row is at (idx - sliceStart) among .ds-event-list rows.
1301
+ const sliceStart = Math.max(0, state.events.length - state.eventsLimit);
1302
+ const rowPos = idx - sliceStart;
1303
+ requestAnimationFrame(() => {
1304
+ const rows = document.querySelectorAll('.ds-event-list .row');
1305
+ const row = rows[rowPos];
1306
+ if (row) { row.scrollIntoView({ block: 'center' }); row.classList.add('event-flash'); setTimeout(() => row.classList.remove('event-flash'), 2000); }
1307
+ });
1308
+ return;
1309
+ }
1310
+ }
1097
1311
  render();
1098
1312
  } catch (e) {
1099
1313
  state.events = [{
@@ -1117,8 +1331,11 @@ async function init() {
1117
1331
  render();
1118
1332
  try {
1119
1333
  state.agents = await B.listAgents(state.backend);
1120
- // Restore the saved agent if still present; else first available, else first.
1121
- let target = state.agents.find(a => a.id === state.selectedAgent);
1334
+ // Agent selection priority: the agent a restored transcript belongs to (so
1335
+ // the chat isn't shown under the wrong agent), else the saved picker agent,
1336
+ // else first available, else first.
1337
+ let target = (state.chat.restoredAgent && state.agents.find(a => a.id === state.chat.restoredAgent))
1338
+ || state.agents.find(a => a.id === state.selectedAgent);
1122
1339
  if (!target) target = state.agents.find(a => a.available !== false) || state.agents[0];
1123
1340
  if (target) await selectAgent(target.id);
1124
1341
  render();