agentgui 1.0.945 → 1.0.946
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/package.json +1 -1
- package/site/app/index.html +28 -7
- package/site/app/js/app.js +193 -50
package/package.json
CHANGED
package/site/app/index.html
CHANGED
|
@@ -19,6 +19,18 @@
|
|
|
19
19
|
--agentgui-fg: #e6e6ea;
|
|
20
20
|
--agentgui-accent: #50c878;
|
|
21
21
|
--agentgui-warn: #ff6b6b;
|
|
22
|
+
/* status-dot state colours, brightened for >=3:1 contrast on the dark bg */
|
|
23
|
+
--agentgui-status-connecting: #f0b84a;
|
|
24
|
+
--agentgui-status-offline: #e06b66;
|
|
25
|
+
/* unified muted text fallback (was an inconsistent mix of #888/#999) */
|
|
26
|
+
--agentgui-muted: #8a8f98;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/* visually-hidden but screen-reader-available (aria-live announcer, labels) */
|
|
30
|
+
.sr-only {
|
|
31
|
+
position: absolute !important; width: 1px; height: 1px;
|
|
32
|
+
padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0);
|
|
33
|
+
white-space: nowrap; border: 0;
|
|
22
34
|
}
|
|
23
35
|
html, body {
|
|
24
36
|
margin: 0;
|
|
@@ -80,8 +92,8 @@
|
|
|
80
92
|
background: var(--accent, var(--agentgui-accent, #50c878));
|
|
81
93
|
animation: agentgui-pulse 2s infinite;
|
|
82
94
|
}
|
|
83
|
-
.status-dot.is-connecting .status-dot-disc { background:
|
|
84
|
-
.status-dot.is-offline .status-dot-disc { background:
|
|
95
|
+
.status-dot.is-connecting .status-dot-disc { background: var(--agentgui-status-connecting); }
|
|
96
|
+
.status-dot.is-offline .status-dot-disc { background: var(--agentgui-status-offline); animation: none; }
|
|
85
97
|
@keyframes agentgui-pulse {
|
|
86
98
|
0% { box-shadow: 0 0 0 0 rgba(80, 200, 120, .5); }
|
|
87
99
|
70% { box-shadow: 0 0 0 6px rgba(80, 200, 120, 0); }
|
|
@@ -159,7 +171,7 @@
|
|
|
159
171
|
|
|
160
172
|
/* empty-state: keep it from wrapping awkwardly in the narrow sidebar */
|
|
161
173
|
.empty-state { white-space: normal; }
|
|
162
|
-
p.empty-state { text-align: left; padding: .6em 0; color: var(--fg-3,
|
|
174
|
+
p.empty-state { text-align: left; padding: .6em 0; color: var(--fg-3, var(--agentgui-muted)); }
|
|
163
175
|
|
|
164
176
|
/* generic interactive focus ring */
|
|
165
177
|
button:focus-visible, [tabindex]:focus-visible, a:focus-visible {
|
|
@@ -198,7 +210,7 @@
|
|
|
198
210
|
}
|
|
199
211
|
.cwd-bar-text {
|
|
200
212
|
font-family: var(--ff-mono, ui-monospace, monospace);
|
|
201
|
-
color: var(--fg-3,
|
|
213
|
+
color: var(--fg-3, var(--agentgui-muted)); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 60vw;
|
|
202
214
|
}
|
|
203
215
|
.cwd-bar-btn {
|
|
204
216
|
cursor: pointer; font: inherit; line-height: 1.3;
|
|
@@ -213,9 +225,8 @@
|
|
|
213
225
|
/* History no-session empty state: fill the void with a centered prompt. */
|
|
214
226
|
.history-empty {
|
|
215
227
|
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
|
216
|
-
gap: .4em; text-align: center; min-height: 50vh; color: var(--fg-3,
|
|
228
|
+
gap: .4em; text-align: center; min-height: 50vh; color: var(--fg-3, var(--agentgui-muted));
|
|
217
229
|
}
|
|
218
|
-
.history-empty-glyph { font-size: 3rem; opacity: .25; line-height: 1; }
|
|
219
230
|
.history-empty-title { margin: 0; font-size: 1.05rem; color: var(--fg-2, var(--agentgui-fg)); }
|
|
220
231
|
.history-empty-sub { margin: 0; max-width: 42ch; }
|
|
221
232
|
|
|
@@ -252,10 +263,21 @@
|
|
|
252
263
|
.chat-composer textarea { overflow-y: auto; scrollbar-width: thin; }
|
|
253
264
|
.chat-composer textarea:not(:focus) { overflow-y: hidden; }
|
|
254
265
|
|
|
266
|
+
/* tablet: keep the chat control cluster from wrapping the status dot alone */
|
|
267
|
+
@media (max-width: 900px) {
|
|
268
|
+
.chat-controls select,
|
|
269
|
+
.chat-controls .select,
|
|
270
|
+
.chat-controls [role="combobox"] { min-width: 110px; max-width: 170px; }
|
|
271
|
+
}
|
|
272
|
+
|
|
255
273
|
/* touch targets on small screens */
|
|
256
274
|
@media (max-width: 640px) {
|
|
257
275
|
.pill { min-height: 36px; padding: .4em .8em; }
|
|
276
|
+
.history-actions { gap: .75em; }
|
|
258
277
|
.history-actions .btn, .history-actions button { min-height: 44px; }
|
|
278
|
+
/* cwd path can't eat the whole row beside its label + buttons on a phone */
|
|
279
|
+
.cwd-bar-text { max-width: 42vw; }
|
|
280
|
+
.cwd-bar { gap: .3em; }
|
|
259
281
|
}
|
|
260
282
|
|
|
261
283
|
@media print {
|
|
@@ -270,7 +292,6 @@
|
|
|
270
292
|
</style>
|
|
271
293
|
</head>
|
|
272
294
|
<body>
|
|
273
|
-
<a href="#agentgui-main" class="skip-link">Skip to main content</a>
|
|
274
295
|
<div id="app"></div>
|
|
275
296
|
<script type="module" src="./js/app.js"></script>
|
|
276
297
|
</body>
|
package/site/app/js/app.js
CHANGED
|
@@ -85,6 +85,24 @@ function debounce(fn, ms) {
|
|
|
85
85
|
function lsSet(k, v) { try { localStorage.setItem(k, v); } catch {} }
|
|
86
86
|
function lsRemove(k) { try { localStorage.removeItem(k); } catch {} }
|
|
87
87
|
|
|
88
|
+
// A single visually-hidden aria-live region for transient announcements (tab
|
|
89
|
+
// changes, etc.) so screen-reader users hear context that's otherwise conveyed
|
|
90
|
+
// only by focus movement or color.
|
|
91
|
+
let _announcer = null;
|
|
92
|
+
function announce(msg) {
|
|
93
|
+
if (typeof document === 'undefined') return;
|
|
94
|
+
if (!_announcer) {
|
|
95
|
+
_announcer = document.createElement('div');
|
|
96
|
+
_announcer.setAttribute('aria-live', 'polite');
|
|
97
|
+
_announcer.setAttribute('role', 'status');
|
|
98
|
+
_announcer.className = 'sr-only';
|
|
99
|
+
document.body.appendChild(_announcer);
|
|
100
|
+
}
|
|
101
|
+
// Clear then set on the next frame so repeated identical messages re-announce.
|
|
102
|
+
_announcer.textContent = '';
|
|
103
|
+
requestAnimationFrame(() => { if (_announcer) _announcer.textContent = msg; });
|
|
104
|
+
}
|
|
105
|
+
|
|
88
106
|
function pillButton(key, label, active, title, onClick) {
|
|
89
107
|
return h('button', {
|
|
90
108
|
key,
|
|
@@ -113,15 +131,22 @@ function timeNow() {
|
|
|
113
131
|
}
|
|
114
132
|
|
|
115
133
|
async function selectAgent(id) {
|
|
134
|
+
// Re-selecting the same agent would needlessly refetch models and reset the
|
|
135
|
+
// current model selection — no-op early.
|
|
136
|
+
if (id === state.selectedAgent && state.agentModels.length) return;
|
|
116
137
|
state.selectedAgent = id;
|
|
117
138
|
localStorage.setItem('agentgui.agent', id);
|
|
118
139
|
state.agentModels = [];
|
|
119
140
|
state.selectedModel = '';
|
|
141
|
+
state.modelsLoading = true;
|
|
120
142
|
render();
|
|
121
143
|
const models = await B.listAgentModels(state.backend, id);
|
|
122
144
|
if (state.selectedAgent !== id) return; // changed while loading
|
|
145
|
+
state.modelsLoading = false;
|
|
123
146
|
state.agentModels = models;
|
|
124
147
|
const saved = localStorage.getItem('agentgui.model');
|
|
148
|
+
// Only restore a saved model that the NEW agent actually offers; otherwise
|
|
149
|
+
// fall back to its first model (never carry a stale model from a prior agent).
|
|
125
150
|
state.selectedModel = (saved && models.some(m => m.id === saved)) ? saved : (models[0]?.id || '');
|
|
126
151
|
render();
|
|
127
152
|
}
|
|
@@ -158,6 +183,9 @@ function sortedAgents() {
|
|
|
158
183
|
|
|
159
184
|
function navTo(tab) {
|
|
160
185
|
const prev = state.tab;
|
|
186
|
+
// Leaving chat clears any pending new-chat confirmation so it doesn't linger
|
|
187
|
+
// as a stale banner when the user returns.
|
|
188
|
+
if (prev === 'chat' && tab !== 'chat') state.confirmingNewChat = false;
|
|
161
189
|
state.tab = tab;
|
|
162
190
|
// Live history SSE is only needed on the history tab; active-chat polling
|
|
163
191
|
// runs globally (started at boot) so running chats show on every tab.
|
|
@@ -168,6 +196,7 @@ function navTo(tab) {
|
|
|
168
196
|
closeLiveStream();
|
|
169
197
|
}
|
|
170
198
|
writeHash(state.selectedSid && tab === 'history' ? state.selectedSid : null);
|
|
199
|
+
announce('Now on ' + tab + ' tab');
|
|
171
200
|
render();
|
|
172
201
|
// Move focus into the new region for keyboard/AT users.
|
|
173
202
|
requestAnimationFrame(() => {
|
|
@@ -187,7 +216,7 @@ function navTo(tab) {
|
|
|
187
216
|
});
|
|
188
217
|
}
|
|
189
218
|
|
|
190
|
-
// The DS Topbar derives aria-current from href
|
|
219
|
+
// The DS Topbar derives aria-current from href<->location.hash matching, which
|
|
191
220
|
// drifts from our hash-based active tab (e.g. aria-current lands on "settings"
|
|
192
221
|
// while we're on "chat"). Re-assert aria-current on the actually-active tab.
|
|
193
222
|
function syncAriaCurrent() {
|
|
@@ -308,15 +337,26 @@ function view() {
|
|
|
308
337
|
onNav: (label) => navTo(label),
|
|
309
338
|
});
|
|
310
339
|
|
|
311
|
-
// The
|
|
312
|
-
// tab), so the crumb carries only the status dot on every tab.
|
|
340
|
+
// The crumb's right slot carries the live/connection status dot on every tab.
|
|
313
341
|
const crumbRight = [dot];
|
|
314
342
|
|
|
315
|
-
//
|
|
316
|
-
//
|
|
343
|
+
// Give the crumb contextual content on the left so it isn't a bare bar holding
|
|
344
|
+
// only the dot: on history it names the selected session (or "all sessions"),
|
|
345
|
+
// on chat it names the active agent, on settings it's "configuration".
|
|
346
|
+
let crumbLeaf = '';
|
|
347
|
+
if (state.tab === 'history') {
|
|
348
|
+
const sel = state.selectedSid && (Array.isArray(state.sessions) ? state.sessions : []).find(s => s.sid === state.selectedSid);
|
|
349
|
+
crumbLeaf = state.selectedSid
|
|
350
|
+
? truncate(projectLabel(sel?.title) || projectLabel(sel?.project) || state.selectedSid, 24, 48)
|
|
351
|
+
: 'all sessions';
|
|
352
|
+
} else if (state.tab === 'chat') {
|
|
353
|
+
crumbLeaf = state.selectedAgent ? (agentById(state.selectedAgent)?.name || state.selectedAgent) : 'no agent';
|
|
354
|
+
} else if (state.tab === 'settings') {
|
|
355
|
+
crumbLeaf = 'configuration';
|
|
356
|
+
}
|
|
317
357
|
const crumb = Crumb({
|
|
318
358
|
trail: [],
|
|
319
|
-
leaf:
|
|
359
|
+
leaf: crumbLeaf,
|
|
320
360
|
right: crumbRight,
|
|
321
361
|
});
|
|
322
362
|
|
|
@@ -329,7 +369,7 @@ function view() {
|
|
|
329
369
|
: 'no agent';
|
|
330
370
|
const status = Status({
|
|
331
371
|
left: [state.backend || 'same-origin', ok ? 'live' : 'offline'],
|
|
332
|
-
right: [agentLabel],
|
|
372
|
+
right: [agentLabel, 'press ? for shortcuts'],
|
|
333
373
|
});
|
|
334
374
|
|
|
335
375
|
// The design system now owns the full-height column + inner scroll for
|
|
@@ -342,7 +382,10 @@ function view() {
|
|
|
342
382
|
children: 'g then c/h/s — chat/history/settings · n — new chat · / — focus search/composer · ? — toggle this · Esc — blur field' })
|
|
343
383
|
: null;
|
|
344
384
|
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));
|
|
345
|
-
|
|
385
|
+
// narrow drives the DS main-column class; the history sidebar itself collapses
|
|
386
|
+
// behind the DS .app-side-toggle hamburger (provided by AppShell when a side
|
|
387
|
+
// exists), so mobile users get a togglable session list.
|
|
388
|
+
return AppShell({ topbar, crumb, side, main, status, narrow: isNarrow() });
|
|
346
389
|
}
|
|
347
390
|
|
|
348
391
|
function mainContent() {
|
|
@@ -351,7 +394,7 @@ function mainContent() {
|
|
|
351
394
|
return settingsMain();
|
|
352
395
|
}
|
|
353
396
|
|
|
354
|
-
//
|
|
397
|
+
// --- chat ---
|
|
355
398
|
function canSend() {
|
|
356
399
|
return !!state.selectedAgent && agentAvailable(state.selectedAgent) && !state.chat.busy;
|
|
357
400
|
}
|
|
@@ -389,12 +432,13 @@ function chatMain() {
|
|
|
389
432
|
// above the thread. The kit holds no agentgui-specific banner logic.
|
|
390
433
|
const banners = [];
|
|
391
434
|
if (state.chat.resumeSid) {
|
|
435
|
+
// The agent-switched note (if any) lives inside the resume banner rather
|
|
436
|
+
// than as a separate stacked Alert, so resume context reads as one block.
|
|
392
437
|
banners.push(h('div', { key: 'rb', class: 'resume-banner', role: 'status' },
|
|
393
|
-
h('span', { key: 'rbtxt', class: 'lede' },
|
|
438
|
+
h('span', { key: 'rbtxt', class: 'lede' },
|
|
439
|
+
'resuming session ' + state.chat.resumeSid.slice(0, 8) + '… via --resume'
|
|
440
|
+
+ (state.chat.resumeNote ? ' — ' + state.chat.resumeNote : '')),
|
|
394
441
|
Btn({ key: 'rclr', onClick: () => { state.chat.resumeSid = null; state.chat.resumeNote = null; render(); }, children: 'clear' })));
|
|
395
|
-
if (state.chat.resumeNote) {
|
|
396
|
-
banners.push(Alert({ key: 'rnote', kind: 'info', title: 'Agent switched', children: state.chat.resumeNote }));
|
|
397
|
-
}
|
|
398
442
|
}
|
|
399
443
|
if (state.selectedAgent && !agentAvailable(state.selectedAgent)) {
|
|
400
444
|
banners.push(Alert({ key: 'unavail', kind: 'warn', title: agentName + ' is not installed',
|
|
@@ -407,9 +451,16 @@ function chatMain() {
|
|
|
407
451
|
Btn({ key: 'cnyes', danger: true, onClick: newChat, children: 'clear' }),
|
|
408
452
|
Btn({ key: 'cnno', onClick: () => { state.confirmingNewChat = false; render(); }, children: 'cancel' })] }));
|
|
409
453
|
}
|
|
410
|
-
|
|
454
|
+
if (state.cwdError) {
|
|
455
|
+
banners.push(Alert({ key: 'cwderr', kind: 'warn', title: 'Invalid working directory', children: state.cwdError }));
|
|
456
|
+
}
|
|
457
|
+
const lastMsg = state.chat.messages.length ? state.chat.messages[state.chat.messages.length - 1] : null;
|
|
458
|
+
const lastErr = lastMsg ? lastMsg.error : null;
|
|
411
459
|
if (lastErr && !state.chat.busy) {
|
|
412
|
-
banners.push(Alert({ key: 'chaterr', kind: 'error', title: 'Stream error',
|
|
460
|
+
banners.push(Alert({ key: 'chaterr', kind: 'error', title: 'Stream error',
|
|
461
|
+
children: [
|
|
462
|
+
h('span', { key: 'cetxt' }, lastErr + ' '),
|
|
463
|
+
Btn({ key: 'cedismiss', onClick: () => { if (lastMsg) { delete lastMsg.error; persistChat(); render(); } }, children: 'dismiss' })] }));
|
|
413
464
|
}
|
|
414
465
|
|
|
415
466
|
const placeholder = !state.selectedAgent
|
|
@@ -426,10 +477,13 @@ function chatMain() {
|
|
|
426
477
|
selectedAgent: state.selectedAgent,
|
|
427
478
|
models: state.agentModels,
|
|
428
479
|
selectedModel: state.selectedModel,
|
|
480
|
+
modelsLoading: !!state.modelsLoading,
|
|
429
481
|
messages: state.chat.messages,
|
|
430
482
|
busy: state.chat.busy,
|
|
431
483
|
draft: state.chat.draft,
|
|
432
|
-
status: state.chat.busy
|
|
484
|
+
status: state.chat.busy
|
|
485
|
+
? (state.health.ws === 'reconnecting' ? 'reconnecting…' : 'streaming…')
|
|
486
|
+
: (state.modelsLoading ? 'loading models…' : (state.chat.resumeSid ? 'resume' : 'ready')),
|
|
433
487
|
agentName,
|
|
434
488
|
placeholder,
|
|
435
489
|
canSend: canSend(),
|
|
@@ -451,13 +505,23 @@ function chatMain() {
|
|
|
451
505
|
if (was !== now) render();
|
|
452
506
|
},
|
|
453
507
|
onSend: (v) => { state.chat.draft = v; sendChat(); },
|
|
454
|
-
onCwdEdit: () => { state.cwdEditing = true; state.cwdDraft = state.chatCwd || ''; render(); },
|
|
508
|
+
onCwdEdit: () => { state.cwdEditing = true; state.cwdDraft = state.chatCwd || ''; state.cwdError = null; render(); },
|
|
455
509
|
onCwdSave: () => {
|
|
456
|
-
|
|
510
|
+
const path = (state.cwdDraft ?? '').trim();
|
|
511
|
+
// A relative cwd would resolve against the server process dir, not what
|
|
512
|
+
// the user means — require an absolute path (POSIX /…, UNC \\…, or
|
|
513
|
+
// Windows drive X:\…). Blank is valid (server default).
|
|
514
|
+
if (path && !/^([/\\]|[A-Za-z]:[/\\])/.test(path)) {
|
|
515
|
+
state.cwdError = 'enter an absolute path (e.g. /home/you/proj or C:\\proj) or leave blank';
|
|
516
|
+
render();
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
state.cwdError = null;
|
|
520
|
+
state.chatCwd = path;
|
|
457
521
|
if (state.chatCwd) lsSet('agentgui.cwd', state.chatCwd); else lsRemove('agentgui.cwd');
|
|
458
522
|
state.cwdEditing = false; state.cwdDraft = undefined; render();
|
|
459
523
|
},
|
|
460
|
-
onCwdCancel: () => { state.cwdEditing = false; state.cwdDraft = undefined; render(); },
|
|
524
|
+
onCwdCancel: () => { state.cwdEditing = false; state.cwdDraft = undefined; state.cwdError = null; render(); },
|
|
461
525
|
onCwdClear: () => { state.chatCwd = ''; lsRemove('agentgui.cwd'); render(); },
|
|
462
526
|
onCwdDraft: (v) => { state.cwdDraft = v; },
|
|
463
527
|
}),
|
|
@@ -485,7 +549,12 @@ function newChat() {
|
|
|
485
549
|
render();
|
|
486
550
|
}
|
|
487
551
|
|
|
488
|
-
function cancelChat() {
|
|
552
|
+
function cancelChat() {
|
|
553
|
+
// Clear busy immediately so the composer re-enables and the "stop" button
|
|
554
|
+
// flips back to "new" without waiting for the stream's finally block.
|
|
555
|
+
state.chat.abort?.abort();
|
|
556
|
+
if (state.chat.busy) { state.chat.busy = false; render(); }
|
|
557
|
+
}
|
|
489
558
|
|
|
490
559
|
const CHAT_KEY = 'agentgui.chat';
|
|
491
560
|
function persistChat() {
|
|
@@ -548,7 +617,7 @@ async function sendChat() {
|
|
|
548
617
|
}
|
|
549
618
|
}
|
|
550
619
|
|
|
551
|
-
//
|
|
620
|
+
// --- history ---
|
|
552
621
|
function reconnectAlert() {
|
|
553
622
|
if (!state.live.error) return null;
|
|
554
623
|
return Alert({
|
|
@@ -588,7 +657,7 @@ function historyMain() {
|
|
|
588
657
|
lede,
|
|
589
658
|
});
|
|
590
659
|
|
|
591
|
-
const actions = h('div', { key: 'acts', class: 'history-actions' },
|
|
660
|
+
const actions = h('div', { key: 'acts', class: 'history-actions', role: 'status', 'aria-live': 'polite' },
|
|
592
661
|
Btn({ key: 'resume', primary: true, onClick: () => resumeInChat(sess || { sid: state.selectedSid }), children: 'open in chat' }),
|
|
593
662
|
Btn({ key: 'copy', onClick: copySid, children: copyToast || 'copy sid' }),
|
|
594
663
|
);
|
|
@@ -597,7 +666,9 @@ function historyMain() {
|
|
|
597
666
|
// Distinguish "still loading" from "genuinely empty" so a 0-event session
|
|
598
667
|
// doesn't spin forever.
|
|
599
668
|
const body = state.eventsLoaded
|
|
600
|
-
? h('
|
|
669
|
+
? h('div', { key: 'noev', class: 'lede empty-state', role: 'status' },
|
|
670
|
+
h('span', { key: 'noevtxt' }, 'no events in this session — '),
|
|
671
|
+
Btn({ key: 'reload', onClick: () => loadSession(state.selectedSid), children: 'reload' }))
|
|
601
672
|
: h('div', { key: 'loading', class: 'lede empty-state', role: 'status', 'aria-live': 'polite' }, Spinner({ key: 'spin', size: 'sm' }), 'loading events…');
|
|
602
673
|
return [reconnectAlert(), head, actions, Panel({ title: 'events', children: body })].filter(Boolean);
|
|
603
674
|
}
|
|
@@ -611,13 +682,17 @@ function historyMain() {
|
|
|
611
682
|
head,
|
|
612
683
|
actions,
|
|
613
684
|
Panel({
|
|
614
|
-
title: total + ' events' + (hiddenCount > 0 ? ' (showing last 300)' : ''),
|
|
685
|
+
title: total + ' events' + (hiddenCount > 0 ? ' (showing last 300; ' + hiddenCount + ' older not rendered)' : ''),
|
|
615
686
|
children: EventList({
|
|
616
687
|
items: shown.map((e, i) => {
|
|
617
688
|
// Stable key: prefer the server-assigned event index, else the
|
|
618
689
|
// event timestamp + position, never a bare array index (which
|
|
619
690
|
// collides between loaded and live-pushed events).
|
|
620
|
-
|
|
691
|
+
// Stable key: server event index when present, else ts + the event's
|
|
692
|
+
// ABSOLUTE position in state.events (not the sliced-view index, which
|
|
693
|
+
// shifts when live events append and would collide loaded vs live rows).
|
|
694
|
+
const absIdx = total - shown.length + i;
|
|
695
|
+
const key = e.i != null ? 'ev' + e.i : 'ev-' + (e.ts || 0) + '-' + (e.type || '') + '-' + absIdx;
|
|
621
696
|
const role = e.role || '?';
|
|
622
697
|
const type = e.type || '?';
|
|
623
698
|
const tool = e.tool ? ' · tool: ' + e.tool : '';
|
|
@@ -628,9 +703,12 @@ function historyMain() {
|
|
|
628
703
|
const full = e.toolInput ? (text + '\n\n' + JSON.stringify(e.toolInput, null, 2)) : raw;
|
|
629
704
|
return {
|
|
630
705
|
key,
|
|
631
|
-
code: String(
|
|
706
|
+
code: String(absIdx + 1).padStart(4, '0'),
|
|
632
707
|
title: expanded ? (full || '(' + type + ')') : (text.slice(0, 220) || '(' + type + ')'),
|
|
633
|
-
|
|
708
|
+
// Guard ts: a missing/zero timestamp renders "Invalid Date" otherwise.
|
|
709
|
+
// Every row is click-to-expand, so always show the affordance word
|
|
710
|
+
// (not only when text overflows 220 chars).
|
|
711
|
+
sub: (e.ts ? new Date(e.ts).toLocaleString() : 'no time') + ' · ' + role + ' · ' + type + tool + errMark + ' · ' + (expanded ? 'collapse' : 'expand'),
|
|
634
712
|
onClick: () => { expanded ? state.expandedEvents.delete(key) : state.expandedEvents.add(key); render(); },
|
|
635
713
|
};
|
|
636
714
|
}),
|
|
@@ -640,20 +718,22 @@ function historyMain() {
|
|
|
640
718
|
}
|
|
641
719
|
|
|
642
720
|
let copyToast = null;
|
|
721
|
+
// Hold the toast long enough to read (2.5s); the copy button label is inside a
|
|
722
|
+
// role=status region (history-actions) so AT announces the change.
|
|
723
|
+
function setCopyToast(msg) { copyToast = msg; render(); setTimeout(() => { copyToast = null; render(); }, 2500); }
|
|
643
724
|
function copySid() {
|
|
644
725
|
const sid = state.selectedSid;
|
|
645
726
|
if (!sid) return;
|
|
646
|
-
const done = () => { copyToast = 'copied'; render(); setTimeout(() => { copyToast = null; render(); }, 1500); };
|
|
647
727
|
if (navigator.clipboard?.writeText) {
|
|
648
|
-
navigator.clipboard.writeText(sid).then(
|
|
728
|
+
navigator.clipboard.writeText(sid).then(() => setCopyToast('copied')).catch(() => setCopyToast('copy failed'));
|
|
649
729
|
} else {
|
|
650
730
|
// Fallback for insecure (http) origins where navigator.clipboard is absent.
|
|
651
731
|
try {
|
|
652
732
|
const ta = document.createElement('textarea');
|
|
653
733
|
ta.value = sid; ta.style.position = 'fixed'; ta.style.opacity = '0';
|
|
654
734
|
document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove();
|
|
655
|
-
|
|
656
|
-
} catch {
|
|
735
|
+
setCopyToast('copied');
|
|
736
|
+
} catch { setCopyToast('copy failed'); }
|
|
657
737
|
}
|
|
658
738
|
}
|
|
659
739
|
|
|
@@ -713,11 +793,14 @@ function historySide() {
|
|
|
713
793
|
const rows = searching
|
|
714
794
|
? visible.map((r, i) =>
|
|
715
795
|
Row({
|
|
716
|
-
key
|
|
796
|
+
// sid can repeat across hits, so key on sid+position; rank is the
|
|
797
|
+
// absolute result position (stable across the 60-row slice).
|
|
798
|
+
key: 'sr-' + (r.sid || '?') + '-' + i,
|
|
717
799
|
rank: String(i + 1).padStart(3, '0'),
|
|
718
800
|
title: r.snippet || '(no snippet)',
|
|
719
801
|
sub: (r.project || '?') + ' · ' + (r.role || '?') + (r.tool ? ' · ' + r.tool : ''),
|
|
720
|
-
|
|
802
|
+
// Rail carries the same semantics as session rows: error > subagent > normal.
|
|
803
|
+
rail: r.isError ? 'flame' : (r.isSubagent ? 'purple' : 'green'),
|
|
721
804
|
onClick: () => loadSession(r.sid),
|
|
722
805
|
})
|
|
723
806
|
)
|
|
@@ -725,8 +808,10 @@ function historySide() {
|
|
|
725
808
|
Row({
|
|
726
809
|
key: 'sess' + s.sid,
|
|
727
810
|
rank: String(i + 1).padStart(3, '0'),
|
|
728
|
-
|
|
729
|
-
|
|
811
|
+
// Subagent is conveyed by the purple rail, not a "- " text prefix.
|
|
812
|
+
title: projectLabel(s.title) || projectLabel(s.project) || s.sid,
|
|
813
|
+
// Always show the error count so 0 reads as "no errors", not "untracked".
|
|
814
|
+
sub: fmtRelTime(s.last) + ' · ' + (s.events || 0) + ' ev · ' + (s.tools || 0) + ' tools · ' + (s.errors || 0) + ' err',
|
|
730
815
|
rail: s.errors ? 'flame' : (s.isSubagent ? 'purple' : 'green'),
|
|
731
816
|
active: s.sid === state.selectedSid,
|
|
732
817
|
onClick: () => loadSession(s.sid),
|
|
@@ -758,8 +843,8 @@ function historySide() {
|
|
|
758
843
|
children: [
|
|
759
844
|
SearchInput({
|
|
760
845
|
key: 'searchInput',
|
|
761
|
-
placeholder: 'search
|
|
762
|
-
'aria-label': 'Search
|
|
846
|
+
placeholder: 'search event text…',
|
|
847
|
+
'aria-label': 'Search event text across sessions',
|
|
763
848
|
value: state.searchQ,
|
|
764
849
|
onInput: (v) => { state.searchQ = v; debouncedSearch(); },
|
|
765
850
|
}),
|
|
@@ -787,7 +872,7 @@ function historySide() {
|
|
|
787
872
|
!searching && subagentCount
|
|
788
873
|
? h('label', { key: 'subtog', class: 'lede subagent-toggle' },
|
|
789
874
|
h('input', { type: 'checkbox', checked: state.showSubagents, onChange: (e) => { state.showSubagents = e.target.checked; render(); } }),
|
|
790
|
-
'show subagents (' + subagentCount + ')')
|
|
875
|
+
'show subagents (' + (state.showSubagents ? subagentCount + ' shown' : subagentCount + ' hidden') + ')')
|
|
791
876
|
: null,
|
|
792
877
|
state.historyError
|
|
793
878
|
? h('p', { key: 'err', class: 'lede field-error', role: 'alert' }, state.historyError)
|
|
@@ -795,12 +880,17 @@ function historySide() {
|
|
|
795
880
|
!searching && truncatedBy > 0
|
|
796
881
|
? Btn({ key: 'more', onClick: () => { state.sessionsLimit += 60; render(); }, children: 'show '+Math.min(60, truncatedBy)+' more ('+truncatedBy+' hidden)' })
|
|
797
882
|
: null,
|
|
883
|
+
// Search is server-capped at 60 hits; there is no deeper page, so tell
|
|
884
|
+
// the user the result set is truncated rather than silently hiding it.
|
|
885
|
+
searching && truncatedBy > 0
|
|
886
|
+
? h('p', { key: 'searchmore', class: 'lede empty-state' }, 'showing first 60 matches (' + truncatedBy + ' more — refine your search)')
|
|
887
|
+
: null,
|
|
798
888
|
],
|
|
799
889
|
}),
|
|
800
890
|
].filter(Boolean);
|
|
801
891
|
}
|
|
802
892
|
|
|
803
|
-
//
|
|
893
|
+
// --- settings ---
|
|
804
894
|
function isValidUrl(s) {
|
|
805
895
|
if (!s) return true; // blank = same-origin is valid
|
|
806
896
|
try { new URL(s.startsWith('http') ? s : 'http://' + s); return true; }
|
|
@@ -809,6 +899,12 @@ function isValidUrl(s) {
|
|
|
809
899
|
|
|
810
900
|
async function saveBackend() {
|
|
811
901
|
if (!isValidUrl(state.backendDraft) || state.backendDraft === state.backend) return;
|
|
902
|
+
// Switching backend orphans the local chat transcript (it belongs to the old
|
|
903
|
+
// server's sessions). Confirm once if there's a transcript to lose.
|
|
904
|
+
if (state.chat.messages.length && !state.confirmingBackend) {
|
|
905
|
+
state.confirmingBackend = true; render(); return;
|
|
906
|
+
}
|
|
907
|
+
state.confirmingBackend = false;
|
|
812
908
|
B.setBackend(state.backendDraft);
|
|
813
909
|
state.backend = state.backendDraft;
|
|
814
910
|
state.health = { status: 'unknown' };
|
|
@@ -822,14 +918,15 @@ async function saveBackend() {
|
|
|
822
918
|
function healthSummary() {
|
|
823
919
|
const hh = state.health || {};
|
|
824
920
|
const ok = hh.status === 'ok';
|
|
921
|
+
// Each chip carries a title so its meaning isn't left to inference.
|
|
825
922
|
const bits = [];
|
|
826
|
-
bits.push(hh.status || 'unknown');
|
|
827
|
-
if (hh.version) bits.push('v' + hh.version);
|
|
828
|
-
if (typeof hh.agents === 'number') bits.push(hh.agents + ' agents');
|
|
829
|
-
if (typeof hh.activeExecutions === 'number') bits.push(hh.activeExecutions + ' active');
|
|
830
|
-
if (hh.db) bits.push('db ' + (hh.db.ok ? 'ok' : 'down'));
|
|
831
|
-
return h('div', { key: 'hp', class: 'health-summary' + (ok ? ' health-ok' : '') },
|
|
832
|
-
...bits.map((b, i) => h('span', { key: 'hb' + i, class: 'health-chip' }, b)));
|
|
923
|
+
bits.push([hh.status || 'unknown', 'Backend connection status']);
|
|
924
|
+
if (hh.version) bits.push(['v' + hh.version, 'Server version']);
|
|
925
|
+
if (typeof hh.agents === 'number') bits.push([hh.agents + ' agents', 'Agents registered on the server']);
|
|
926
|
+
if (typeof hh.activeExecutions === 'number') bits.push([hh.activeExecutions + ' active', 'Chats currently executing']);
|
|
927
|
+
if (hh.db) bits.push(['db ' + (hh.db.ok ? 'ok' : 'down'), 'History database status']);
|
|
928
|
+
return h('div', { key: 'hp', class: 'health-summary' + (ok ? ' health-ok' : ''), role: 'group', 'aria-label': 'Backend health' },
|
|
929
|
+
...bits.map(([b, t], i) => h('span', { key: 'hb' + i, class: 'health-chip', title: t }, b)));
|
|
833
930
|
}
|
|
834
931
|
|
|
835
932
|
function settingsMain() {
|
|
@@ -861,6 +958,7 @@ function settingsMain() {
|
|
|
861
958
|
state.backendStatus === 'connecting' ? h('p', { key: 'bst', class: 'lede', role: 'status' }, 'connecting…') : null,
|
|
862
959
|
state.backendStatus === 'ok' ? h('p', { key: 'bst', class: 'lede', role: 'status' }, 'connected') : null,
|
|
863
960
|
state.backendStatus === 'failed' ? h('p', { key: 'bst', class: 'lede field-error', role: 'alert' }, 'connection failed — check the URL') : null,
|
|
961
|
+
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,
|
|
864
962
|
healthSummary(),
|
|
865
963
|
Btn({
|
|
866
964
|
key: 'savebtn',
|
|
@@ -874,10 +972,40 @@ function settingsMain() {
|
|
|
874
972
|
]),
|
|
875
973
|
}),
|
|
876
974
|
agentsPanel(),
|
|
975
|
+
preferencesPanel(),
|
|
877
976
|
]),
|
|
878
977
|
];
|
|
879
978
|
}
|
|
880
979
|
|
|
980
|
+
function clearLocalData() {
|
|
981
|
+
if (!state.confirmingClearData) { state.confirmingClearData = true; render(); return; }
|
|
982
|
+
state.confirmingClearData = false;
|
|
983
|
+
// Wipe agentgui's own localStorage keys (chat transcript, agent/model/cwd,
|
|
984
|
+
// backend). Leaves the page reload to re-init from defaults.
|
|
985
|
+
for (const k of ['agentgui.chat', 'agentgui.agent', 'agentgui.model', 'agentgui.cwd', 'agentgui.backend']) lsRemove(k);
|
|
986
|
+
state.chat = { messages: [], busy: false, abort: null, draft: '', resumeSid: null };
|
|
987
|
+
render();
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
function preferencesPanel() {
|
|
991
|
+
const hh = state.health || {};
|
|
992
|
+
return Panel({
|
|
993
|
+
title: 'preferences',
|
|
994
|
+
children: [
|
|
995
|
+
h('div', { key: 'ver', class: 'lede' }, 'server ' + (hh.version ? 'v' + hh.version : 'version unknown') + (window.__SERVER_VERSION ? ' · build ' + window.__SERVER_VERSION : '')),
|
|
996
|
+
h('div', { key: 'sc', class: 'lede', style: 'margin:.5em 0' },
|
|
997
|
+
'keyboard: g then c/h/s — switch tabs · n — new chat · / — focus search/composer · ? — toggle hint · Esc — blur field'),
|
|
998
|
+
state.confirmingClearData
|
|
999
|
+
? Alert({ key: 'cld', kind: 'warn', title: 'Clear all local data?',
|
|
1000
|
+
children: [
|
|
1001
|
+
h('span', { key: 'cldtxt' }, 'Removes saved chat, agent/model/cwd, and backend from this browser. This cannot be undone. '),
|
|
1002
|
+
Btn({ key: 'cldyes', danger: true, onClick: clearLocalData, children: 'clear' }),
|
|
1003
|
+
Btn({ key: 'cldno', onClick: () => { state.confirmingClearData = false; render(); }, children: 'cancel' })] })
|
|
1004
|
+
: Btn({ key: 'cldbtn', onClick: clearLocalData, children: 'clear local data' }),
|
|
1005
|
+
],
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
|
|
881
1009
|
function acpStatusFor(agentId) {
|
|
882
1010
|
const acp = Array.isArray(state.health.acp) ? state.health.acp : [];
|
|
883
1011
|
return acp.find(a => a.id === agentId) || null;
|
|
@@ -891,6 +1019,7 @@ function agentsPanel() {
|
|
|
891
1019
|
? state.agents.map((a, i) => {
|
|
892
1020
|
const acp = acpStatusFor(a.id);
|
|
893
1021
|
const avail = a.available !== false;
|
|
1022
|
+
const usable = avail || a.npxInstallable; // selectable from this row
|
|
894
1023
|
const bits = [a.protocol || 'agent'];
|
|
895
1024
|
if (!avail) bits.push(a.npxInstallable ? 'runs via npx' : 'not installed');
|
|
896
1025
|
if (acp) bits.push(acp.healthy ? 'running·healthy' : (acp.running ? 'running' : 'stopped'));
|
|
@@ -899,18 +1028,21 @@ function agentsPanel() {
|
|
|
899
1028
|
return Row({
|
|
900
1029
|
key: 'ag' + a.id,
|
|
901
1030
|
rank: String(i + 1).padStart(3, '0'),
|
|
902
|
-
title: a.name
|
|
1031
|
+
title: a.name,
|
|
903
1032
|
sub: bits.join(' · '),
|
|
904
1033
|
rail: a.id === state.selectedAgent ? 'green' : (avail ? 'purple' : 'flame'),
|
|
905
1034
|
active: a.id === state.selectedAgent,
|
|
906
|
-
|
|
1035
|
+
// Non-installable agents are genuinely inert: mark them disabled (no
|
|
1036
|
+
// click, no button role) instead of looking clickable but doing nothing.
|
|
1037
|
+
state: usable ? 'default' : 'disabled',
|
|
1038
|
+
onClick: usable ? () => { navTo('chat'); selectAgent(a.id); } : undefined,
|
|
907
1039
|
});
|
|
908
1040
|
})
|
|
909
1041
|
: h('p', { key: 'none', class: 'lede' }, 'no agents loaded'),
|
|
910
1042
|
});
|
|
911
1043
|
}
|
|
912
1044
|
|
|
913
|
-
//
|
|
1045
|
+
// --- data ---
|
|
914
1046
|
async function refreshHistory() {
|
|
915
1047
|
try {
|
|
916
1048
|
state.sessions = await B.listSessions(state.backend);
|
|
@@ -950,6 +1082,11 @@ async function loadSession(sid) {
|
|
|
950
1082
|
state.expandedEvents = new Set(); // don't carry expansion to the new session
|
|
951
1083
|
writeHash(sid, { push: true });
|
|
952
1084
|
render();
|
|
1085
|
+
// Bring the now-active sidebar row into view (deep-link / back-forward may
|
|
1086
|
+
// select a row that's scrolled out of the session list).
|
|
1087
|
+
requestAnimationFrame(() => {
|
|
1088
|
+
document.querySelector('.app-side .row.active')?.scrollIntoView({ block: 'nearest' });
|
|
1089
|
+
});
|
|
953
1090
|
try {
|
|
954
1091
|
state.events = await B.getSessionEvents(state.backend, sid);
|
|
955
1092
|
state.eventsLoaded = true;
|
|
@@ -1058,7 +1195,13 @@ window.addEventListener('keydown', (e) => {
|
|
|
1058
1195
|
}
|
|
1059
1196
|
if (e.key === 'g') { gPending = true; setTimeout(() => { gPending = false; }, 1000); return; }
|
|
1060
1197
|
if (e.key === 'n' && state.tab === 'chat') { e.preventDefault(); newChat(); return; }
|
|
1061
|
-
if (e.key === '/') {
|
|
1198
|
+
if (e.key === '/') {
|
|
1199
|
+
// / focuses search on history, composer on chat; on settings there is no
|
|
1200
|
+
// such field, so ignore it cleanly rather than focusing nothing.
|
|
1201
|
+
if (state.tab === 'history') { e.preventDefault(); focusSearch(); announce('search focused'); }
|
|
1202
|
+
else if (state.tab === 'chat') { e.preventDefault(); focusComposer(); announce('composer focused'); }
|
|
1203
|
+
return;
|
|
1204
|
+
}
|
|
1062
1205
|
if (e.key === '?') { state.showShortcuts = !state.showShortcuts; render(); return; }
|
|
1063
1206
|
});
|
|
1064
1207
|
|