agentgui 1.0.940 → 1.0.941
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/AGENTS.md +3 -8
- package/package.json +1 -1
- package/server.js +10 -1
- package/site/app/index.html +14 -37
- package/site/app/js/app.js +243 -65
- package/site/app/js/backend.js +20 -10
- package/site/app/vendor/anentrypoint-design/247420.css +274 -86
- package/site/app/vendor/anentrypoint-design/247420.js +13 -13
package/site/app/js/app.js
CHANGED
|
@@ -31,12 +31,27 @@ const state = {
|
|
|
31
31
|
};
|
|
32
32
|
|
|
33
33
|
function readHash() {
|
|
34
|
-
const
|
|
35
|
-
|
|
34
|
+
const hash = location.hash || '';
|
|
35
|
+
const sidM = hash.match(/sid=([^&]+)/);
|
|
36
|
+
const tabM = hash.match(/tab=([^&]+)/);
|
|
37
|
+
return {
|
|
38
|
+
sid: sidM ? decodeURIComponent(sidM[1]) : null,
|
|
39
|
+
tab: tabM ? decodeURIComponent(tabM[1]) : null,
|
|
40
|
+
};
|
|
36
41
|
}
|
|
37
|
-
function
|
|
38
|
-
const
|
|
39
|
-
if (
|
|
42
|
+
function buildHash(tab, sid) {
|
|
43
|
+
const parts = [];
|
|
44
|
+
if (tab && tab !== 'chat') parts.push('tab=' + encodeURIComponent(tab));
|
|
45
|
+
if (sid) parts.push('sid=' + encodeURIComponent(sid));
|
|
46
|
+
return parts.length ? '#' + parts.join('&') : '';
|
|
47
|
+
}
|
|
48
|
+
function writeHash(sid, { push = false } = {}) {
|
|
49
|
+
const h = buildHash(state.tab, sid);
|
|
50
|
+
const url = location.pathname + location.search + h;
|
|
51
|
+
if (location.hash === h) return;
|
|
52
|
+
// pushState for session selection so Back returns to the session list;
|
|
53
|
+
// replaceState for tab-only changes.
|
|
54
|
+
(push ? history.pushState : history.replaceState).call(history, null, '', url);
|
|
40
55
|
}
|
|
41
56
|
function fmtRelTime(ts) {
|
|
42
57
|
if (!ts) return '';
|
|
@@ -55,7 +70,8 @@ function scheduleRender() {
|
|
|
55
70
|
requestAnimationFrame(() => { renderScheduled = false; render(); });
|
|
56
71
|
}
|
|
57
72
|
|
|
58
|
-
|
|
73
|
+
const NARROW_BP = 640; // unified with the CSS touch-target breakpoint in index.html
|
|
74
|
+
function isNarrow() { return typeof window !== 'undefined' && window.innerWidth < NARROW_BP; }
|
|
59
75
|
function truncate(str, mobileLen, desktopLen) {
|
|
60
76
|
const s = String(str ?? '');
|
|
61
77
|
const max = isNarrow() ? mobileLen : desktopLen;
|
|
@@ -66,6 +82,9 @@ function debounce(fn, ms) {
|
|
|
66
82
|
return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
|
|
67
83
|
}
|
|
68
84
|
|
|
85
|
+
function lsSet(k, v) { try { localStorage.setItem(k, v); } catch {} }
|
|
86
|
+
function lsRemove(k) { try { localStorage.removeItem(k); } catch {} }
|
|
87
|
+
|
|
69
88
|
function pillButton(key, label, active, title, onClick) {
|
|
70
89
|
return h('button', {
|
|
71
90
|
key,
|
|
@@ -79,8 +98,11 @@ function pillButton(key, label, active, title, onClick) {
|
|
|
79
98
|
|
|
80
99
|
function scrollChatToBottom() {
|
|
81
100
|
requestAnimationFrame(() => {
|
|
82
|
-
|
|
83
|
-
|
|
101
|
+
// The design system's chat scroll container is .chat-thread; fall back to
|
|
102
|
+
// the main region only if it's not mounted yet.
|
|
103
|
+
const scroller = document.querySelector('.chat-thread')
|
|
104
|
+
|| document.querySelector('#agentgui-main')
|
|
105
|
+
|| document.getElementById('app');
|
|
84
106
|
if (scroller) scroller.scrollTop = scroller.scrollHeight;
|
|
85
107
|
});
|
|
86
108
|
}
|
|
@@ -116,29 +138,45 @@ function agentAvailable(id) { const a = agentById(id); return !a || a.available
|
|
|
116
138
|
function navTo(tab) {
|
|
117
139
|
const prev = state.tab;
|
|
118
140
|
state.tab = tab;
|
|
141
|
+
// Live history SSE is only needed on the history tab; active-chat polling
|
|
142
|
+
// runs globally (started at boot) so running chats show on every tab.
|
|
119
143
|
if (tab === 'history') {
|
|
120
144
|
refreshHistory();
|
|
121
145
|
openLiveStream();
|
|
122
|
-
startActivePolling();
|
|
123
146
|
} else if (prev === 'history') {
|
|
124
147
|
closeLiveStream();
|
|
125
|
-
stopActivePolling();
|
|
126
148
|
}
|
|
149
|
+
writeHash(state.selectedSid && tab === 'history' ? state.selectedSid : null);
|
|
127
150
|
render();
|
|
151
|
+
// Move focus into the new region for keyboard/AT users.
|
|
152
|
+
requestAnimationFrame(() => {
|
|
153
|
+
const region = document.querySelector('#agentgui-main');
|
|
154
|
+
if (!region) return;
|
|
155
|
+
const heading = region.querySelector('h1, h2');
|
|
156
|
+
const target = heading || region;
|
|
157
|
+
if (!target.hasAttribute('tabindex')) target.setAttribute('tabindex', '-1');
|
|
158
|
+
try { target.focus({ preventScroll: true }); } catch {}
|
|
159
|
+
});
|
|
128
160
|
}
|
|
129
161
|
|
|
130
162
|
async function refreshActive() {
|
|
131
|
-
state.active = await B.listActiveChats(state.backend);
|
|
163
|
+
try { state.active = await B.listActiveChats(state.backend); } catch { return; }
|
|
132
164
|
render();
|
|
133
165
|
}
|
|
134
166
|
function startActivePolling() {
|
|
135
167
|
if (state.activeTimer) return;
|
|
136
168
|
refreshActive();
|
|
137
|
-
|
|
169
|
+
// Small jitter so many tabs don't hit the server in lockstep.
|
|
170
|
+
state.activeTimer = setInterval(refreshActive, 3000 + Math.floor(Math.random() * 600));
|
|
138
171
|
}
|
|
139
172
|
function stopActivePolling() {
|
|
140
173
|
if (state.activeTimer) { clearInterval(state.activeTimer); state.activeTimer = null; }
|
|
141
174
|
}
|
|
175
|
+
|
|
176
|
+
// Re-render once a minute so relative timestamps ("5s ago") don't sit frozen
|
|
177
|
+
// between events. Cheap: scheduleRender coalesces via rAF.
|
|
178
|
+
let _relTick = null;
|
|
179
|
+
function startRelTimeTick() { if (!_relTick) _relTick = setInterval(() => scheduleRender(), 30000); }
|
|
142
180
|
async function stopActiveChat(sid) {
|
|
143
181
|
try { await B.cancelChat(state.backend, sid); } catch {}
|
|
144
182
|
refreshActive();
|
|
@@ -158,6 +196,8 @@ function openLiveStream() {
|
|
|
158
196
|
} else if (kind === 'event' && data) {
|
|
159
197
|
if (state.selectedSid && data.sid === state.selectedSid) {
|
|
160
198
|
state.events.push(data);
|
|
199
|
+
// Cap retained events so a long live session can't grow unbounded.
|
|
200
|
+
if (state.events.length > 2000) state.events.splice(0, state.events.length - 2000);
|
|
161
201
|
}
|
|
162
202
|
const arr = Array.isArray(state.sessions) ? state.sessions : [];
|
|
163
203
|
const sess = arr.find(s => s.sid === data.sid);
|
|
@@ -167,11 +207,13 @@ function openLiveStream() {
|
|
|
167
207
|
if (data.type === 'tool_use') sess.tools = (sess.tools || 0) + 1;
|
|
168
208
|
if (data.isError) sess.errors = (sess.errors || 0) + 1;
|
|
169
209
|
} else {
|
|
170
|
-
|
|
210
|
+
// Unknown session: a burst of events for a new session would trigger
|
|
211
|
+
// a full session-list refetch per event — debounce it into one.
|
|
212
|
+
debouncedRefreshHistory();
|
|
171
213
|
return;
|
|
172
214
|
}
|
|
173
215
|
} else if (kind === 'conversation') {
|
|
174
|
-
|
|
216
|
+
debouncedRefreshHistory();
|
|
175
217
|
return;
|
|
176
218
|
} else if (kind === 'error' && data) {
|
|
177
219
|
state.live.error = data.error || 'stream error';
|
|
@@ -208,12 +250,19 @@ function view() {
|
|
|
208
250
|
: (liveActive ? '● live · ' + state.live.eventCount : (state.live.connected ? '● live' : '◌ connecting…')))
|
|
209
251
|
: (ok ? (state.health.ws === 'reconnecting' ? '◌ ws reconnecting' : '● connected') : '○ offline');
|
|
210
252
|
const dotLive = state.tab === 'history' ? (liveActive || state.live.connected) : ok;
|
|
211
|
-
|
|
253
|
+
// Split the leading status glyph (● ◌ ○) from the words: glyph is decorative
|
|
254
|
+
// (aria-hidden), only the text is announced, so AT reads "live" not "black circle live".
|
|
255
|
+
const glyphMatch = dotText.match(/^([●◌○])\s*(.*)$/);
|
|
256
|
+
const dotGlyph = glyphMatch ? glyphMatch[1] : '';
|
|
257
|
+
const dotLabel = glyphMatch ? glyphMatch[2] : dotText;
|
|
258
|
+
const dot = h('span', { key: 'dot', class: 'status-dot' + (dotLive ? ' status-dot-live' : ''), role: 'status', 'aria-live': 'polite' },
|
|
259
|
+
dotGlyph ? h('span', { key: 'dg', 'aria-hidden': 'true' }, dotGlyph + ' ') : null,
|
|
260
|
+
h('span', { key: 'dl' }, dotLabel));
|
|
212
261
|
|
|
213
262
|
const topbar = Topbar({
|
|
214
263
|
brand: 'agentgui',
|
|
215
264
|
leaf: state.tab,
|
|
216
|
-
items: [['chat', '#'], ['history', '
|
|
265
|
+
items: [['chat', buildHash('chat', null) || '#'], ['history', buildHash('history', null)], ['settings', buildHash('settings', null)]],
|
|
217
266
|
active: state.tab,
|
|
218
267
|
onNav: (label) => navTo(label),
|
|
219
268
|
});
|
|
@@ -272,9 +321,11 @@ function view() {
|
|
|
272
321
|
right: [agentLabel],
|
|
273
322
|
});
|
|
274
323
|
|
|
324
|
+
// The design system now owns the full-height column + inner scroll for
|
|
325
|
+
// .app-main, so chat just needs to be a flex column that fills it.
|
|
275
326
|
const mainStyle = state.tab === 'chat'
|
|
276
|
-
? 'min-height:0;
|
|
277
|
-
: 'min-height:0
|
|
327
|
+
? 'min-height:0;display:flex;flex-direction:column;flex:1'
|
|
328
|
+
: 'min-height:0';
|
|
278
329
|
const shortcutsHint = state.showShortcuts
|
|
279
330
|
? Alert({ key: 'sc', kind: 'info', title: 'Keyboard shortcuts',
|
|
280
331
|
children: 'g then c/h/s — chat/history/settings · n — new chat · / — focus search/composer · ? — toggle this · Esc — blur field' })
|
|
@@ -306,6 +357,20 @@ function toolSummary(block) {
|
|
|
306
357
|
return '⌘ ' + name + (arg ? ' · ' + String(arg).slice(0, 120) : '');
|
|
307
358
|
}
|
|
308
359
|
|
|
360
|
+
function toolResultSummary(block) {
|
|
361
|
+
const c = block?.content ?? block?.output ?? block;
|
|
362
|
+
let s = typeof c === 'string' ? c : (() => { try { return JSON.stringify(c); } catch { return String(c); } })();
|
|
363
|
+
s = s.replace(/\s+/g, ' ').trim();
|
|
364
|
+
return (block?.is_error ? '⚠ ' : '') + s.slice(0, 160);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function errText(e) {
|
|
368
|
+
if (e == null) return 'unknown error';
|
|
369
|
+
if (typeof e === 'string') return e;
|
|
370
|
+
if (e.message) return e.message;
|
|
371
|
+
try { return JSON.stringify(e); } catch { return String(e); }
|
|
372
|
+
}
|
|
373
|
+
|
|
309
374
|
function chatMain() {
|
|
310
375
|
const lastIdx = state.chat.messages.length - 1;
|
|
311
376
|
const agentName = agentById(state.selectedAgent)?.name || state.selectedAgent || 'agent';
|
|
@@ -332,7 +397,7 @@ function chatMain() {
|
|
|
332
397
|
: (!agentAvailable(state.selectedAgent) ? agentName + ' is not installed' : 'message…');
|
|
333
398
|
const composer = ChatComposer({
|
|
334
399
|
value: state.chat.draft,
|
|
335
|
-
disabled:
|
|
400
|
+
disabled: !canSend(),
|
|
336
401
|
placeholder,
|
|
337
402
|
onInput: (v) => { state.chat.draft = v; render(); },
|
|
338
403
|
onSend: (v) => { state.chat.draft = v; sendChat(); },
|
|
@@ -341,24 +406,31 @@ function chatMain() {
|
|
|
341
406
|
const banners = [];
|
|
342
407
|
if (state.chat.resumeSid) {
|
|
343
408
|
banners.push(h('div', { key: 'rb', class: 'resume-banner', role: 'status' },
|
|
344
|
-
h('span', { class: 'lede' }, '▶ resuming session ' + state.chat.resumeSid.slice(0, 8) + '… via --resume'),
|
|
345
|
-
Btn({ key: 'rclr', onClick: () => { state.chat.resumeSid = null; render(); }, children: '× clear' })));
|
|
409
|
+
h('span', { key: 'rbtxt', class: 'lede' }, '▶ resuming session ' + state.chat.resumeSid.slice(0, 8) + '… via --resume'),
|
|
410
|
+
Btn({ key: 'rclr', onClick: () => { state.chat.resumeSid = null; state.chat.resumeNote = null; render(); }, children: '× clear' })));
|
|
411
|
+
if (state.chat.resumeNote) {
|
|
412
|
+
banners.push(Alert({ key: 'rnote', kind: 'info', title: 'Agent switched', children: state.chat.resumeNote }));
|
|
413
|
+
}
|
|
346
414
|
}
|
|
347
|
-
banners.push(
|
|
348
|
-
h('span', { class: 'lede' }, state.chatCwd ? '▣ cwd: ' + state.chatCwd : '▣ cwd: server default'),
|
|
349
|
-
Btn({ key: 'cwdset', onClick: () => {
|
|
350
|
-
const v = prompt('Working directory for new chats (absolute path; blank = server default):', state.chatCwd || '');
|
|
351
|
-
if (v === null) return;
|
|
352
|
-
state.chatCwd = v.trim();
|
|
353
|
-
if (state.chatCwd) localStorage.setItem('agentgui.cwd', state.chatCwd); else localStorage.removeItem('agentgui.cwd');
|
|
354
|
-
render();
|
|
355
|
-
}, children: state.chatCwd ? 'change' : 'set' }),
|
|
356
|
-
state.chatCwd ? Btn({ key: 'cwdclr', onClick: () => { state.chatCwd = ''; localStorage.removeItem('agentgui.cwd'); render(); }, children: '× default' }) : null));
|
|
415
|
+
banners.push(cwdBanner());
|
|
357
416
|
if (state.selectedAgent && !agentAvailable(state.selectedAgent)) {
|
|
358
417
|
banners.push(Alert({ key: 'unavail', kind: 'warn', title: agentName + ' is not installed',
|
|
359
418
|
children: 'This agent\'s CLI was not found on the server. Pick another agent or install it.' }));
|
|
360
419
|
}
|
|
420
|
+
if (state.confirmingNewChat) {
|
|
421
|
+
banners.push(Alert({ key: 'confnew', kind: 'warn', title: 'Clear chat history?',
|
|
422
|
+
children: [
|
|
423
|
+
h('span', { key: 'cntxt' }, 'This cannot be undone. '),
|
|
424
|
+
Btn({ key: 'cnyes', danger: true, onClick: newChat, children: 'clear' }),
|
|
425
|
+
Btn({ key: 'cnno', onClick: () => { state.confirmingNewChat = false; render(); }, children: 'cancel' })] }));
|
|
426
|
+
}
|
|
427
|
+
// Last stream error surfaced as a proper Alert instead of raw JSON in the bubble.
|
|
428
|
+
const lastErr = state.chat.messages.length ? state.chat.messages[state.chat.messages.length - 1].error : null;
|
|
429
|
+
if (lastErr && !state.chat.busy) {
|
|
430
|
+
banners.push(Alert({ key: 'chaterr', kind: 'error', title: 'Stream error', children: lastErr }));
|
|
431
|
+
}
|
|
361
432
|
return [
|
|
433
|
+
offlineBanner(),
|
|
362
434
|
...banners,
|
|
363
435
|
Chat({
|
|
364
436
|
title: agentName + (state.selectedModel ? ' · ' + state.selectedModel : '') + (state.chat.resumeSid ? ' · resume' : ''),
|
|
@@ -369,8 +441,36 @@ function chatMain() {
|
|
|
369
441
|
].filter(Boolean);
|
|
370
442
|
}
|
|
371
443
|
|
|
444
|
+
function offlineBanner() {
|
|
445
|
+
if (state.health.status === 'ok' || state.health.status === 'unknown') return null;
|
|
446
|
+
return Alert({ key: 'offline', kind: 'error', title: 'Backend unreachable',
|
|
447
|
+
children: 'agentgui can\'t reach the server (' + (state.health.error || state.health.status) + '). Chat and history actions will fail until it reconnects.' });
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function cwdBanner() {
|
|
451
|
+
if (state.cwdEditing) {
|
|
452
|
+
return h('div', { key: 'cwdb', class: 'resume-banner', role: 'group', 'aria-label': 'Set working directory' },
|
|
453
|
+
TextField({ key: 'cwdfield', label: 'working directory (blank = server default)', value: state.cwdDraft ?? state.chatCwd ?? '',
|
|
454
|
+
placeholder: 'absolute path', onInput: (v) => { state.cwdDraft = v; } }),
|
|
455
|
+
Btn({ key: 'cwdsave', primary: true, onClick: () => {
|
|
456
|
+
state.chatCwd = (state.cwdDraft ?? '').trim();
|
|
457
|
+
if (state.chatCwd) lsSet('agentgui.cwd', state.chatCwd); else lsRemove('agentgui.cwd');
|
|
458
|
+
state.cwdEditing = false; state.cwdDraft = undefined; render();
|
|
459
|
+
}, children: 'save' }),
|
|
460
|
+
Btn({ key: 'cwdcancel', onClick: () => { state.cwdEditing = false; state.cwdDraft = undefined; render(); }, children: 'cancel' }));
|
|
461
|
+
}
|
|
462
|
+
return h('div', { key: 'cwdb', class: 'resume-banner', role: 'group', 'aria-label': 'Working directory' },
|
|
463
|
+
h('span', { class: 'lede' }, state.chatCwd ? '▣ cwd: ' + state.chatCwd : '▣ cwd: server default'),
|
|
464
|
+
Btn({ key: 'cwdset', onClick: () => { state.cwdEditing = true; state.cwdDraft = state.chatCwd || ''; render(); }, children: state.chatCwd ? 'change' : 'set' }),
|
|
465
|
+
state.chatCwd ? Btn({ key: 'cwdclr', onClick: () => { state.chatCwd = ''; lsRemove('agentgui.cwd'); render(); }, children: '× default' }) : null);
|
|
466
|
+
}
|
|
467
|
+
|
|
372
468
|
function newChat() {
|
|
373
|
-
if (state.chat.messages.length && !
|
|
469
|
+
if (state.chat.messages.length && !state.confirmingNewChat) {
|
|
470
|
+
state.confirmingNewChat = true; render();
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
state.confirmingNewChat = false;
|
|
374
474
|
state.chat.abort?.abort();
|
|
375
475
|
state.chat = { messages: [], busy: false, abort: null, draft: '', resumeSid: null };
|
|
376
476
|
try { localStorage.removeItem(CHAT_KEY); } catch {}
|
|
@@ -425,10 +525,12 @@ async function sendChat() {
|
|
|
425
525
|
})) {
|
|
426
526
|
if (ev.type === 'text') { cur.content += ev.text; render(); scrollChatToBottom(); }
|
|
427
527
|
else if (ev.type === 'tool') { cur.parts.push(toolSummary(ev.block)); render(); scrollChatToBottom(); }
|
|
428
|
-
else if (ev.type === '
|
|
528
|
+
else if (ev.type === 'tool_result') { cur.parts.push('↳ ' + toolResultSummary(ev.block)); render(); scrollChatToBottom(); }
|
|
529
|
+
else if (ev.type === 'result') { /* terminal usage/summary block — already reflected via text */ }
|
|
530
|
+
else if (ev.type === 'error') { cur.error = errText(ev.error); render(); }
|
|
429
531
|
}
|
|
430
532
|
} catch (e) {
|
|
431
|
-
if (e.name !== 'AbortError') cur.
|
|
533
|
+
if (e.name !== 'AbortError') cur.error = errText(e.message);
|
|
432
534
|
} finally {
|
|
433
535
|
state.chat.busy = false;
|
|
434
536
|
state.chat.abort = null;
|
|
@@ -439,12 +541,25 @@ async function sendChat() {
|
|
|
439
541
|
}
|
|
440
542
|
|
|
441
543
|
// ── history ────────────────────────────────────────────────────────────────
|
|
544
|
+
function reconnectAlert() {
|
|
545
|
+
if (!state.live.error) return null;
|
|
546
|
+
return Alert({
|
|
547
|
+
key: 'liveerr',
|
|
548
|
+
kind: 'error',
|
|
549
|
+
title: 'Live stream disconnected',
|
|
550
|
+
children: [h('span', { key: 'lemsg' }, state.live.error + ' — '), Btn({ key: 'reco', onClick: openLiveStream, children: 'reconnect', title: 'Reconnect to history stream' })],
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
|
|
442
554
|
function historyMain() {
|
|
443
555
|
if (!state.selectedSid) {
|
|
444
|
-
return [
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
556
|
+
return [
|
|
557
|
+
reconnectAlert(),
|
|
558
|
+
PageHeader({
|
|
559
|
+
title: '§ history',
|
|
560
|
+
lede: 'pick a session from the sidebar — events stream live from ccsniff /v1/history.',
|
|
561
|
+
}),
|
|
562
|
+
].filter(Boolean);
|
|
448
563
|
}
|
|
449
564
|
|
|
450
565
|
const sess = (Array.isArray(state.sessions) ? state.sessions : []).find(s => s.sid === state.selectedSid);
|
|
@@ -457,52 +572,73 @@ function historyMain() {
|
|
|
457
572
|
lede,
|
|
458
573
|
});
|
|
459
574
|
|
|
460
|
-
if (!state.selectedSid) {
|
|
461
|
-
return [head, state.live.error ? Alert({
|
|
462
|
-
key: 'err',
|
|
463
|
-
kind: 'error',
|
|
464
|
-
title: 'Connection lost',
|
|
465
|
-
children: [state.live.error, ' — ', Btn({ onClick: openLiveStream, children: 'reconnect', title: 'Reconnect to history stream' })],
|
|
466
|
-
}) : null];
|
|
467
|
-
}
|
|
468
|
-
|
|
469
575
|
const actions = h('div', { key: 'acts', class: 'history-actions' },
|
|
470
576
|
Btn({ key: 'resume', primary: true, onClick: () => resumeInChat(sess || { sid: state.selectedSid }), children: '▶ open in chat' }),
|
|
471
|
-
Btn({ key: 'copy', onClick:
|
|
577
|
+
Btn({ key: 'copy', onClick: copySid, children: copyToast || '⎘ copy sid' }),
|
|
472
578
|
);
|
|
473
579
|
|
|
474
580
|
if (state.events.length === 0) {
|
|
475
|
-
|
|
581
|
+
// Distinguish "still loading" from "genuinely empty" so a 0-event session
|
|
582
|
+
// doesn't spin forever.
|
|
583
|
+
const body = state.eventsLoaded
|
|
584
|
+
? h('p', { key: 'noev', class: 'lede empty-state', role: 'status' }, 'no events in this session')
|
|
585
|
+
: h('div', { key: 'loading', class: 'lede empty-state', role: 'status', 'aria-live': 'polite' }, Spinner({ key: 'spin', size: 'sm' }), 'loading events…');
|
|
586
|
+
return [reconnectAlert(), head, actions, Panel({ title: 'events', children: body })].filter(Boolean);
|
|
476
587
|
}
|
|
477
588
|
|
|
478
589
|
if (!state.expandedEvents) state.expandedEvents = new Set();
|
|
590
|
+
const total = state.events.length;
|
|
591
|
+
const shown = state.events.slice(-300);
|
|
592
|
+
const hiddenCount = total - shown.length;
|
|
479
593
|
return [
|
|
594
|
+
reconnectAlert(),
|
|
480
595
|
head,
|
|
481
596
|
actions,
|
|
482
597
|
Panel({
|
|
483
|
-
title:
|
|
598
|
+
title: total + ' events' + (hiddenCount > 0 ? ' (showing last 300)' : ''),
|
|
484
599
|
children: EventList({
|
|
485
|
-
items:
|
|
486
|
-
|
|
600
|
+
items: shown.map((e, i) => {
|
|
601
|
+
// Stable key: prefer the server-assigned event index, else the
|
|
602
|
+
// event timestamp + position, never a bare array index (which
|
|
603
|
+
// collides between loaded and live-pushed events).
|
|
604
|
+
const key = e.i != null ? 'ev' + e.i : 'ev-' + (e.ts || 0) + '-' + (total - shown.length + i);
|
|
487
605
|
const role = e.role || '?';
|
|
488
606
|
const type = e.type || '?';
|
|
489
607
|
const tool = e.tool ? ' · ⌘ ' + e.tool : '';
|
|
490
608
|
const errMark = e.isError ? ' · ⚠' : '';
|
|
491
609
|
const raw = e.text || '';
|
|
492
610
|
const text = raw.replace(/\s+/g, ' ').trim();
|
|
493
|
-
const expanded = state.expandedEvents.has(
|
|
611
|
+
const expanded = state.expandedEvents.has(key);
|
|
494
612
|
const full = e.toolInput ? (text + '\n\n' + JSON.stringify(e.toolInput, null, 2)) : raw;
|
|
495
613
|
return {
|
|
496
|
-
key
|
|
497
|
-
code: String(
|
|
614
|
+
key,
|
|
615
|
+
code: String(total - shown.length + i + 1).padStart(4, '0'),
|
|
498
616
|
title: expanded ? (full || '(' + type + ')') : (text.slice(0, 220) || '(' + type + ')'),
|
|
499
617
|
sub: new Date(e.ts).toLocaleString() + ' · ' + role + ' · ' + type + tool + errMark + (raw.length > 220 ? ' · ' + (expanded ? 'click to collapse' : 'click to expand') : ''),
|
|
500
|
-
onClick: () => { expanded ? state.expandedEvents.delete(
|
|
618
|
+
onClick: () => { expanded ? state.expandedEvents.delete(key) : state.expandedEvents.add(key); render(); },
|
|
501
619
|
};
|
|
502
620
|
}),
|
|
503
621
|
}),
|
|
504
622
|
}),
|
|
505
|
-
];
|
|
623
|
+
].filter(Boolean);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
let copyToast = null;
|
|
627
|
+
function copySid() {
|
|
628
|
+
const sid = state.selectedSid;
|
|
629
|
+
if (!sid) return;
|
|
630
|
+
const done = () => { copyToast = '✓ copied'; render(); setTimeout(() => { copyToast = null; render(); }, 1500); };
|
|
631
|
+
if (navigator.clipboard?.writeText) {
|
|
632
|
+
navigator.clipboard.writeText(sid).then(done).catch(() => { copyToast = '⚠ copy failed'; render(); setTimeout(() => { copyToast = null; render(); }, 1500); });
|
|
633
|
+
} else {
|
|
634
|
+
// Fallback for insecure (http) origins where navigator.clipboard is absent.
|
|
635
|
+
try {
|
|
636
|
+
const ta = document.createElement('textarea');
|
|
637
|
+
ta.value = sid; ta.style.position = 'fixed'; ta.style.opacity = '0';
|
|
638
|
+
document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove();
|
|
639
|
+
done();
|
|
640
|
+
} catch { copyToast = '⚠ copy failed'; render(); setTimeout(() => { copyToast = null; render(); }, 1500); }
|
|
641
|
+
}
|
|
506
642
|
}
|
|
507
643
|
|
|
508
644
|
function resumeInChat(sess) {
|
|
@@ -511,7 +647,13 @@ function resumeInChat(sess) {
|
|
|
511
647
|
state.chat.resumeSid = sess?.sid || state.selectedSid;
|
|
512
648
|
state.chat.messages = [];
|
|
513
649
|
state.chat.draft = '';
|
|
514
|
-
// Only claude-code supports --resume by sid
|
|
650
|
+
// Only claude-code supports --resume by sid; warn if we have to switch the
|
|
651
|
+
// user's selected agent rather than silently discarding it.
|
|
652
|
+
if (state.selectedAgent && state.selectedAgent !== 'claude-code') {
|
|
653
|
+
state.chat.resumeNote = 'Switched to Claude Code — only it supports resuming a session by id.';
|
|
654
|
+
} else {
|
|
655
|
+
state.chat.resumeNote = null;
|
|
656
|
+
}
|
|
515
657
|
if (state.selectedAgent !== 'claude-code') selectAgent('claude-code');
|
|
516
658
|
render();
|
|
517
659
|
}
|
|
@@ -577,7 +719,7 @@ function historySide() {
|
|
|
577
719
|
const agentName = agentById(r.agentId)?.name || r.agentId || 'agent';
|
|
578
720
|
const elapsed = r.startedAt ? Math.round((Date.now() - r.startedAt) / 1000) : 0;
|
|
579
721
|
return h('div', { key: 'run' + r.sessionId, class: 'resume-banner', role: 'group' },
|
|
580
|
-
h('span', { class: 'lede' }, '● ' + agentName + (r.model ? ' · ' + r.model : '') + ' · ' + elapsed + 's' + (r.cwd ? ' · ' + r.cwd.split(
|
|
722
|
+
h('span', { class: 'lede' }, '● ' + agentName + (r.model ? ' · ' + r.model : '') + ' · ' + elapsed + 's' + (r.cwd ? ' · ' + r.cwd.split(/[/\\]/).filter(Boolean).slice(-1)[0] : '')),
|
|
581
723
|
Btn({ key: 'stop' + r.sessionId, onClick: () => stopActiveChat(r.sessionId), children: '◼ stop' }));
|
|
582
724
|
}),
|
|
583
725
|
})
|
|
@@ -603,7 +745,10 @@ function historySide() {
|
|
|
603
745
|
searching && !state.searchBusy && !state.searchHits.error && (state.searchHits.results || []).length === 0
|
|
604
746
|
? h('p', { key: 'nomatch', class: 'lede empty-state' }, 'no matches for "' + state.searchQ + '"')
|
|
605
747
|
: null,
|
|
606
|
-
state.searchQ
|
|
748
|
+
state.searchQ.trim().length === 1
|
|
749
|
+
? h('p', { key: 'min2', class: 'lede empty-state' }, 'type at least 2 characters to search')
|
|
750
|
+
: null,
|
|
751
|
+
state.searchQ
|
|
607
752
|
? Btn({ key: 'clearq', onClick: () => { state.searchQ = ''; state.searchHits = null; state.searchBusy = false; render(); }, children: '× clear search' })
|
|
608
753
|
: null,
|
|
609
754
|
!searching && projects.length > 1
|
|
@@ -749,6 +894,7 @@ async function refreshHistory() {
|
|
|
749
894
|
render();
|
|
750
895
|
}
|
|
751
896
|
}
|
|
897
|
+
const debouncedRefreshHistory = debounce(refreshHistory, 500);
|
|
752
898
|
|
|
753
899
|
async function runSearch() {
|
|
754
900
|
const q = state.searchQ.trim();
|
|
@@ -757,7 +903,7 @@ async function runSearch() {
|
|
|
757
903
|
state.searchBusy = true;
|
|
758
904
|
render();
|
|
759
905
|
try {
|
|
760
|
-
state.searchHits = await B.searchHistory(state.backend, q,
|
|
906
|
+
state.searchHits = await B.searchHistory(state.backend, q, 60);
|
|
761
907
|
} catch (e) {
|
|
762
908
|
state.searchHits = { query: q, results: [], error: e.message };
|
|
763
909
|
} finally {
|
|
@@ -770,16 +916,22 @@ const debouncedSearch = debounce(runSearch, 300);
|
|
|
770
916
|
async function loadSession(sid) {
|
|
771
917
|
state.selectedSid = sid;
|
|
772
918
|
state.events = [];
|
|
773
|
-
|
|
919
|
+
state.eventsLoaded = false;
|
|
920
|
+
state.expandedEvents = new Set(); // don't carry expansion to the new session
|
|
921
|
+
writeHash(sid, { push: true });
|
|
774
922
|
render();
|
|
775
|
-
try {
|
|
776
|
-
|
|
923
|
+
try {
|
|
924
|
+
state.events = await B.getSessionEvents(state.backend, sid);
|
|
925
|
+
state.eventsLoaded = true;
|
|
926
|
+
render();
|
|
927
|
+
} catch (e) {
|
|
777
928
|
state.events = [{
|
|
778
929
|
ts: Date.now(),
|
|
779
930
|
role: 'error',
|
|
780
931
|
type: 'fetch',
|
|
781
932
|
text: 'Failed to load session: ' + e.message + ' — retry via sidebar',
|
|
782
933
|
}];
|
|
934
|
+
state.eventsLoaded = true;
|
|
783
935
|
render();
|
|
784
936
|
}
|
|
785
937
|
}
|
|
@@ -801,13 +953,26 @@ async function init() {
|
|
|
801
953
|
render();
|
|
802
954
|
} catch (e) { console.warn('agents fetch failed:', e.message); }
|
|
803
955
|
|
|
804
|
-
const initialSid = readHash();
|
|
956
|
+
const { sid: initialSid, tab: initialTab } = readHash();
|
|
805
957
|
if (initialSid) {
|
|
806
958
|
navTo('history');
|
|
807
959
|
await refreshHistory();
|
|
808
960
|
await loadSession(initialSid);
|
|
961
|
+
} else if (initialTab && initialTab !== state.tab) {
|
|
962
|
+
navTo(initialTab);
|
|
809
963
|
}
|
|
810
964
|
|
|
965
|
+
registerWsStatusOnce();
|
|
966
|
+
startActivePolling(); // surface running chats on any tab, not just history
|
|
967
|
+
startRelTimeTick();
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
// init() runs both at boot and on every saveBackend(); registering the WS
|
|
971
|
+
// status listener inside it leaked a listener per save. Register exactly once.
|
|
972
|
+
let wsStatusRegistered = false;
|
|
973
|
+
function registerWsStatusOnce() {
|
|
974
|
+
if (wsStatusRegistered) return;
|
|
975
|
+
wsStatusRegistered = true;
|
|
811
976
|
B.onWsStatus?.((s) => {
|
|
812
977
|
if (s === 'closed' || s === 'error') {
|
|
813
978
|
if (state.health.status === 'ok') { state.health = { ...state.health, ws: 'reconnecting' }; render(); }
|
|
@@ -819,6 +984,19 @@ async function init() {
|
|
|
819
984
|
|
|
820
985
|
restoreChat();
|
|
821
986
|
render = mount(document.getElementById('app'), view);
|
|
987
|
+
|
|
988
|
+
// Re-render on resize so isNarrow()/truncate() reflect the current width
|
|
989
|
+
// (they read window.innerWidth only at render time).
|
|
990
|
+
window.addEventListener('resize', debounce(() => scheduleRender(), 150));
|
|
991
|
+
|
|
992
|
+
// Browser Back/forward: re-sync tab + selected session from the hash.
|
|
993
|
+
window.addEventListener('popstate', () => {
|
|
994
|
+
const { sid, tab } = readHash();
|
|
995
|
+
if (sid && sid !== state.selectedSid) { state.tab = 'history'; loadSession(sid); }
|
|
996
|
+
else if (!sid && tab && tab !== state.tab) navTo(tab);
|
|
997
|
+
else if (!sid && !tab && state.tab !== 'chat') navTo('chat');
|
|
998
|
+
});
|
|
999
|
+
|
|
822
1000
|
window.__agentgui = { state, render };
|
|
823
1001
|
|
|
824
1002
|
// Keyboard shortcuts. 'g' then c/h/s switches tabs; 'n' new chat; '/' focuses
|
package/site/app/js/backend.js
CHANGED
|
@@ -27,14 +27,17 @@ function withToken(url) {
|
|
|
27
27
|
return url + (url.includes('?') ? '&' : '?') + 'token=' + encodeURIComponent(tok);
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
function lsGet(k) { try { return localStorage.getItem(k); } catch { return null; } }
|
|
31
|
+
function lsSet(k, v) { try { localStorage.setItem(k, v); } catch {} }
|
|
32
|
+
|
|
30
33
|
export function getBackend() {
|
|
31
34
|
const u = new URL(location.href);
|
|
32
35
|
const fromQs = u.searchParams.get('backend');
|
|
33
|
-
if (fromQs) {
|
|
34
|
-
return
|
|
36
|
+
if (fromQs) { lsSet(KEY, fromQs); return fromQs; }
|
|
37
|
+
return lsGet(KEY) || DEFAULT_BACKEND;
|
|
35
38
|
}
|
|
36
39
|
|
|
37
|
-
export function setBackend(url) {
|
|
40
|
+
export function setBackend(url) { lsSet(KEY, url); }
|
|
38
41
|
|
|
39
42
|
export async function probeBackend(base) {
|
|
40
43
|
try {
|
|
@@ -262,25 +265,31 @@ export async function* streamChat(base, { model, messages, signal, agentId, resu
|
|
|
262
265
|
const sessionId = started?.sessionId;
|
|
263
266
|
if (!sessionId) { yield { type: 'error', error: 'no sessionId from server' }; return; }
|
|
264
267
|
|
|
268
|
+
const finish = () => { done = true; if (resolveWait) { resolveWait(); resolveWait = null; } };
|
|
269
|
+
|
|
265
270
|
const unsub = addSessionListener(sessionId, (ev) => {
|
|
266
271
|
if (ev.type === 'streaming_progress') {
|
|
267
272
|
const block = ev.block;
|
|
268
273
|
if (block?.type === 'text' && block.text) push({ type: 'text', text: block.text });
|
|
269
274
|
else if (block?.type === 'tool_use') push({ type: 'tool', block });
|
|
270
|
-
else if (block?.type === 'tool_result') push({ type: '
|
|
275
|
+
else if (block?.type === 'tool_result') push({ type: 'tool_result', block });
|
|
271
276
|
else if (block?.type === 'result') push({ type: 'result', block });
|
|
272
277
|
} else if (ev.type === 'streaming_complete') {
|
|
273
|
-
|
|
274
|
-
if (resolveWait) { resolveWait(); resolveWait = null; }
|
|
278
|
+
finish();
|
|
275
279
|
} else if (ev.type === 'streaming_error') {
|
|
276
280
|
errored = ev.error || 'streaming error';
|
|
277
|
-
|
|
278
|
-
if (resolveWait) { resolveWait(); resolveWait = null; }
|
|
281
|
+
finish();
|
|
279
282
|
}
|
|
280
283
|
});
|
|
281
284
|
|
|
282
|
-
//
|
|
283
|
-
|
|
285
|
+
// If the websocket drops mid-stream, streaming_complete will never arrive —
|
|
286
|
+
// surface an error and end the iterator instead of hanging forever.
|
|
287
|
+
const onWs = (s) => { if ((s === 'closed' || s === 'error') && !done) { errored = errored || 'connection lost during stream'; finish(); } };
|
|
288
|
+
const unsubWs = onWsStatus ? onWsStatus(onWs) : null;
|
|
289
|
+
|
|
290
|
+
// Wire AbortSignal to chat.cancel — and end the iterator immediately so the
|
|
291
|
+
// caller's busy state clears even if the server never emits a final event.
|
|
292
|
+
const onAbort = () => { wsCall(base, 'chat.cancel', { sessionId }).catch(() => {}); finish(); };
|
|
284
293
|
if (signal) {
|
|
285
294
|
if (signal.aborted) onAbort();
|
|
286
295
|
else signal.addEventListener('abort', onAbort, { once: true });
|
|
@@ -297,6 +306,7 @@ export async function* streamChat(base, { model, messages, signal, agentId, resu
|
|
|
297
306
|
if (errored) yield { type: 'error', error: errored };
|
|
298
307
|
} finally {
|
|
299
308
|
unsub();
|
|
309
|
+
if (typeof unsubWs === 'function') unsubWs();
|
|
300
310
|
if (signal) signal.removeEventListener?.('abort', onAbort);
|
|
301
311
|
}
|
|
302
312
|
}
|