agentgui 1.0.948 → 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.
@@ -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;
@@ -317,7 +332,7 @@ function view() {
317
332
  const dotLabel = state.tab === 'history'
318
333
  ? (state.live.error
319
334
  ? state.live.error + (state.live.reconnects ? ' · ' + state.live.reconnects + ' reconnects' : '')
320
- : (liveActive ? 'live · ' + state.live.eventCount : (state.live.connected ? 'live' : 'connecting...')))
335
+ : (liveActive ? 'live · ' + state.live.eventCount : (state.live.connected ? 'live' : 'connecting')))
321
336
  : (ok ? (state.health.ws === 'reconnecting' ? 'ws reconnecting' : 'connected') : 'offline');
322
337
  const dotLive = state.tab === 'history' ? (liveActive || state.live.connected) : ok;
323
338
  // The status dot is drawn entirely by CSS (.status-dot::before) - a small
@@ -400,22 +415,46 @@ function canSend() {
400
415
  return !!state.selectedAgent && agentAvailable(state.selectedAgent) && !state.chat.busy;
401
416
  }
402
417
 
403
- function toolSummary(block) {
404
- const name = block.name || block.kind || 'tool';
405
- let arg = '';
406
- 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) {
407
421
  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 {} }
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);
410
424
  }
411
- 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
+ };
412
442
  }
413
443
 
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);
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
+ }
419
458
  }
420
459
 
421
460
  function errText(e) {
@@ -466,13 +505,14 @@ function chatMain() {
466
505
 
467
506
  const placeholder = !state.selectedAgent
468
507
  ? 'choose an agent first'
469
- : (!agentAvailable(state.selectedAgent) ? agentName + ' is not installed' : 'message...');
508
+ : (!agentAvailable(state.selectedAgent) ? agentName + ' is not installed' : 'message');
470
509
 
471
510
  // The reusable AgentChat kit owns the agent/model picker, cwd bar, transcript
472
511
  // (with AICat-style auto-scroll + thinking), and the caret-stable composer.
473
512
  // agentgui supplies state and wires every server interaction as a callback.
474
513
  return [
475
514
  offlineBanner(),
515
+ runningPanel(),
476
516
  AgentChat({
477
517
  agents: sortedAgents(),
478
518
  selectedAgent: state.selectedAgent,
@@ -483,12 +523,18 @@ function chatMain() {
483
523
  busy: state.chat.busy,
484
524
  draft: state.chat.draft,
485
525
  status: state.chat.busy
486
- ? (state.health.ws === 'reconnecting' ? 'reconnecting...' : 'streaming...')
487
- : (state.modelsLoading ? 'loading models...' : (state.chat.resumeSid ? 'resume' : 'ready')),
526
+ ? (state.health.ws === 'reconnecting' ? 'reconnecting' : 'streaming')
527
+ : (state.modelsLoading ? 'loading models' : (state.chat.resumeSid ? 'resume' : 'ready')),
488
528
  agentName,
489
529
  placeholder,
490
530
  canSend: canSend(),
491
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(); },
492
538
  cwd: state.chatCwd,
493
539
  cwdEditing: !!state.cwdEditing,
494
540
  cwdDraft: state.cwdDraft,
@@ -558,9 +604,17 @@ function cancelChat() {
558
604
  }
559
605
 
560
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
+ }
561
613
  function persistChat() {
562
614
  try {
563
- const msgs = state.chat.messages.map(m => ({ id: m.id, role: m.role, content: m.content, time: m.time, parts: m.parts }));
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 }));
564
618
  if (!msgs.length) { lsRemove(CHAT_KEY); return; }
565
619
  lsSet(CHAT_KEY, JSON.stringify({ messages: msgs, resumeSid: state.chat.resumeSid, agent: state.selectedAgent, model: state.selectedModel }));
566
620
  } catch {}
@@ -571,8 +625,14 @@ function restoreChat() {
571
625
  if (!raw) return;
572
626
  const saved = JSON.parse(raw);
573
627
  if (Array.isArray(saved.messages) && saved.messages.length) {
574
- 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 : [] }));
575
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;
576
636
  }
577
637
  } catch {}
578
638
  }
@@ -601,9 +661,18 @@ async function sendChat() {
601
661
  signal: ctrl.signal,
602
662
  resumeSid: state.chat.resumeSid || undefined,
603
663
  })) {
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(); }
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(); }
607
676
  else if (ev.type === 'result') { /* terminal usage/summary block - already reflected via text */ }
608
677
  else if (ev.type === 'error') { cur.error = errText(ev.error); render(); }
609
678
  }
