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