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.
- package/.claude/workflows/gui-audit.js +86 -0
- package/AGENTS.md +15 -1
- package/lib/http-handler.js +29 -2
- package/lib/ws-handlers-util.js +9 -0
- package/package.json +2 -2
- package/site/app/index.html +20 -16
- package/site/app/js/app.js +196 -52
- package/site/app/js/backend.js +5 -1
- package/site/app/vendor/anentrypoint-design/247420.css +923 -46
- package/site/app/vendor/anentrypoint-design/247420.js +89 -42
package/site/app/js/app.js
CHANGED
|
@@ -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) + '
|
|
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
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
|
|
409
|
-
if (
|
|
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 '
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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
|
|
487
|
-
: (state.modelsLoading ? 'loading models
|
|
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
|
|
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
|
|
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 === '
|
|
605
|
-
|
|
606
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
//
|
|
1121
|
-
|
|
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();
|
package/site/app/js/backend.js
CHANGED
|
@@ -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 === '
|
|
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 });
|