agentgui 1.0.947 → 1.0.949

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.
@@ -11,10 +11,10 @@ const state = {
11
11
  health: { status: 'unknown' },
12
12
  tab: 'chat',
13
13
  agents: [],
14
- selectedAgent: localStorage.getItem('agentgui.agent') || '',
14
+ selectedAgent: lsGet('agentgui.agent') || '',
15
15
  agentModels: [],
16
- selectedModel: localStorage.getItem('agentgui.model') || '',
17
- chatCwd: localStorage.getItem('agentgui.cwd') || '',
16
+ selectedModel: lsGet('agentgui.model') || '',
17
+ chatCwd: lsGet('agentgui.cwd') || '',
18
18
  chat: { messages: [], busy: false, abort: null, draft: '', resumeSid: null },
19
19
  sessions: [],
20
20
  selectedSid: null,
@@ -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,6 +71,20 @@ 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) {
@@ -82,6 +97,7 @@ function debounce(fn, ms) {
82
97
  return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
83
98
  }
84
99
 
100
+ function lsGet(k) { try { return localStorage.getItem(k); } catch { return null; } }
85
101
  function lsSet(k, v) { try { localStorage.setItem(k, v); } catch {} }
86
102
  function lsRemove(k) { try { localStorage.removeItem(k); } catch {} }
87
103
 
@@ -132,10 +148,10 @@ function timeNow() {
132
148
 
133
149
  async function selectAgent(id) {
134
150
  // Re-selecting the same agent would needlessly refetch models and reset the
135
- // current model selection no-op early.
151
+ // current model selection - no-op early.
136
152
  if (id === state.selectedAgent && state.agentModels.length) return;
137
153
  state.selectedAgent = id;
138
- localStorage.setItem('agentgui.agent', id);
154
+ lsSet('agentgui.agent', id);
139
155
  state.agentModels = [];
140
156
  state.selectedModel = '';
141
157
  state.modelsLoading = true;
@@ -144,7 +160,7 @@ async function selectAgent(id) {
144
160
  if (state.selectedAgent !== id) return; // changed while loading
145
161
  state.modelsLoading = false;
146
162
  state.agentModels = models;
147
- const saved = localStorage.getItem('agentgui.model');
163
+ const saved = lsGet('agentgui.model');
148
164
  // Only restore a saved model that the NEW agent actually offers; otherwise
149
165
  // fall back to its first model (never carry a stale model from a prior agent).
150
166
  state.selectedModel = (saved && models.some(m => m.id === saved)) ? saved : (models[0]?.id || '');
@@ -153,7 +169,7 @@ async function selectAgent(id) {
153
169
 
154
170
  function selectModel(id) {
155
171
  state.selectedModel = id;
156
- localStorage.setItem('agentgui.model', id);
172
+ lsSet('agentgui.model', id);
157
173
  render();
158
174
  }
159
175
 
@@ -161,7 +177,7 @@ function agentById(id) { return state.agents.find(a => a.id === id); }
161
177
  function agentAvailable(id) { const a = agentById(id); return !a || a.available !== false; }
162
178
 
163
179
  // The four flagship orchestration targets surface first, then other available
164
- // agents, then npx-installable, then not-installed so the agents the GUI
180
+ // agents, then npx-installable, then not-installed - so the agents the GUI
165
181
  // exists to drive are reachable without scanning a flat 17-item list. This
166
182
  // ordering is agentgui's orchestration priority, so it stays in the host and is
167
183
  // passed pre-sorted to the (app-agnostic) AgentChat kit.
@@ -206,7 +222,7 @@ function navTo(tab) {
206
222
  const heading = region.querySelector('h1, h2');
207
223
  const target = heading || region;
208
224
  if (!target.hasAttribute('tabindex')) target.setAttribute('tabindex', '-1');
209
- // Mark as programmatically focused so CSS can suppress the focus ring we
225
+ // Mark as programmatically focused so CSS can suppress the focus ring - we
210
226
  // move focus here for AT, but a visible green outline box around the heading
211
227
  // reads as an accidental border to sighted users.
212
228
  target.setAttribute('data-prog-focus', '');
@@ -277,7 +293,7 @@ function openLiveStream() {
277
293
  if (data.isError) sess.errors = (sess.errors || 0) + 1;
278
294
  } else {
279
295
  // Unknown session: a burst of events for a new session would trigger
280
- // a full session-list refetch per event debounce it into one.
296
+ // a full session-list refetch per event - debounce it into one.
281
297
  debouncedRefreshHistory();
282
298
  return;
283
299
  }
@@ -319,7 +335,7 @@ function view() {
319
335
  : (liveActive ? 'live · ' + state.live.eventCount : (state.live.connected ? 'live' : 'connecting…')))
320
336
  : (ok ? (state.health.ws === 'reconnecting' ? 'ws reconnecting' : 'connected') : 'offline');
321
337
  const dotLive = state.tab === 'history' ? (liveActive || state.live.connected) : ok;
322
- // The status dot is drawn entirely by CSS (.status-dot::before) a small
338
+ // The status dot is drawn entirely by CSS (.status-dot::before) - a small
323
339
  // colored disc, real product design, not a text glyph. State drives its colour
324
340
  // via the modifier class; the label carries only words so AT reads "live", and
325
341
  // there are no literal status-glyph characters in the DOM.
@@ -379,7 +395,7 @@ function view() {
379
395
  : 'min-height:0';
380
396
  const shortcutsHint = state.showShortcuts
381
397
  ? Alert({ key: 'sc', kind: 'info', title: 'Keyboard shortcuts',
382
- children: 'g then c/h/s chat/history/settings · n new chat · / focus search/composer · ? toggle this · Esc blur field' })
398
+ children: 'g then c/h/s - chat/history/settings · n - new chat · / - focus search/composer · ? - toggle this · Esc - blur field' })
383
399
  : null;
384
400
  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));
385
401
  // narrow drives the DS main-column class; the history sidebar itself collapses
@@ -399,22 +415,46 @@ function canSend() {
399
415
  return !!state.selectedAgent && agentAvailable(state.selectedAgent) && !state.chat.busy;
400
416
  }
401
417
 
402
- function toolSummary(block) {
403
- const name = block.name || block.kind || 'tool';
404
- let arg = '';
405
- const inp = block.input || block.rawInput;
418
+ // A short, human one-liner for a tool's most salient argument (used as the
419
+ // collapsible card's label). The full args live in the structured part.
420
+ function toolLabel(inp) {
406
421
  if (inp && typeof inp === 'object') {
407
- arg = inp.command || inp.file_path || inp.path || inp.pattern || inp.query || inp.url || '';
408
- if (!arg) { try { arg = JSON.stringify(inp).slice(0, 120); } catch {} }
422
+ const a = inp.command || inp.file_path || inp.path || inp.pattern || inp.query || inp.url || '';
423
+ if (a) return String(a).slice(0, 160);
409
424
  }
410
- return 'tool: ' + name + (arg ? ' · ' + String(arg).slice(0, 120) : '');
425
+ return '';
426
+ }
427
+
428
+ // Build a structured tool part the kit's ToolCallNode renders as a collapsible
429
+ // card (name + label + full args, status icon). tool_result is matched back to
430
+ // this part by tool_use id so the result lands inside the same card.
431
+ function toolPart(block) {
432
+ const name = block.name || block.kind || 'tool';
433
+ const input = block.input || block.rawInput || {};
434
+ return {
435
+ kind: 'tool',
436
+ _id: block.id || block.tool_use_id || null,
437
+ name,
438
+ label: toolLabel(input),
439
+ args: input,
440
+ status: 'running',
441
+ };
411
442
  }
412
443
 
413
- function toolResultSummary(block) {
414
- const c = block?.content ?? block?.output ?? block;
415
- let s = typeof c === 'string' ? c : (() => { try { return JSON.stringify(c); } catch { return String(c); } })();
416
- s = s.replace(/\s+/g, ' ').trim();
417
- return (block?.is_error ? '[error] ' : '') + s.slice(0, 160);
444
+ // Apply a tool_result to its matching tool part (by id), else append a
445
+ // standalone tool_result part. Sets the card's result + done/error status.
446
+ function applyToolResult(parts, block) {
447
+ const id = block.tool_use_id || block.id || null;
448
+ const content = block?.content ?? block?.output ?? block;
449
+ const isError = !!(block?.is_error);
450
+ const target = id ? [...parts].reverse().find(p => p && p.kind === 'tool' && p._id === id) : null;
451
+ if (target) {
452
+ target.result = content;
453
+ target.status = isError ? 'error' : 'done';
454
+ target.error = isError || undefined;
455
+ } else {
456
+ parts.push({ kind: 'tool_result', name: 'result', result: content, error: isError || undefined, status: isError ? 'error' : 'done' });
457
+ }
418
458
  }
419
459
 
420
460
  function errText(e) {
@@ -436,8 +476,8 @@ function chatMain() {
436
476
  // than as a separate stacked Alert, so resume context reads as one block.
437
477
  banners.push(h('div', { key: 'rb', class: 'resume-banner', role: 'status' },
438
478
  h('span', { key: 'rbtxt', class: 'lede' },
439
- 'resuming session ' + state.chat.resumeSid.slice(0, 8) + ' via --resume'
440
- + (state.chat.resumeNote ? ' ' + state.chat.resumeNote : '')),
479
+ 'resuming session ' + state.chat.resumeSid.slice(0, 8) + '... via --resume'
480
+ + (state.chat.resumeNote ? ' - ' + state.chat.resumeNote : '')),
441
481
  Btn({ key: 'rclr', onClick: () => { state.chat.resumeSid = null; state.chat.resumeNote = null; render(); }, children: 'clear' })));
442
482
  }
443
483
  if (state.selectedAgent && !agentAvailable(state.selectedAgent)) {
@@ -472,6 +512,7 @@ function chatMain() {
472
512
  // agentgui supplies state and wires every server interaction as a callback.
473
513
  return [
474
514
  offlineBanner(),
515
+ runningPanel(),
475
516
  AgentChat({
476
517
  agents: sortedAgents(),
477
518
  selectedAgent: state.selectedAgent,
@@ -488,6 +529,12 @@ function chatMain() {
488
529
  placeholder,
489
530
  canSend: canSend(),
490
531
  banners,
532
+ suggestions: state.chat.messages.length ? [] : [
533
+ 'Summarize the structure of this project',
534
+ 'What are the recent changes on this branch?',
535
+ 'Find and explain the main entry point',
536
+ ],
537
+ onSuggestionClick: (t) => { if (!canSend()) return; state.chat.draft = t; sendChat(); },
491
538
  cwd: state.chatCwd,
492
539
  cwdEditing: !!state.cwdEditing,
493
540
  cwdDraft: state.cwdDraft,
@@ -509,8 +556,8 @@ function chatMain() {
509
556
  onCwdSave: () => {
510
557
  const path = (state.cwdDraft ?? '').trim();
511
558
  // A relative cwd would resolve against the server process dir, not what
512
- // the user means require an absolute path (POSIX /…, UNC \\…, or
513
- // Windows drive X:\…). Blank is valid (server default).
559
+ // the user means - require an absolute path (POSIX /..., UNC \\..., or
560
+ // Windows drive X:\...). Blank is valid (server default).
514
561
  if (path && !/^([/\\]|[A-Za-z]:[/\\])/.test(path)) {
515
562
  state.cwdError = 'enter an absolute path (e.g. /home/you/proj or C:\\proj) or leave blank';
516
563
  render();
@@ -545,7 +592,7 @@ function newChat() {
545
592
  state.confirmingNewChat = false;
546
593
  state.chat.abort?.abort();
547
594
  state.chat = { messages: [], busy: false, abort: null, draft: '', resumeSid: null };
548
- try { localStorage.removeItem(CHAT_KEY); } catch {}
595
+ lsRemove(CHAT_KEY);
549
596
  render();
550
597
  }
551
598
 
@@ -557,21 +604,35 @@ function cancelChat() {
557
604
  }
558
605
 
559
606
  const CHAT_KEY = 'agentgui.chat';
607
+ // An assistant turn with no content AND no parts is an empty shell (an aborted
608
+ // turn that produced nothing). The view already renders it as nothing; drop it
609
+ // from persisted/in-memory state so a restored chat never carries blank bubbles.
610
+ function isEmptyTurn(m) {
611
+ return m.role === 'assistant' && !m.content && !(Array.isArray(m.parts) && m.parts.length);
612
+ }
560
613
  function persistChat() {
561
614
  try {
562
- const msgs = state.chat.messages.map(m => ({ id: m.id, role: m.role, content: m.content, time: m.time, parts: m.parts }));
563
- if (!msgs.length) { localStorage.removeItem(CHAT_KEY); return; }
564
- localStorage.setItem(CHAT_KEY, JSON.stringify({ messages: msgs, resumeSid: state.chat.resumeSid, agent: state.selectedAgent, model: state.selectedModel }));
615
+ const msgs = state.chat.messages
616
+ .filter(m => !isEmptyTurn(m))
617
+ .map(m => ({ id: m.id, role: m.role, content: m.content, time: m.time, parts: m.parts }));
618
+ if (!msgs.length) { lsRemove(CHAT_KEY); return; }
619
+ lsSet(CHAT_KEY, JSON.stringify({ messages: msgs, resumeSid: state.chat.resumeSid, agent: state.selectedAgent, model: state.selectedModel }));
565
620
  } catch {}
566
621
  }
567
622
  function restoreChat() {
568
623
  try {
569
- const raw = localStorage.getItem(CHAT_KEY);
624
+ const raw = lsGet(CHAT_KEY);
570
625
  if (!raw) return;
571
626
  const saved = JSON.parse(raw);
572
627
  if (Array.isArray(saved.messages) && saved.messages.length) {
573
- state.chat.messages = saved.messages.map(m => ({ ...m, parts: Array.isArray(m.parts) ? m.parts : [] }));
628
+ state.chat.messages = saved.messages
629
+ .filter(m => !isEmptyTurn(m))
630
+ .map(m => ({ ...m, parts: Array.isArray(m.parts) ? m.parts : [] }));
574
631
  state.chat.resumeSid = saved.resumeSid || null;
632
+ // Restore the agent/model the transcript belongs to, so a restored chat
633
+ // isn't silently shown under whatever agent the picker defaulted to.
634
+ state.chat.restoredAgent = saved.agent || null;
635
+ state.chat.restoredModel = saved.model || null;
575
636
  }
576
637
  } catch {}
577
638
  }
@@ -600,10 +661,19 @@ async function sendChat() {
600
661
  signal: ctrl.signal,
601
662
  resumeSid: state.chat.resumeSid || undefined,
602
663
  })) {
603
- if (ev.type === 'text') { cur.content += ev.text; render(); scrollChatToBottom(); }
604
- else if (ev.type === 'tool') { cur.parts.push(toolSummary(ev.block)); render(); scrollChatToBottom(); }
605
- else if (ev.type === 'tool_result') { cur.parts.push('-> ' + toolResultSummary(ev.block)); render(); scrollChatToBottom(); }
606
- else if (ev.type === 'result') { /* terminal usage/summary block — already reflected via text */ }
664
+ if (ev.type === 'session') {
665
+ // Remember the server's session id so the NEXT turn resumes this
666
+ // conversation instead of cold-spawning. Only claude-code supports
667
+ // --resume by id; for other agents we leave resumeSid unset.
668
+ if (state.selectedAgent === 'claude-code' && ev.sessionId) {
669
+ state.chat.resumeSid = ev.sessionId;
670
+ persistChat();
671
+ }
672
+ }
673
+ else if (ev.type === 'text') { cur.content += ev.text; scheduleStreamRender(); }
674
+ else if (ev.type === 'tool') { cur.parts.push(toolPart(ev.block)); scheduleStreamRender(); }
675
+ else if (ev.type === 'tool_result') { applyToolResult(cur.parts, ev.block); scheduleStreamRender(); }
676
+ else if (ev.type === 'result') { /* terminal usage/summary block - already reflected via text */ }
607
677
  else if (ev.type === 'error') { cur.error = errText(ev.error); render(); }
608
678
  }
609
679
  } catch (e) {
@@ -624,7 +694,7 @@ function reconnectAlert() {
624
694
  key: 'liveerr',
625
695
  kind: 'error',
626
696
  title: 'Live stream disconnected',
627
- children: [h('span', { key: 'lemsg' }, state.live.error + ' '), Btn({ key: 'reco', onClick: openLiveStream, children: 'reconnect', title: 'Reconnect to history stream' })],
697
+ children: [h('span', { key: 'lemsg' }, state.live.error + ' - '), Btn({ key: 'reco', onClick: openLiveStream, children: 'reconnect', title: 'Reconnect to history stream' })],
628
698
  });
629
699
  }
630
700
 
@@ -635,7 +705,7 @@ function historyMain() {
635
705
  reconnectAlert(),
636
706
  PageHeader({
637
707
  title: 'history',
638
- lede: 'pick a session from the sidebar events stream live from ccsniff /v1/history.',
708
+ lede: 'pick a session from the sidebar - events stream live from ccsniff /v1/history.',
639
709
  }),
640
710
  h('div', { key: 'histempty', class: 'history-empty', role: 'status' },
641
711
  h('p', { key: 'gt', class: 'history-empty-title' },
@@ -643,7 +713,7 @@ function historyMain() {
643
713
  h('p', { key: 'gs', class: 'lede history-empty-sub' },
644
714
  count
645
715
  ? count + ' session' + (count === 1 ? '' : 's') + ' available · use the search box or press / to filter'
646
- : 'Start a chat or run a local coding agent its session will appear here live.')),
716
+ : 'Start a chat or run a local coding agent - its session will appear here live.')),
647
717
  ].filter(Boolean);
648
718
  }
649
719
 
@@ -667,7 +737,7 @@ function historyMain() {
667
737
  // doesn't spin forever.
668
738
  const body = state.eventsLoaded
669
739
  ? h('div', { key: 'noev', class: 'lede empty-state', role: 'status' },
670
- h('span', { key: 'noevtxt' }, 'no events in this session '),
740
+ h('span', { key: 'noevtxt' }, 'no events in this session - '),
671
741
  Btn({ key: 'reload', onClick: () => loadSession(state.selectedSid), children: 'reload' }))
672
742
  : h('div', { key: 'loading', class: 'lede empty-state', role: 'status', 'aria-live': 'polite' }, Spinner({ key: 'spin', size: 'sm' }), 'loading events…');
673
743
  return [reconnectAlert(), head, actions, Panel({ title: 'events', children: body })].filter(Boolean);
@@ -675,15 +745,32 @@ function historyMain() {
675
745
 
676
746
  if (!state.expandedEvents) state.expandedEvents = new Set();
677
747
  const total = state.events.length;
678
- const shown = state.events.slice(-300);
748
+ const limit = state.eventsLimit;
749
+ const shown = state.events.slice(-limit);
679
750
  const hiddenCount = total - shown.length;
751
+ // Keys of the currently-shown rows, so expand-all toggles only what's rendered.
752
+ const shownKeys = shown.map((e, i) => {
753
+ const absIdx = total - shown.length + i;
754
+ return e.i != null ? 'ev' + e.i : 'ev-' + (e.ts || 0) + '-' + (e.type || '') + '-' + absIdx;
755
+ });
756
+ const allExpanded = shownKeys.length > 0 && shownKeys.every(k => state.expandedEvents.has(k));
757
+ const eventControls = h('div', { key: 'evctrl', class: 'history-actions', role: 'group', 'aria-label': 'event controls' },
758
+ Btn({ key: 'expall', onClick: () => {
759
+ if (allExpanded) { shownKeys.forEach(k => state.expandedEvents.delete(k)); }
760
+ else { shownKeys.forEach(k => state.expandedEvents.add(k)); }
761
+ render();
762
+ }, children: allExpanded ? 'collapse all' : 'expand all' }),
763
+ hiddenCount > 0
764
+ ? Btn({ key: 'older', onClick: () => { state.eventsLimit += 300; render(); }, children: 'load ' + Math.min(300, hiddenCount) + ' older (' + hiddenCount + ' hidden)' })
765
+ : null,
766
+ );
680
767
  return [
681
768
  reconnectAlert(),
682
769
  head,
683
770
  actions,
684
771
  Panel({
685
- title: total + ' events' + (hiddenCount > 0 ? ' (showing last 300; ' + hiddenCount + ' older not rendered)' : ''),
686
- children: EventList({
772
+ title: total + ' events' + (hiddenCount > 0 ? ' (showing last ' + shown.length + '; ' + hiddenCount + ' older)' : ''),
773
+ children: [eventControls, EventList({
687
774
  items: shown.map((e, i) => {
688
775
  // Stable key: prefer the server-assigned event index, else the
689
776
  // event timestamp + position, never a bare array index (which
@@ -701,9 +788,15 @@ function historyMain() {
701
788
  const text = raw.replace(/\s+/g, ' ').trim();
702
789
  const expanded = state.expandedEvents.has(key);
703
790
  const full = e.toolInput ? (text + '\n\n' + JSON.stringify(e.toolInput, null, 2)) : raw;
791
+ // Rail tone matches the session/agents rail semantics so an event's
792
+ // kind is visible at a glance, consistent across the GUI:
793
+ // flame = error, purple = tool_use, green = normal turn.
794
+ const isTool = type === 'tool_use' || !!e.tool;
795
+ const rail = e.isError ? 'flame' : (isTool ? 'purple' : 'green');
704
796
  return {
705
797
  key,
706
798
  code: String(absIdx + 1).padStart(4, '0'),
799
+ rail,
707
800
  title: expanded ? (full || '(' + type + ')') : (text.slice(0, 220) || '(' + type + ')'),
708
801
  // Guard ts: a missing/zero timestamp renders "Invalid Date" otherwise.
709
802
  // Every row is click-to-expand, so always show the affordance word
@@ -712,7 +805,7 @@ function historyMain() {
712
805
  onClick: () => { expanded ? state.expandedEvents.delete(key) : state.expandedEvents.add(key); render(); },
713
806
  };
714
807
  }),
715
- }),
808
+ })],
716
809
  }),
717
810
  ].filter(Boolean);
718
811
  }
@@ -746,7 +839,7 @@ function resumeInChat(sess) {
746
839
  // Only claude-code supports --resume by sid; warn if we have to switch the
747
840
  // user's selected agent rather than silently discarding it.
748
841
  if (state.selectedAgent && state.selectedAgent !== 'claude-code') {
749
- state.chat.resumeNote = 'Switched to Claude Code only it supports resuming a session by id.';
842
+ state.chat.resumeNote = 'Switched to Claude Code - only it supports resuming a session by id.';
750
843
  } else {
751
844
  state.chat.resumeNote = null;
752
845
  }
@@ -784,6 +877,26 @@ function uniqueProjects() {
784
877
  return Array.from(seen.entries()).sort((a, b) => b[1] - a[1]);
785
878
  }
786
879
 
880
+ // The running-chats panel: in-flight chats with per-session stop. Shown on the
881
+ // history sidebar AND the chat tab, so a chat started then navigated-away-from
882
+ // stays visible and stoppable from anywhere (active polling is global).
883
+ function runningPanel() {
884
+ const running = Array.isArray(state.active) ? state.active : [];
885
+ if (!running.length) return null;
886
+ return Panel({
887
+ key: 'runningPanel',
888
+ title: 'running · ' + running.length,
889
+ children: running.map((r) => {
890
+ const agentName = agentById(r.agentId)?.name || r.agentId || 'agent';
891
+ const elapsed = r.startedAt ? Math.round((Date.now() - r.startedAt) / 1000) : 0;
892
+ return h('div', { key: 'run' + r.sessionId, class: 'resume-banner', role: 'group' },
893
+ h('span', { key: 'rd', class: 'status-dot-disc status-dot-live', 'aria-hidden': 'true' }),
894
+ h('span', { class: 'lede' }, agentName + (r.model ? ' · ' + r.model : '') + ' · ' + elapsed + 's' + (r.cwd ? ' · ' + r.cwd.split(/[/\\]/).filter(Boolean).slice(-1)[0] : '')),
895
+ Btn({ key: 'stop' + r.sessionId, onClick: () => stopActiveChat(r.sessionId), children: 'stop' }));
896
+ }),
897
+ });
898
+ }
899
+
787
900
  function historySide() {
788
901
  const searching = !!state.searchHits;
789
902
  const sessionsView = visibleSessions();
@@ -801,7 +914,9 @@ function historySide() {
801
914
  sub: (r.project || '?') + ' · ' + (r.role || '?') + (r.tool ? ' · ' + r.tool : ''),
802
915
  // Rail carries the same semantics as session rows: error > subagent > normal.
803
916
  rail: r.isError ? 'flame' : (r.isSubagent ? 'purple' : 'green'),
804
- onClick: () => loadSession(r.sid),
917
+ // Carry the matched event's index so loadSession scrolls to + flashes
918
+ // the match, instead of dropping the user at the top of the session.
919
+ onClick: () => loadSession(r.sid, { focusEventI: r.i, focusEventTs: r.ts }),
805
920
  })
806
921
  )
807
922
  : visible.map((s, i) =>
@@ -819,23 +934,9 @@ function historySide() {
819
934
  );
820
935
  const subagentCount = (Array.isArray(state.sessions) ? state.sessions : []).filter(s => s.isSubagent).length;
821
936
  const projects = uniqueProjects();
822
- const running = Array.isArray(state.active) ? state.active : [];
823
937
 
824
938
  return [
825
- running.length
826
- ? Panel({
827
- key: 'runningPanel',
828
- title: 'running · ' + running.length,
829
- children: running.map((r, i) => {
830
- const agentName = agentById(r.agentId)?.name || r.agentId || 'agent';
831
- const elapsed = r.startedAt ? Math.round((Date.now() - r.startedAt) / 1000) : 0;
832
- return h('div', { key: 'run' + r.sessionId, class: 'resume-banner', role: 'group' },
833
- h('span', { key: 'rd', class: 'status-dot-disc status-dot-live', 'aria-hidden': 'true' }),
834
- h('span', { class: 'lede' }, agentName + (r.model ? ' · ' + r.model : '') + ' · ' + elapsed + 's' + (r.cwd ? ' · ' + r.cwd.split(/[/\\]/).filter(Boolean).slice(-1)[0] : '')),
835
- Btn({ key: 'stop' + r.sessionId, onClick: () => stopActiveChat(r.sessionId), children: 'stop' }));
836
- }),
837
- })
838
- : null,
939
+ runningPanel(),
839
940
  Panel({
840
941
  title: searching
841
942
  ? 'matches · ' + (state.searchHits.results?.length || 0)
@@ -843,7 +944,10 @@ function historySide() {
843
944
  children: [
844
945
  SearchInput({
845
946
  key: 'searchInput',
846
- placeholder: 'search event text…',
947
+ // The DS SearchInput reads `label` (not aria-label) for the accessible
948
+ // name, falling back to placeholder; pass label so AT announces it.
949
+ placeholder: 'Search event text across sessions',
950
+ label: 'Search event text across sessions',
847
951
  'aria-label': 'Search event text across sessions',
848
952
  value: state.searchQ,
849
953
  onInput: (v) => { state.searchQ = v; debouncedSearch(); },
@@ -883,7 +987,7 @@ function historySide() {
883
987
  // Search is server-capped at 60 hits; there is no deeper page, so tell
884
988
  // the user the result set is truncated rather than silently hiding it.
885
989
  searching && truncatedBy > 0
886
- ? h('p', { key: 'searchmore', class: 'lede empty-state' }, 'showing first 60 matches (' + truncatedBy + ' more refine your search)')
990
+ ? h('p', { key: 'searchmore', class: 'lede empty-state' }, 'showing first 60 matches (' + truncatedBy + ' more - refine your search)')
887
991
  : null,
888
992
  ],
889
993
  }),
@@ -920,7 +1024,13 @@ function healthSummary() {
920
1024
  const ok = hh.status === 'ok';
921
1025
  // Each chip carries a title so its meaning isn't left to inference.
922
1026
  const bits = [];
923
- bits.push([hh.status || 'unknown', 'Backend connection status']);
1027
+ // Use the same connection vocabulary as the crumb status dot
1028
+ // (connected/offline/connecting) so the same state reads the same word
1029
+ // everywhere, instead of the raw health.status ('ok'/'down').
1030
+ const connWord = hh.status === 'ok' ? 'connected'
1031
+ : hh.status === 'unknown' ? 'connecting'
1032
+ : 'offline';
1033
+ bits.push([connWord, 'Backend connection status']);
924
1034
  if (hh.version) bits.push(['v' + hh.version, 'Server version']);
925
1035
  if (typeof hh.agents === 'number') bits.push([hh.agents + ' agents', 'Agents registered on the server']);
926
1036
  if (typeof hh.activeExecutions === 'number') bits.push([hh.activeExecutions + ' active', 'Chats currently executing']);
@@ -935,7 +1045,7 @@ function settingsMain() {
935
1045
  return [
936
1046
  PageHeader({
937
1047
  title: 'settings',
938
- lede: 'point agentgui at any backend. blank = same-origin (ccsniff in-process). ?backend=… or the field below persists via localStorage.',
1048
+ lede: 'point agentgui at any backend. blank = same-origin (ccsniff in-process). ?backend=... or the field below persists via localStorage.',
939
1049
  }),
940
1050
  h('div', { key: 'settings-grid', class: 'settings-grid' }, [
941
1051
  Panel({
@@ -957,8 +1067,8 @@ function settingsMain() {
957
1067
  !isValid ? h('p', { key: 'err', id: 'backend-url-error', class: 'lede field-error', role: 'alert' }, 'Invalid URL format') : null,
958
1068
  state.backendStatus === 'connecting' ? h('p', { key: 'bst', class: 'lede', role: 'status' }, 'connecting…') : null,
959
1069
  state.backendStatus === 'ok' ? h('p', { key: 'bst', class: 'lede', role: 'status' }, 'connected') : null,
960
- state.backendStatus === 'failed' ? h('p', { key: 'bst', class: 'lede field-error', role: 'alert' }, 'connection failed check the URL') : null,
961
- 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,
1070
+ state.backendStatus === 'failed' ? h('p', { key: 'bst', class: 'lede field-error', role: 'alert' }, 'connection failed - check the URL') : null,
1071
+ 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,
962
1072
  healthSummary(),
963
1073
  Btn({
964
1074
  key: 'savebtn',
@@ -994,7 +1104,9 @@ function preferencesPanel() {
994
1104
  children: [
995
1105
  h('div', { key: 'ver', class: 'lede' }, 'server ' + (hh.version ? 'v' + hh.version : 'version unknown') + (window.__SERVER_VERSION ? ' · build ' + window.__SERVER_VERSION : '')),
996
1106
  h('div', { key: 'sc', class: 'lede', style: 'margin:.5em 0' },
997
- 'keyboard: g then c/h/s switch tabs · n new chat · / focus search/composer · ? toggle hint · Esc blur field'),
1107
+ 'keyboard: g then c/h/s - switch tabs · n - new chat · / - focus search/composer · ? - toggle hint · Esc - blur field'),
1108
+ h('div', { key: 'cwdnote', class: 'lede', style: 'margin:.5em 0' },
1109
+ 'working directory: set per-chat in the chat composer’s cwd bar' + (state.chatCwd ? ' (current: ' + state.chatCwd + ')' : ' (currently: server default)')),
998
1110
  state.confirmingClearData
999
1111
  ? Alert({ key: 'cld', kind: 'warn', title: 'Clear all local data?',
1000
1112
  children: [
@@ -1013,9 +1125,16 @@ function acpStatusFor(agentId) {
1013
1125
 
1014
1126
  function agentsPanel() {
1015
1127
  const installed = state.agents.filter(a => a.available !== false);
1128
+ const hasAcp = (Array.isArray(state.health.acp) ? state.health.acp : []).length > 0;
1016
1129
  return Panel({
1017
1130
  title: 'agents · ' + installed.length + '/' + state.agents.length + ' installed',
1018
- children: state.agents.length
1131
+ children: [
1132
+ // ACP agents (opencode/kilo/codex) are managed automatically: started
1133
+ // on-demand and restarted with backoff by the server, so there are no
1134
+ // manual start/stop controls by design. This note makes that explicit so
1135
+ // a 'stopped' row doesn't read as a missing action.
1136
+ 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,
1137
+ ...(state.agents.length
1019
1138
  ? state.agents.map((a, i) => {
1020
1139
  const acp = acpStatusFor(a.id);
1021
1140
  const avail = a.available !== false;
@@ -1038,7 +1157,8 @@ function agentsPanel() {
1038
1157
  onClick: usable ? () => { navTo('chat'); selectAgent(a.id); } : undefined,
1039
1158
  });
1040
1159
  })
1041
- : h('p', { key: 'none', class: 'lede' }, 'no agents loaded'),
1160
+ : [h('p', { key: 'none', class: 'lede' }, 'no agents loaded')]),
1161
+ ].filter(Boolean),
1042
1162
  });
1043
1163
  }
1044
1164
 
@@ -1073,14 +1193,19 @@ async function runSearch() {
1073
1193
  }
1074
1194
  const debouncedSearch = debounce(runSearch, 300);
1075
1195
 
1076
- async function loadSession(sid) {
1196
+ async function loadSession(sid, { focusEventI = null, focusEventTs = null } = {}) {
1077
1197
  // Guard against a bad sid from a malformed hash (e.g. "?sid=undefined").
1078
1198
  if (!sid || sid === 'undefined' || sid === 'null') { state.selectedSid = null; render(); return; }
1079
1199
  state.selectedSid = sid;
1080
1200
  state.events = [];
1081
1201
  state.eventsLoaded = false;
1202
+ state.eventsLimit = 300; // reset the render window per session
1082
1203
  state.expandedEvents = new Set(); // don't carry expansion to the new session
1083
1204
  writeHash(sid, { push: true });
1205
+ // Close the mobile sidebar drawer on selection. The DS only auto-closes when
1206
+ // the clicked element is an <a>; agentgui's session rows are onClick divs, so
1207
+ // we close it explicitly here.
1208
+ document.querySelector('.app-body.side-open')?.classList.remove('side-open');
1084
1209
  render();
1085
1210
  // Bring the now-active sidebar row into view (deep-link / back-forward may
1086
1211
  // select a row that's scrolled out of the session list).
@@ -1090,13 +1215,33 @@ async function loadSession(sid) {
1090
1215
  try {
1091
1216
  state.events = await B.getSessionEvents(state.backend, sid);
1092
1217
  state.eventsLoaded = true;
1218
+ // If we arrived from a search hit, make sure the matched event is within the
1219
+ // render window, then scroll to + flash it so the match isn't lost.
1220
+ if (focusEventI != null || focusEventTs != null) {
1221
+ const idx = state.events.findIndex(e => (focusEventI != null && e.i === focusEventI) || (focusEventTs != null && e.ts === focusEventTs));
1222
+ if (idx >= 0) {
1223
+ const fromEnd = state.events.length - idx;
1224
+ if (fromEnd > state.eventsLimit) state.eventsLimit = Math.ceil(fromEnd / 300) * 300;
1225
+ render();
1226
+ // The rendered EventList shows the last eventsLimit events in order, so
1227
+ // the matched event's row is at (idx - sliceStart) among .ds-event-list rows.
1228
+ const sliceStart = Math.max(0, state.events.length - state.eventsLimit);
1229
+ const rowPos = idx - sliceStart;
1230
+ requestAnimationFrame(() => {
1231
+ const rows = document.querySelectorAll('.ds-event-list .row');
1232
+ const row = rows[rowPos];
1233
+ if (row) { row.scrollIntoView({ block: 'center' }); row.classList.add('event-flash'); setTimeout(() => row.classList.remove('event-flash'), 2000); }
1234
+ });
1235
+ return;
1236
+ }
1237
+ }
1093
1238
  render();
1094
1239
  } catch (e) {
1095
1240
  state.events = [{
1096
1241
  ts: Date.now(),
1097
1242
  role: 'error',
1098
1243
  type: 'fetch',
1099
- text: 'Failed to load session: ' + e.message + ' retry via sidebar',
1244
+ text: 'Failed to load session: ' + e.message + ' - retry via sidebar',
1100
1245
  }];
1101
1246
  state.eventsLoaded = true;
1102
1247
  render();
@@ -1113,8 +1258,11 @@ async function init() {
1113
1258
  render();
1114
1259
  try {
1115
1260
  state.agents = await B.listAgents(state.backend);
1116
- // Restore the saved agent if still present; else first available, else first.
1117
- let target = state.agents.find(a => a.id === state.selectedAgent);
1261
+ // Agent selection priority: the agent a restored transcript belongs to (so
1262
+ // the chat isn't shown under the wrong agent), else the saved picker agent,
1263
+ // else first available, else first.
1264
+ let target = (state.chat.restoredAgent && state.agents.find(a => a.id === state.chat.restoredAgent))
1265
+ || state.agents.find(a => a.id === state.selectedAgent);
1118
1266
  if (!target) target = state.agents.find(a => a.available !== false) || state.agents[0];
1119
1267
  if (target) await selectAgent(target.id);
1120
1268
  render();