@@ -670,21 +739,38 @@ function historyMain() {
670
739
  ? h('div', { key: 'noev', class: 'lede empty-state', role: 'status' },
671
740
  h('span', { key: 'noevtxt' }, 'no events in this session - '),
672
741
  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...');
742
+ : h('div', { key: 'loading', class: 'lede empty-state', role: 'status', 'aria-live': 'polite' }, Spinner({ key: 'spin', size: 'sm' }), 'loading events');
674
743
  return [reconnectAlert(), head, actions, Panel({ title: 'events', children: body })].filter(Boolean);
675
744
  }
676
745
 
677
746
  if (!state.expandedEvents) state.expandedEvents = new Set();
678
747
  const total = state.events.length;
679
- const shown = state.events.slice(-300);
748
+ const limit = state.eventsLimit;
749
+ const shown = state.events.slice(-limit);
680
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
+ );
681
767
  return [
682
768
  reconnectAlert(),
683
769
  head,
684
770
  actions,
685
771
  Panel({
686
- title: total + ' events' + (hiddenCount > 0 ? ' (showing last 300; ' + hiddenCount + ' older not rendered)' : ''),
687
- children: EventList({
772
+ title: total + ' events' + (hiddenCount > 0 ? ' (showing last ' + shown.length + '; ' + hiddenCount + ' older)' : ''),
773
+ children: [eventControls, EventList({
688
774
  items: shown.map((e, i) => {
689
775
  // Stable key: prefer the server-assigned event index, else the
690
776
  // event timestamp + position, never a bare array index (which
@@ -702,9 +788,15 @@ function historyMain() {
702
788
  const text = raw.replace(/\s+/g, ' ').trim();
703
789
  const expanded = state.expandedEvents.has(key);
704
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');
705
796
  return {
706
797
  key,
707
798
  code: String(absIdx + 1).padStart(4, '0'),
799
+ rail,
708
800
  title: expanded ? (full || '(' + type + ')') : (text.slice(0, 220) || '(' + type + ')'),
709
801
  // Guard ts: a missing/zero timestamp renders "Invalid Date" otherwise.
710
802
  // Every row is click-to-expand, so always show the affordance word
@@ -713,7 +805,7 @@ function historyMain() {
713
805
  onClick: () => { expanded ? state.expandedEvents.delete(key) : state.expandedEvents.add(key); render(); },
714
806
  };
715
807
  }),
716
- }),
808
+ })],
717
809
  }),
718
810
  ].filter(Boolean);
719
811
  }
@@ -785,6 +877,26 @@ function uniqueProjects() {
785
877
  return Array.from(seen.entries()).sort((a, b) => b[1] - a[1]);
786
878
  }
787
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
+
788
900
  function historySide() {
789
901
  const searching = !!state.searchHits;
790
902
  const sessionsView = visibleSessions();
@@ -802,7 +914,9 @@ function historySide() {
802
914
  sub: (r.project || '?') + ' · ' + (r.role || '?') + (r.tool ? ' · ' + r.tool : ''),
803
915
  // Rail carries the same semantics as session rows: error > subagent > normal.
804
916
  rail: r.isError ? 'flame' : (r.isSubagent ? 'purple' : 'green'),
805
- 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 }),
806
920
  })
807
921
  )
808
922
  : visible.map((s, i) =>
@@ -820,23 +934,9 @@ function historySide() {
820
934
  );
821
935
  const subagentCount = (Array.isArray(state.sessions) ? state.sessions : []).filter(s => s.isSubagent).length;
822
936
  const projects = uniqueProjects();
823
- const running = Array.isArray(state.active) ? state.active : [];
824
937
 
825
938
  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,
939
+ runningPanel(),
840
940
  Panel({
841
941
  title: searching
842
942
  ? 'matches · ' + (state.searchHits.results?.length || 0)
@@ -853,7 +953,7 @@ function historySide() {
853
953
  onInput: (v) => { state.searchQ = v; debouncedSearch(); },
854
954
  }),
855
955
  state.searchBusy
856
- ? h('div', { key: 'searchbusy', class: 'lede empty-state', role: 'status' }, Spinner({ key: 'ss', size: 'sm' }), 'searching...')
956
+ ? h('div', { key: 'searchbusy', class: 'lede empty-state', role: 'status' }, Spinner({ key: 'ss', size: 'sm' }), 'searching')
857
957
  : null,
858
958
  searching && state.searchHits.error
859
959
  ? Alert({ key: 'searcherr', kind: 'error', title: 'Search failed', children: state.searchHits.error })
@@ -924,7 +1024,13 @@ function healthSummary() {
924
1024
  const ok = hh.status === 'ok';
925
1025
  // Each chip carries a title so its meaning isn't left to inference.
926
1026
  const bits = [];
927
- 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']);
928
1034
  if (hh.version) bits.push(['v' + hh.version, 'Server version']);
929
1035
  if (typeof hh.agents === 'number') bits.push([hh.agents + ' agents', 'Agents registered on the server']);
930
1036
  if (typeof hh.activeExecutions === 'number') bits.push([hh.activeExecutions + ' active', 'Chats currently executing']);
@@ -959,7 +1065,7 @@ function settingsMain() {
959
1065
  onInput: (v) => { state.backendDraft = v; render(); },
960
1066
  }),
961
1067
  !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,
1068
+ state.backendStatus === 'connecting' ? h('p', { key: 'bst', class: 'lede', role: 'status' }, 'connecting') : null,
963
1069
  state.backendStatus === 'ok' ? h('p', { key: 'bst', class: 'lede', role: 'status' }, 'connected') : null,
964
1070
  state.backendStatus === 'failed' ? h('p', { key: 'bst', class: 'lede field-error', role: 'alert' }, 'connection failed - check the URL') : null,
965
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,
@@ -970,7 +1076,7 @@ function settingsMain() {
970
1076
  primary: true,
971
1077
  disabled: !isValid || state.backendDraft === state.backend || state.backendStatus === 'connecting',
972
1078
  onClick: (e) => { e.preventDefault(); saveBackend(); },
973
- children: state.backendStatus === 'connecting' ? 'connecting...' : 'save + reconnect',
1079
+ children: state.backendStatus === 'connecting' ? 'connecting' : 'save + reconnect',
974
1080
  title: isValid ? 'Save backend URL and reconnect' : 'Fix URL format first',
975
1081
  }),
976
1082
  ]),
