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.
- package/.claude/workflows/gui-audit.js +86 -0
- package/AGENTS.md +29 -11
- package/docs/demo.html +27 -27
- package/lib/codec.js +1 -1
- package/lib/http-handler.js +31 -4
- package/lib/process-message.js +1 -1
- package/lib/stream-event-handler.js +3 -3
- package/lib/terminal.js +1 -1
- package/lib/ws-handlers-util.js +9 -0
- package/lib/ws-setup.js +1 -1
- package/package.json +2 -2
- package/scripts/build-rippleui.mjs +5 -5
- package/scripts/capture-screenshots.mjs +3 -3
- package/scripts/copy-vendor.js +1 -1
- package/scripts/harvest-fixtures.mjs +7 -7
- package/scripts/seed-large-conversation.js +2 -2
- package/scripts/smoke-ws-chat.mjs +2 -2
- package/server.js +1 -1
- package/site/app/index.html +25 -19
- package/site/app/js/app.js +224 -76
- package/site/app/js/backend.js +13 -9
- package/site/app/vendor/anentrypoint-design/247420.css +923 -46
- package/site/app/vendor/anentrypoint-design/247420.js +89 -42
- package/site/theme.mjs +1 -1
- package/test.js +27 -11
package/site/app/js/app.js
CHANGED
|
@@ -11,10 +11,10 @@ const state = {
|
|
|
11
11
|
health: { status: 'unknown' },
|
|
12
12
|
tab: 'chat',
|
|
13
13
|
agents: [],
|
|
14
|
-
selectedAgent:
|
|
14
|
+
selectedAgent: lsGet('agentgui.agent') || '',
|
|
15
15
|
agentModels: [],
|
|
16
|
-
selectedModel:
|
|
17
|
-
chatCwd:
|
|
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
|
|
151
|
+
// current model selection - no-op early.
|
|
136
152
|
if (id === state.selectedAgent && state.agentModels.length) return;
|
|
137
153
|
state.selectedAgent = id;
|
|
138
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
408
|
-
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);
|
|
409
424
|
}
|
|
410
|
-
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
|
+
};
|
|
411
442
|
}
|
|
412
443
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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) + '
|
|
440
|
-
+ (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
|
|
513
|
-
// Windows drive X
|
|
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
|
-
|
|
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
|
|
563
|
-
|
|
564
|
-
|
|
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 =
|
|
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
|
|
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 === '
|
|
604
|
-
|
|
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(); }
|
|
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 + '
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
961
|
-
state.confirmingBackend ? h('p', { key: 'bcw', class: 'lede field-error', role: 'alert' }, 'changing backend discards this browser\'s chat transcript
|
|
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
|
|
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:
|
|
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 + '
|
|
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
|
-
//
|
|
1117
|
-
|
|
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();
|