agentgui 1.0.940 → 1.0.942
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 +6 -7
- package/package.json +1 -1
- package/server.js +10 -1
- package/site/app/index.html +129 -38
- package/site/app/js/app.js +302 -72
- 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,64 @@ 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
|
+
syncAriaCurrent();
|
|
154
|
+
const region = document.querySelector('#agentgui-main');
|
|
155
|
+
if (!region) return;
|
|
156
|
+
const heading = region.querySelector('h1, h2');
|
|
157
|
+
const target = heading || region;
|
|
158
|
+
if (!target.hasAttribute('tabindex')) target.setAttribute('tabindex', '-1');
|
|
159
|
+
// Mark as programmatically focused so CSS can suppress the focus ring — we
|
|
160
|
+
// move focus here for AT, but a visible green outline box around the heading
|
|
161
|
+
// reads as an accidental border to sighted users.
|
|
162
|
+
target.setAttribute('data-prog-focus', '');
|
|
163
|
+
try { target.focus({ preventScroll: true }); } catch {}
|
|
164
|
+
const clear = () => { target.removeAttribute('data-prog-focus'); target.removeEventListener('blur', clear); };
|
|
165
|
+
target.addEventListener('blur', clear);
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// The DS Topbar derives aria-current from href↔location.hash matching, which
|
|
170
|
+
// drifts from our hash-based active tab (e.g. aria-current lands on "settings"
|
|
171
|
+
// while we're on "chat"). Re-assert aria-current on the actually-active tab.
|
|
172
|
+
function syncAriaCurrent() {
|
|
173
|
+
const links = document.querySelectorAll('.app-topbar nav a');
|
|
174
|
+
links.forEach((a) => {
|
|
175
|
+
const isActive = a.classList.contains('active');
|
|
176
|
+
if (isActive) a.setAttribute('aria-current', 'page');
|
|
177
|
+
else a.removeAttribute('aria-current');
|
|
178
|
+
});
|
|
128
179
|
}
|
|
129
180
|
|
|
130
181
|
async function refreshActive() {
|
|
131
|
-
state.active = await B.listActiveChats(state.backend);
|
|
182
|
+
try { state.active = await B.listActiveChats(state.backend); } catch { return; }
|
|
132
183
|
render();
|
|
133
184
|
}
|
|
134
185
|
function startActivePolling() {
|
|
135
186
|
if (state.activeTimer) return;
|
|
136
187
|
refreshActive();
|
|
137
|
-
|
|
188
|
+
// Small jitter so many tabs don't hit the server in lockstep.
|
|
189
|
+
state.activeTimer = setInterval(refreshActive, 3000 + Math.floor(Math.random() * 600));
|
|
138
190
|
}
|
|
139
191
|
function stopActivePolling() {
|
|
140
192
|
if (state.activeTimer) { clearInterval(state.activeTimer); state.activeTimer = null; }
|
|
141
193
|
}
|
|
194
|
+
|
|
195
|
+
// Re-render once a minute so relative timestamps ("5s ago") don't sit frozen
|
|
196
|
+
// between events. Cheap: scheduleRender coalesces via rAF.
|
|
197
|
+
let _relTick = null;
|
|
198
|
+
function startRelTimeTick() { if (!_relTick) _relTick = setInterval(() => scheduleRender(), 30000); }
|
|
142
199
|
async function stopActiveChat(sid) {
|
|
143
200
|
try { await B.cancelChat(state.backend, sid); } catch {}
|
|
144
201
|
refreshActive();
|
|
@@ -158,6 +215,8 @@ function openLiveStream() {
|
|
|
158
215
|
} else if (kind === 'event' && data) {
|
|
159
216
|
if (state.selectedSid && data.sid === state.selectedSid) {
|
|
160
217
|
state.events.push(data);
|
|
218
|
+
// Cap retained events so a long live session can't grow unbounded.
|
|
219
|
+
if (state.events.length > 2000) state.events.splice(0, state.events.length - 2000);
|
|
161
220
|
}
|
|
162
221
|
const arr = Array.isArray(state.sessions) ? state.sessions : [];
|
|
163
222
|
const sess = arr.find(s => s.sid === data.sid);
|
|
@@ -167,11 +226,13 @@ function openLiveStream() {
|
|
|
167
226
|
if (data.type === 'tool_use') sess.tools = (sess.tools || 0) + 1;
|
|
168
227
|
if (data.isError) sess.errors = (sess.errors || 0) + 1;
|
|
169
228
|
} else {
|
|
170
|
-
|
|
229
|
+
// Unknown session: a burst of events for a new session would trigger
|
|
230
|
+
// a full session-list refetch per event — debounce it into one.
|
|
231
|
+
debouncedRefreshHistory();
|
|
171
232
|
return;
|
|
172
233
|
}
|
|
173
234
|
} else if (kind === 'conversation') {
|
|
174
|
-
|
|
235
|
+
debouncedRefreshHistory();
|
|
175
236
|
return;
|
|
176
237
|
} else if (kind === 'error' && data) {
|
|
177
238
|
state.live.error = data.error || 'stream error';
|
|
@@ -208,12 +269,21 @@ function view() {
|
|
|
208
269
|
: (liveActive ? '● live · ' + state.live.eventCount : (state.live.connected ? '● live' : '◌ connecting…')))
|
|
209
270
|
: (ok ? (state.health.ws === 'reconnecting' ? '◌ ws reconnecting' : '● connected') : '○ offline');
|
|
210
271
|
const dotLive = state.tab === 'history' ? (liveActive || state.live.connected) : ok;
|
|
211
|
-
|
|
272
|
+
// Split the leading status glyph (● ◌ ○) from the words: glyph is decorative
|
|
273
|
+
// (aria-hidden), only the text is announced, so AT reads "live" not "black circle live".
|
|
274
|
+
const glyphMatch = dotText.match(/^([●◌○])\s*(.*)$/);
|
|
275
|
+
const dotGlyph = glyphMatch ? glyphMatch[1] : '';
|
|
276
|
+
const dotLabel = glyphMatch ? glyphMatch[2] : dotText;
|
|
277
|
+
// When live, the CSS .status-dot-live::before draws the (pulsing) dot, so the
|
|
278
|
+
// literal glyph would render a second dot — only emit the glyph when NOT live.
|
|
279
|
+
const dot = h('span', { key: 'dot', class: 'status-dot' + (dotLive ? ' status-dot-live' : ''), role: 'status', 'aria-live': 'polite' },
|
|
280
|
+
(!dotLive && dotGlyph) ? h('span', { key: 'dg', 'aria-hidden': 'true' }, dotGlyph + ' ') : null,
|
|
281
|
+
h('span', { key: 'dl' }, dotLabel));
|
|
212
282
|
|
|
213
283
|
const topbar = Topbar({
|
|
214
284
|
brand: 'agentgui',
|
|
215
285
|
leaf: state.tab,
|
|
216
|
-
items: [['chat', '#'], ['history', '
|
|
286
|
+
items: [['chat', buildHash('chat', null) || '#'], ['history', buildHash('history', null)], ['settings', buildHash('settings', null)]],
|
|
217
287
|
active: state.tab,
|
|
218
288
|
onNav: (label) => navTo(label),
|
|
219
289
|
});
|
|
@@ -272,16 +342,17 @@ function view() {
|
|
|
272
342
|
right: [agentLabel],
|
|
273
343
|
});
|
|
274
344
|
|
|
345
|
+
// The design system now owns the full-height column + inner scroll for
|
|
346
|
+
// .app-main, so chat just needs to be a flex column that fills it.
|
|
275
347
|
const mainStyle = state.tab === 'chat'
|
|
276
|
-
? 'min-height:0;
|
|
277
|
-
: 'min-height:0
|
|
348
|
+
? 'min-height:0;display:flex;flex-direction:column;flex:1'
|
|
349
|
+
: 'min-height:0';
|
|
278
350
|
const shortcutsHint = state.showShortcuts
|
|
279
351
|
? Alert({ key: 'sc', kind: 'info', title: 'Keyboard shortcuts',
|
|
280
352
|
children: 'g then c/h/s — chat/history/settings · n — new chat · / — focus search/composer · ? — toggle this · Esc — blur field' })
|
|
281
353
|
: null;
|
|
282
354
|
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));
|
|
283
|
-
|
|
284
|
-
return AppShell({ topbar, crumb, side, main, status, narrow: state.tab === 'settings' });
|
|
355
|
+
return AppShell({ topbar, crumb, side, main, status, narrow: false });
|
|
285
356
|
}
|
|
286
357
|
|
|
287
358
|
function mainContent() {
|
|
@@ -306,6 +377,20 @@ function toolSummary(block) {
|
|
|
306
377
|
return '⌘ ' + name + (arg ? ' · ' + String(arg).slice(0, 120) : '');
|
|
307
378
|
}
|
|
308
379
|
|
|
380
|
+
function toolResultSummary(block) {
|
|
381
|
+
const c = block?.content ?? block?.output ?? block;
|
|
382
|
+
let s = typeof c === 'string' ? c : (() => { try { return JSON.stringify(c); } catch { return String(c); } })();
|
|
383
|
+
s = s.replace(/\s+/g, ' ').trim();
|
|
384
|
+
return (block?.is_error ? '⚠ ' : '') + s.slice(0, 160);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function errText(e) {
|
|
388
|
+
if (e == null) return 'unknown error';
|
|
389
|
+
if (typeof e === 'string') return e;
|
|
390
|
+
if (e.message) return e.message;
|
|
391
|
+
try { return JSON.stringify(e); } catch { return String(e); }
|
|
392
|
+
}
|
|
393
|
+
|
|
309
394
|
function chatMain() {
|
|
310
395
|
const lastIdx = state.chat.messages.length - 1;
|
|
311
396
|
const agentName = agentById(state.selectedAgent)?.name || state.selectedAgent || 'agent';
|
|
@@ -332,7 +417,7 @@ function chatMain() {
|
|
|
332
417
|
: (!agentAvailable(state.selectedAgent) ? agentName + ' is not installed' : 'message…');
|
|
333
418
|
const composer = ChatComposer({
|
|
334
419
|
value: state.chat.draft,
|
|
335
|
-
disabled:
|
|
420
|
+
disabled: !canSend(),
|
|
336
421
|
placeholder,
|
|
337
422
|
onInput: (v) => { state.chat.draft = v; render(); },
|
|
338
423
|
onSend: (v) => { state.chat.draft = v; sendChat(); },
|
|
@@ -341,36 +426,79 @@ function chatMain() {
|
|
|
341
426
|
const banners = [];
|
|
342
427
|
if (state.chat.resumeSid) {
|
|
343
428
|
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' })));
|
|
429
|
+
h('span', { key: 'rbtxt', class: 'lede' }, '▶ resuming session ' + state.chat.resumeSid.slice(0, 8) + '… via --resume'),
|
|
430
|
+
Btn({ key: 'rclr', onClick: () => { state.chat.resumeSid = null; state.chat.resumeNote = null; render(); }, children: '× clear' })));
|
|
431
|
+
if (state.chat.resumeNote) {
|
|
432
|
+
banners.push(Alert({ key: 'rnote', kind: 'info', title: 'Agent switched', children: state.chat.resumeNote }));
|
|
433
|
+
}
|
|
346
434
|
}
|
|
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));
|
|
435
|
+
banners.push(cwdBanner());
|
|
357
436
|
if (state.selectedAgent && !agentAvailable(state.selectedAgent)) {
|
|
358
437
|
banners.push(Alert({ key: 'unavail', kind: 'warn', title: agentName + ' is not installed',
|
|
359
438
|
children: 'This agent\'s CLI was not found on the server. Pick another agent or install it.' }));
|
|
360
439
|
}
|
|
440
|
+
if (state.confirmingNewChat) {
|
|
441
|
+
banners.push(Alert({ key: 'confnew', kind: 'warn', title: 'Clear chat history?',
|
|
442
|
+
children: [
|
|
443
|
+
h('span', { key: 'cntxt' }, 'This cannot be undone. '),
|
|
444
|
+
Btn({ key: 'cnyes', danger: true, onClick: newChat, children: 'clear' }),
|
|
445
|
+
Btn({ key: 'cnno', onClick: () => { state.confirmingNewChat = false; render(); }, children: 'cancel' })] }));
|
|
446
|
+
}
|
|
447
|
+
// Last stream error surfaced as a proper Alert instead of raw JSON in the bubble.
|
|
448
|
+
const lastErr = state.chat.messages.length ? state.chat.messages[state.chat.messages.length - 1].error : null;
|
|
449
|
+
if (lastErr && !state.chat.busy) {
|
|
450
|
+
banners.push(Alert({ key: 'chaterr', kind: 'error', title: 'Stream error', children: lastErr }));
|
|
451
|
+
}
|
|
361
452
|
return [
|
|
453
|
+
offlineBanner(),
|
|
362
454
|
...banners,
|
|
363
455
|
Chat({
|
|
364
456
|
title: agentName + (state.selectedModel ? ' · ' + state.selectedModel : '') + (state.chat.resumeSid ? ' · resume' : ''),
|
|
365
|
-
sub
|
|
457
|
+
// Explicit human-readable sub; the DS default ("NN msgs", zero-padded)
|
|
458
|
+
// leaks an event-list style into chat. Hide it when there are no messages
|
|
459
|
+
// (the empty-state already says "no messages yet").
|
|
460
|
+
sub: state.chat.busy
|
|
461
|
+
? 'streaming…'
|
|
462
|
+
: (state.chat.messages.length
|
|
463
|
+
? state.chat.messages.length + (state.chat.messages.length === 1 ? ' message' : ' messages')
|
|
464
|
+
: ''),
|
|
366
465
|
messages: msgs,
|
|
367
466
|
composer,
|
|
368
467
|
}),
|
|
369
468
|
].filter(Boolean);
|
|
370
469
|
}
|
|
371
470
|
|
|
471
|
+
function offlineBanner() {
|
|
472
|
+
if (state.health.status === 'ok' || state.health.status === 'unknown') return null;
|
|
473
|
+
return Alert({ key: 'offline', kind: 'error', title: 'Backend unreachable',
|
|
474
|
+
children: 'agentgui can\'t reach the server (' + (state.health.error || state.health.status) + '). Chat and history actions will fail until it reconnects.' });
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function cwdBanner() {
|
|
478
|
+
if (state.cwdEditing) {
|
|
479
|
+
return h('div', { key: 'cwdb', class: 'resume-banner', role: 'group', 'aria-label': 'Set working directory' },
|
|
480
|
+
TextField({ key: 'cwdfield', label: 'working directory (blank = server default)', value: state.cwdDraft ?? state.chatCwd ?? '',
|
|
481
|
+
placeholder: 'absolute path', onInput: (v) => { state.cwdDraft = v; } }),
|
|
482
|
+
Btn({ key: 'cwdsave', primary: true, onClick: () => {
|
|
483
|
+
state.chatCwd = (state.cwdDraft ?? '').trim();
|
|
484
|
+
if (state.chatCwd) lsSet('agentgui.cwd', state.chatCwd); else lsRemove('agentgui.cwd');
|
|
485
|
+
state.cwdEditing = false; state.cwdDraft = undefined; render();
|
|
486
|
+
}, children: 'save' }),
|
|
487
|
+
Btn({ key: 'cwdcancel', onClick: () => { state.cwdEditing = false; state.cwdDraft = undefined; render(); }, children: 'cancel' }));
|
|
488
|
+
}
|
|
489
|
+
return h('div', { key: 'cwdb', class: 'cwd-bar', role: 'group', 'aria-label': 'Working directory' },
|
|
490
|
+
h('span', { key: 'cwdtxt', class: 'lede cwd-bar-text', title: state.chatCwd || 'server default working directory' },
|
|
491
|
+
state.chatCwd ? '▣ ' + truncate(state.chatCwd, 28, 60) : '▣ cwd: server default'),
|
|
492
|
+
h('button', { key: 'cwdset', type: 'button', class: 'cwd-bar-btn', onClick: () => { state.cwdEditing = true; state.cwdDraft = state.chatCwd || ''; render(); } }, state.chatCwd ? 'change' : 'set'),
|
|
493
|
+
state.chatCwd ? h('button', { key: 'cwdclr', type: 'button', class: 'cwd-bar-btn', onClick: () => { state.chatCwd = ''; lsRemove('agentgui.cwd'); render(); } }, '× default') : null);
|
|
494
|
+
}
|
|
495
|
+
|
|
372
496
|
function newChat() {
|
|
373
|
-
if (state.chat.messages.length && !
|
|
497
|
+
if (state.chat.messages.length && !state.confirmingNewChat) {
|
|
498
|
+
state.confirmingNewChat = true; render();
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
state.confirmingNewChat = false;
|
|
374
502
|
state.chat.abort?.abort();
|
|
375
503
|
state.chat = { messages: [], busy: false, abort: null, draft: '', resumeSid: null };
|
|
376
504
|
try { localStorage.removeItem(CHAT_KEY); } catch {}
|
|
@@ -425,10 +553,12 @@ async function sendChat() {
|
|
|
425
553
|
})) {
|
|
426
554
|
if (ev.type === 'text') { cur.content += ev.text; render(); scrollChatToBottom(); }
|
|
427
555
|
else if (ev.type === 'tool') { cur.parts.push(toolSummary(ev.block)); render(); scrollChatToBottom(); }
|
|
428
|
-
else if (ev.type === '
|
|
556
|
+
else if (ev.type === 'tool_result') { cur.parts.push('↳ ' + toolResultSummary(ev.block)); render(); scrollChatToBottom(); }
|
|
557
|
+
else if (ev.type === 'result') { /* terminal usage/summary block — already reflected via text */ }
|
|
558
|
+
else if (ev.type === 'error') { cur.error = errText(ev.error); render(); }
|
|
429
559
|
}
|
|
430
560
|
} catch (e) {
|
|
431
|
-
if (e.name !== 'AbortError') cur.
|
|
561
|
+
if (e.name !== 'AbortError') cur.error = errText(e.message);
|
|
432
562
|
} finally {
|
|
433
563
|
state.chat.busy = false;
|
|
434
564
|
state.chat.abort = null;
|
|
@@ -439,70 +569,113 @@ async function sendChat() {
|
|
|
439
569
|
}
|
|
440
570
|
|
|
441
571
|
// ── history ────────────────────────────────────────────────────────────────
|
|
572
|
+
function reconnectAlert() {
|
|
573
|
+
if (!state.live.error) return null;
|
|
574
|
+
return Alert({
|
|
575
|
+
key: 'liveerr',
|
|
576
|
+
kind: 'error',
|
|
577
|
+
title: 'Live stream disconnected',
|
|
578
|
+
children: [h('span', { key: 'lemsg' }, state.live.error + ' — '), Btn({ key: 'reco', onClick: openLiveStream, children: 'reconnect', title: 'Reconnect to history stream' })],
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
|
|
442
582
|
function historyMain() {
|
|
443
583
|
if (!state.selectedSid) {
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
584
|
+
const count = (Array.isArray(state.sessions) ? state.sessions : []).length;
|
|
585
|
+
return [
|
|
586
|
+
reconnectAlert(),
|
|
587
|
+
PageHeader({
|
|
588
|
+
title: '§ history',
|
|
589
|
+
lede: 'pick a session from the sidebar — events stream live from ccsniff /v1/history.',
|
|
590
|
+
}),
|
|
591
|
+
h('div', { key: 'histempty', class: 'history-empty', role: 'status' },
|
|
592
|
+
h('div', { key: 'ge', class: 'history-empty-glyph', 'aria-hidden': 'true' }, '§'),
|
|
593
|
+
h('p', { key: 'gt', class: 'history-empty-title' },
|
|
594
|
+
count ? 'Select a session to view its events' : 'No sessions yet'),
|
|
595
|
+
h('p', { key: 'gs', class: 'lede history-empty-sub' },
|
|
596
|
+
count
|
|
597
|
+
? count + ' session' + (count === 1 ? '' : 's') + ' available · use the search box or press / to filter'
|
|
598
|
+
: 'Start a chat or run a local coding agent — its session will appear here live.')),
|
|
599
|
+
].filter(Boolean);
|
|
448
600
|
}
|
|
449
601
|
|
|
450
602
|
const sess = (Array.isArray(state.sessions) ? state.sessions : []).find(s => s.sid === state.selectedSid);
|
|
451
603
|
const lede = sess
|
|
452
|
-
? (sess.project || sess.cwd || '?') + ' · ' + (sess.events || 0) + ' events · ' + (sess.userTurns || 0) + ' turns · ' + fmtRelTime(sess.last)
|
|
604
|
+
? (projectLabel(sess.project) || sess.cwd || '?') + ' · ' + (sess.events || 0) + ' events · ' + (sess.userTurns || 0) + ' turns · ' + fmtRelTime(sess.last)
|
|
453
605
|
: state.selectedSid;
|
|
454
606
|
|
|
455
607
|
const head = PageHeader({
|
|
456
|
-
title: '§ ' + truncate(sess?.title || state.selectedSid, 40, 80),
|
|
608
|
+
title: '§ ' + truncate(projectLabel(sess?.title) || projectLabel(sess?.project) || state.selectedSid, 40, 80),
|
|
457
609
|
lede,
|
|
458
610
|
});
|
|
459
611
|
|
|
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
612
|
const actions = h('div', { key: 'acts', class: 'history-actions' },
|
|
470
613
|
Btn({ key: 'resume', primary: true, onClick: () => resumeInChat(sess || { sid: state.selectedSid }), children: '▶ open in chat' }),
|
|
471
|
-
Btn({ key: 'copy', onClick:
|
|
614
|
+
Btn({ key: 'copy', onClick: copySid, children: copyToast || '⎘ copy sid' }),
|
|
472
615
|
);
|
|
473
616
|
|
|
474
617
|
if (state.events.length === 0) {
|
|
475
|
-
|
|
618
|
+
// Distinguish "still loading" from "genuinely empty" so a 0-event session
|
|
619
|
+
// doesn't spin forever.
|
|
620
|
+
const body = state.eventsLoaded
|
|
621
|
+
? h('p', { key: 'noev', class: 'lede empty-state', role: 'status' }, 'no events in this session')
|
|
622
|
+
: h('div', { key: 'loading', class: 'lede empty-state', role: 'status', 'aria-live': 'polite' }, Spinner({ key: 'spin', size: 'sm' }), 'loading events…');
|
|
623
|
+
return [reconnectAlert(), head, actions, Panel({ title: 'events', children: body })].filter(Boolean);
|
|
476
624
|
}
|
|
477
625
|
|
|
478
626
|
if (!state.expandedEvents) state.expandedEvents = new Set();
|
|
627
|
+
const total = state.events.length;
|
|
628
|
+
const shown = state.events.slice(-300);
|
|
629
|
+
const hiddenCount = total - shown.length;
|
|
479
630
|
return [
|
|
631
|
+
reconnectAlert(),
|
|
480
632
|
head,
|
|
481
633
|
actions,
|
|
482
634
|
Panel({
|
|
483
|
-
title:
|
|
635
|
+
title: total + ' events' + (hiddenCount > 0 ? ' (showing last 300)' : ''),
|
|
484
636
|
children: EventList({
|
|
485
|
-
items:
|
|
486
|
-
|
|
637
|
+
items: shown.map((e, i) => {
|
|
638
|
+
// Stable key: prefer the server-assigned event index, else the
|
|
639
|
+
// event timestamp + position, never a bare array index (which
|
|
640
|
+
// collides between loaded and live-pushed events).
|
|
641
|
+
const key = e.i != null ? 'ev' + e.i : 'ev-' + (e.ts || 0) + '-' + (total - shown.length + i);
|
|
487
642
|
const role = e.role || '?';
|
|
488
643
|
const type = e.type || '?';
|
|
489
644
|
const tool = e.tool ? ' · ⌘ ' + e.tool : '';
|
|
490
645
|
const errMark = e.isError ? ' · ⚠' : '';
|
|
491
646
|
const raw = e.text || '';
|
|
492
647
|
const text = raw.replace(/\s+/g, ' ').trim();
|
|
493
|
-
const expanded = state.expandedEvents.has(
|
|
648
|
+
const expanded = state.expandedEvents.has(key);
|
|
494
649
|
const full = e.toolInput ? (text + '\n\n' + JSON.stringify(e.toolInput, null, 2)) : raw;
|
|
495
650
|
return {
|
|
496
|
-
key
|
|
497
|
-
code: String(
|
|
651
|
+
key,
|
|
652
|
+
code: String(total - shown.length + i + 1).padStart(4, '0'),
|
|
498
653
|
title: expanded ? (full || '(' + type + ')') : (text.slice(0, 220) || '(' + type + ')'),
|
|
499
654
|
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(
|
|
655
|
+
onClick: () => { expanded ? state.expandedEvents.delete(key) : state.expandedEvents.add(key); render(); },
|
|
501
656
|
};
|
|
502
657
|
}),
|
|
503
658
|
}),
|
|
504
659
|
}),
|
|
505
|
-
];
|
|
660
|
+
].filter(Boolean);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
let copyToast = null;
|
|
664
|
+
function copySid() {
|
|
665
|
+
const sid = state.selectedSid;
|
|
666
|
+
if (!sid) return;
|
|
667
|
+
const done = () => { copyToast = '✓ copied'; render(); setTimeout(() => { copyToast = null; render(); }, 1500); };
|
|
668
|
+
if (navigator.clipboard?.writeText) {
|
|
669
|
+
navigator.clipboard.writeText(sid).then(done).catch(() => { copyToast = '⚠ copy failed'; render(); setTimeout(() => { copyToast = null; render(); }, 1500); });
|
|
670
|
+
} else {
|
|
671
|
+
// Fallback for insecure (http) origins where navigator.clipboard is absent.
|
|
672
|
+
try {
|
|
673
|
+
const ta = document.createElement('textarea');
|
|
674
|
+
ta.value = sid; ta.style.position = 'fixed'; ta.style.opacity = '0';
|
|
675
|
+
document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove();
|
|
676
|
+
done();
|
|
677
|
+
} catch { copyToast = '⚠ copy failed'; render(); setTimeout(() => { copyToast = null; render(); }, 1500); }
|
|
678
|
+
}
|
|
506
679
|
}
|
|
507
680
|
|
|
508
681
|
function resumeInChat(sess) {
|
|
@@ -511,7 +684,13 @@ function resumeInChat(sess) {
|
|
|
511
684
|
state.chat.resumeSid = sess?.sid || state.selectedSid;
|
|
512
685
|
state.chat.messages = [];
|
|
513
686
|
state.chat.draft = '';
|
|
514
|
-
// Only claude-code supports --resume by sid
|
|
687
|
+
// Only claude-code supports --resume by sid; warn if we have to switch the
|
|
688
|
+
// user's selected agent rather than silently discarding it.
|
|
689
|
+
if (state.selectedAgent && state.selectedAgent !== 'claude-code') {
|
|
690
|
+
state.chat.resumeNote = 'Switched to Claude Code — only it supports resuming a session by id.';
|
|
691
|
+
} else {
|
|
692
|
+
state.chat.resumeNote = null;
|
|
693
|
+
}
|
|
515
694
|
if (state.selectedAgent !== 'claude-code') selectAgent('claude-code');
|
|
516
695
|
render();
|
|
517
696
|
}
|
|
@@ -526,6 +705,16 @@ function visibleSessions() {
|
|
|
526
705
|
return filtered.slice().sort((a, b) => (b.last || 0) - (a.last || 0));
|
|
527
706
|
}
|
|
528
707
|
|
|
708
|
+
// ccsniff derives `project` from the ~/.claude/projects dir name, which encodes
|
|
709
|
+
// the cwd as a dash-joined path (e.g. "-config-workspace-agentgui"). Show the
|
|
710
|
+
// last meaningful segment ("agentgui") rather than the raw slug.
|
|
711
|
+
function projectLabel(project) {
|
|
712
|
+
if (!project) return '';
|
|
713
|
+
if (/[/\\]/.test(project)) return project.split(/[/\\]/).filter(Boolean).pop() || project;
|
|
714
|
+
const segs = project.split('-').filter(Boolean);
|
|
715
|
+
return segs.length ? segs[segs.length - 1] : project;
|
|
716
|
+
}
|
|
717
|
+
|
|
529
718
|
function uniqueProjects() {
|
|
530
719
|
const arr = Array.isArray(state.sessions) ? state.sessions : [];
|
|
531
720
|
const seen = new Map();
|
|
@@ -557,7 +746,7 @@ function historySide() {
|
|
|
557
746
|
Row({
|
|
558
747
|
key: 'sess' + s.sid,
|
|
559
748
|
rank: String(i + 1).padStart(3, '0'),
|
|
560
|
-
title: (s.isSubagent ? '↳ ' : '') + (s.title || s.project || s.sid),
|
|
749
|
+
title: (s.isSubagent ? '↳ ' : '') + (projectLabel(s.title) || projectLabel(s.project) || s.sid),
|
|
561
750
|
sub: fmtRelTime(s.last) + ' · ' + (s.events || 0) + ' ev · ' + (s.tools || 0) + ' tools' + (s.errors ? ' · ' + s.errors + ' err' : ''),
|
|
562
751
|
rail: s.errors ? 'flame' : (s.isSubagent ? 'purple' : 'green'),
|
|
563
752
|
active: s.sid === state.selectedSid,
|
|
@@ -577,7 +766,7 @@ function historySide() {
|
|
|
577
766
|
const agentName = agentById(r.agentId)?.name || r.agentId || 'agent';
|
|
578
767
|
const elapsed = r.startedAt ? Math.round((Date.now() - r.startedAt) / 1000) : 0;
|
|
579
768
|
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(
|
|
769
|
+
h('span', { class: 'lede' }, '● ' + agentName + (r.model ? ' · ' + r.model : '') + ' · ' + elapsed + 's' + (r.cwd ? ' · ' + r.cwd.split(/[/\\]/).filter(Boolean).slice(-1)[0] : '')),
|
|
581
770
|
Btn({ key: 'stop' + r.sessionId, onClick: () => stopActiveChat(r.sessionId), children: '◼ stop' }));
|
|
582
771
|
}),
|
|
583
772
|
})
|
|
@@ -603,14 +792,17 @@ function historySide() {
|
|
|
603
792
|
searching && !state.searchBusy && !state.searchHits.error && (state.searchHits.results || []).length === 0
|
|
604
793
|
? h('p', { key: 'nomatch', class: 'lede empty-state' }, 'no matches for "' + state.searchQ + '"')
|
|
605
794
|
: null,
|
|
606
|
-
state.searchQ
|
|
795
|
+
state.searchQ.trim().length === 1
|
|
796
|
+
? h('p', { key: 'min2', class: 'lede empty-state' }, 'type at least 2 characters to search')
|
|
797
|
+
: null,
|
|
798
|
+
state.searchQ
|
|
607
799
|
? Btn({ key: 'clearq', onClick: () => { state.searchQ = ''; state.searchHits = null; state.searchBusy = false; render(); }, children: '× clear search' })
|
|
608
800
|
: null,
|
|
609
801
|
!searching && projects.length > 1
|
|
610
802
|
? h('div', { key: 'projfilter', class: 'pill-row', role: 'group', 'aria-label': 'Filter sessions by project' },
|
|
611
803
|
pillButton('allp', 'all', !state.projectFilter, 'Show all projects', () => { state.projectFilter = ''; render(); }),
|
|
612
804
|
...projects.slice(0, 8).map(([name, count]) =>
|
|
613
|
-
pillButton('p'+name, truncate(name, 14, 20) + ' (' + count + ')', state.projectFilter === name, name, () => { state.projectFilter = state.projectFilter === name ? '' : name; render(); })))
|
|
805
|
+
pillButton('p'+name, truncate(projectLabel(name), 14, 20) + ' (' + count + ')', state.projectFilter === name, name, () => { state.projectFilter = state.projectFilter === name ? '' : name; render(); })))
|
|
614
806
|
: null,
|
|
615
807
|
!searching && subagentCount
|
|
616
808
|
? h('label', { key: 'subtog', class: 'lede subagent-toggle' },
|
|
@@ -669,6 +861,7 @@ function settingsMain() {
|
|
|
669
861
|
title: '⌘ settings',
|
|
670
862
|
lede: 'point agentgui at any backend. blank = same-origin (ccsniff in-process). ?backend=… or the field below persists via localStorage.',
|
|
671
863
|
}),
|
|
864
|
+
h('div', { key: 'settings-grid', class: 'settings-grid' }, [
|
|
672
865
|
Panel({
|
|
673
866
|
title: 'backend',
|
|
674
867
|
children: h('form', {
|
|
@@ -702,6 +895,7 @@ function settingsMain() {
|
|
|
702
895
|
]),
|
|
703
896
|
}),
|
|
704
897
|
agentsPanel(),
|
|
898
|
+
]),
|
|
705
899
|
];
|
|
706
900
|
}
|
|
707
901
|
|
|
@@ -749,6 +943,7 @@ async function refreshHistory() {
|
|
|
749
943
|
render();
|
|
750
944
|
}
|
|
751
945
|
}
|
|
946
|
+
const debouncedRefreshHistory = debounce(refreshHistory, 500);
|
|
752
947
|
|
|
753
948
|
async function runSearch() {
|
|
754
949
|
const q = state.searchQ.trim();
|
|
@@ -757,7 +952,7 @@ async function runSearch() {
|
|
|
757
952
|
state.searchBusy = true;
|
|
758
953
|
render();
|
|
759
954
|
try {
|
|
760
|
-
state.searchHits = await B.searchHistory(state.backend, q,
|
|
955
|
+
state.searchHits = await B.searchHistory(state.backend, q, 60);
|
|
761
956
|
} catch (e) {
|
|
762
957
|
state.searchHits = { query: q, results: [], error: e.message };
|
|
763
958
|
} finally {
|
|
@@ -768,18 +963,26 @@ async function runSearch() {
|
|
|
768
963
|
const debouncedSearch = debounce(runSearch, 300);
|
|
769
964
|
|
|
770
965
|
async function loadSession(sid) {
|
|
966
|
+
// Guard against a bad sid from a malformed hash (e.g. "?sid=undefined").
|
|
967
|
+
if (!sid || sid === 'undefined' || sid === 'null') { state.selectedSid = null; render(); return; }
|
|
771
968
|
state.selectedSid = sid;
|
|
772
969
|
state.events = [];
|
|
773
|
-
|
|
970
|
+
state.eventsLoaded = false;
|
|
971
|
+
state.expandedEvents = new Set(); // don't carry expansion to the new session
|
|
972
|
+
writeHash(sid, { push: true });
|
|
774
973
|
render();
|
|
775
|
-
try {
|
|
776
|
-
|
|
974
|
+
try {
|
|
975
|
+
state.events = await B.getSessionEvents(state.backend, sid);
|
|
976
|
+
state.eventsLoaded = true;
|
|
977
|
+
render();
|
|
978
|
+
} catch (e) {
|
|
777
979
|
state.events = [{
|
|
778
980
|
ts: Date.now(),
|
|
779
981
|
role: 'error',
|
|
780
982
|
type: 'fetch',
|
|
781
983
|
text: 'Failed to load session: ' + e.message + ' — retry via sidebar',
|
|
782
984
|
}];
|
|
985
|
+
state.eventsLoaded = true;
|
|
783
986
|
render();
|
|
784
987
|
}
|
|
785
988
|
}
|
|
@@ -801,13 +1004,26 @@ async function init() {
|
|
|
801
1004
|
render();
|
|
802
1005
|
} catch (e) { console.warn('agents fetch failed:', e.message); }
|
|
803
1006
|
|
|
804
|
-
const initialSid = readHash();
|
|
1007
|
+
const { sid: initialSid, tab: initialTab } = readHash();
|
|
805
1008
|
if (initialSid) {
|
|
806
1009
|
navTo('history');
|
|
807
1010
|
await refreshHistory();
|
|
808
1011
|
await loadSession(initialSid);
|
|
1012
|
+
} else if (initialTab && initialTab !== state.tab) {
|
|
1013
|
+
navTo(initialTab);
|
|
809
1014
|
}
|
|
810
1015
|
|
|
1016
|
+
registerWsStatusOnce();
|
|
1017
|
+
startActivePolling(); // surface running chats on any tab, not just history
|
|
1018
|
+
startRelTimeTick();
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// init() runs both at boot and on every saveBackend(); registering the WS
|
|
1022
|
+
// status listener inside it leaked a listener per save. Register exactly once.
|
|
1023
|
+
let wsStatusRegistered = false;
|
|
1024
|
+
function registerWsStatusOnce() {
|
|
1025
|
+
if (wsStatusRegistered) return;
|
|
1026
|
+
wsStatusRegistered = true;
|
|
811
1027
|
B.onWsStatus?.((s) => {
|
|
812
1028
|
if (s === 'closed' || s === 'error') {
|
|
813
1029
|
if (state.health.status === 'ok') { state.health = { ...state.health, ws: 'reconnecting' }; render(); }
|
|
@@ -819,6 +1035,20 @@ async function init() {
|
|
|
819
1035
|
|
|
820
1036
|
restoreChat();
|
|
821
1037
|
render = mount(document.getElementById('app'), view);
|
|
1038
|
+
requestAnimationFrame(syncAriaCurrent);
|
|
1039
|
+
|
|
1040
|
+
// Re-render on resize so isNarrow()/truncate() reflect the current width
|
|
1041
|
+
// (they read window.innerWidth only at render time).
|
|
1042
|
+
window.addEventListener('resize', debounce(() => scheduleRender(), 150));
|
|
1043
|
+
|
|
1044
|
+
// Browser Back/forward: re-sync tab + selected session from the hash.
|
|
1045
|
+
window.addEventListener('popstate', () => {
|
|
1046
|
+
const { sid, tab } = readHash();
|
|
1047
|
+
if (sid && sid !== state.selectedSid) { state.tab = 'history'; loadSession(sid); }
|
|
1048
|
+
else if (!sid && tab && tab !== state.tab) navTo(tab);
|
|
1049
|
+
else if (!sid && !tab && state.tab !== 'chat') navTo('chat');
|
|
1050
|
+
});
|
|
1051
|
+
|
|
822
1052
|
window.__agentgui = { state, render };
|
|
823
1053
|
|
|
824
1054
|
// Keyboard shortcuts. 'g' then c/h/s switches tabs; 'n' new chat; '/' focuses
|