@@ -999,6 +1105,8 @@ function preferencesPanel() {
999
1105
  h('div', { key: 'ver', class: 'lede' }, 'server ' + (hh.version ? 'v' + hh.version : 'version unknown') + (window.__SERVER_VERSION ? ' · build ' + window.__SERVER_VERSION : '')),
1000
1106
  h('div', { key: 'sc', class: 'lede', style: 'margin:.5em 0' },
1001
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)')),
1002
1110
  state.confirmingClearData
1003
1111
  ? Alert({ key: 'cld', kind: 'warn', title: 'Clear all local data?',
1004
1112
  children: [
@@ -1017,9 +1125,16 @@ function acpStatusFor(agentId) {
1017
1125
 
1018
1126
  function agentsPanel() {
1019
1127
  const installed = state.agents.filter(a => a.available !== false);
1128
+ const hasAcp = (Array.isArray(state.health.acp) ? state.health.acp : []).length > 0;
1020
1129
  return Panel({
1021
1130
  title: 'agents · ' + installed.length + '/' + state.agents.length + ' installed',
1022
- 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
1023
1138
  ? state.agents.map((a, i) => {
1024
1139
  const acp = acpStatusFor(a.id);
1025
1140
  const avail = a.available !== false;
@@ -1042,7 +1157,8 @@ function agentsPanel() {
1042
1157
  onClick: usable ? () => { navTo('chat'); selectAgent(a.id); } : undefined,
1043
1158
  });
1044
1159
  })
1045
- : h('p', { key: 'none', class: 'lede' }, 'no agents loaded'),
1160
+ : [h('p', { key: 'none', class: 'lede' }, 'no agents loaded')]),
1161
+ ].filter(Boolean),
1046
1162
  });
1047
1163
  }
1048
1164
 
@@ -1077,14 +1193,19 @@ async function runSearch() {
1077
1193
  }
1078
1194
  const debouncedSearch = debounce(runSearch, 300);
1079
1195
 
1080
- async function loadSession(sid) {
1196
+ async function loadSession(sid, { focusEventI = null, focusEventTs = null } = {}) {
1081
1197
  // Guard against a bad sid from a malformed hash (e.g. "?sid=undefined").
1082
1198
  if (!sid || sid === 'undefined' || sid === 'null') { state.selectedSid = null; render(); return; }
1083
1199
  state.selectedSid = sid;
1084
1200
  state.events = [];
1085
1201
  state.eventsLoaded = false;
1202
+ state.eventsLimit = 300; // reset the render window per session
1086
1203
  state.expandedEvents = new Set(); // don't carry expansion to the new session
1087
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');
1088
1209
  render();
1089
1210
  // Bring the now-active sidebar row into view (deep-link / back-forward may
1090
1211
  // select a row that's scrolled out of the session list).
@@ -1094,6 +1215,26 @@ async function loadSession(sid) {
1094
1215
  try {
1095
1216
  state.events = await B.getSessionEvents(state.backend, sid);
1096
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
+ }
1097
1238
  render();
1098
1239
  } catch (e) {
1099
1240
  state.events = [{
@@ -1117,8 +1258,11 @@ async function init() {
1117
1258
  render();
1118
1259
  try {
1119
1260
  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);
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);
1122
1266
  if (!target) target = state.agents.find(a => a.available !== false) || state.agents[0];
1123
1267
  if (target) await selectAgent(target.id);
1124
1268
  render();
@@ -268,7 +268,11 @@ export async function* streamChat(base, { model, messages, signal, agentId, resu
268
268
  const finish = () => { done = true; if (resolveWait) { resolveWait(); resolveWait = null; } };
269
269
 
270
270
  const unsub = addSessionListener(sessionId, (ev) => {
271
- if (ev.type === 'streaming_progress') {
271
+ if (ev.type === 'streaming_session') {
272
+ // claude's real session id - surface it so the host can --resume this
273
+ // conversation on the next turn (multi-turn continuity).
274
+ if (ev.claudeSessionId) push({ type: 'session', sessionId: ev.claudeSessionId });
275
+ } else if (ev.type === 'streaming_progress') {
272
276
  const block = ev.block;
273
277
  if (block?.type === 'text' && block.text) push({ type: 'text', text: block.text });
274
278
  else if (block?.type === 'tool_use') push({ type: 'tool', block });