agentgui 1.0.958 → 1.0.960
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/workflows/gui-completion.js +88 -0
- package/.claude/workflows/gui-logic-predictability.js +88 -0
- package/AGENTS.md +20 -0
- package/PUNCHLIST-COMPLETION.md +266 -0
- package/PUNCHLIST-LOGIC.md +405 -0
- package/lib/broadcast.js +1 -0
- package/lib/http-handler.js +196 -16
- package/lib/ws-handlers-util.js +42 -4
- package/package.json +1 -1
- package/scripts/validate-mutations.mjs +40 -0
- package/server.js +27 -0
- package/site/app/js/app.js +1391 -200
- package/site/app/js/backend.js +86 -4
- package/site/app/vendor/anentrypoint-design/247420.css +226 -2
- package/site/app/vendor/anentrypoint-design/247420.js +13 -13
package/site/app/js/app.js
CHANGED
|
@@ -3,7 +3,13 @@ import * as B from './backend.js';
|
|
|
3
3
|
|
|
4
4
|
installStyles().catch(() => {});
|
|
5
5
|
|
|
6
|
-
const { AppShell, WorkspaceShell, WorkspaceRail, Topbar, Crumb, Side, Status, Chat, ChatComposer, AgentChat, ConversationList, SessionDashboard, Row, Panel, PageHeader, SearchInput, TextField, Select, Btn, Icon, EventList, Spinner, Alert, FileGrid, FileSkeleton, sortFiles, FileToolbar, RootsPicker, BreadcrumbPath, EmptyState, FileViewer, FilePreviewPane, FilePreviewCode, FilePreviewText, FilePreviewMedia, ThemeToggle, ContextPane } = C;
|
|
6
|
+
const { AppShell, WorkspaceShell, WorkspaceRail, Topbar, Crumb, Side, Status, Chat, ChatComposer, AgentChat, ConversationList, SessionDashboard, Row, Panel, PageHeader, SearchInput, TextField, Select, Btn, Icon, EventList, Spinner, Alert, FileGrid, FileSkeleton, sortFiles, FileToolbar, RootsPicker, BreadcrumbPath, EmptyState, FileViewer, FilePreviewPane, FilePreviewCode, FilePreviewText, FilePreviewMedia, ThemeToggle, ContextPane, PromptDialog, ConfirmDialog, DropZone, UploadProgress, FilterPills, SessionMeta } = C;
|
|
7
|
+
|
|
8
|
+
// One duration/bytes vocabulary across every surface: prefer the kit's shared
|
|
9
|
+
// formatters (exported alongside the components), fall back to the local
|
|
10
|
+
// equivalents so the app keeps working against an older vendored kit build.
|
|
11
|
+
const fmtDuration = C.fmtDuration || ((ms) => humanizeMs(ms));
|
|
12
|
+
const fmtBytes = C.fmtBytes || ((n) => (Math.round((n || 0) / 102.4) / 10) + ' KB');
|
|
7
13
|
|
|
8
14
|
const state = {
|
|
9
15
|
backend: B.getBackend(),
|
|
@@ -15,7 +21,13 @@ const state = {
|
|
|
15
21
|
agentModels: [],
|
|
16
22
|
selectedModel: lsGet('agentgui.model') || '',
|
|
17
23
|
chatCwd: lsGet('agentgui.cwd') || '',
|
|
18
|
-
chat: { messages: [], busy: false, abort: null, draft: '', resumeSid: null },
|
|
24
|
+
chat: { messages: [], busy: false, abort: null, draft: '', resumeSid: null, confirmingEdit: null, totalCost: 0 },
|
|
25
|
+
agentsError: null,
|
|
26
|
+
settingsSection: null,
|
|
27
|
+
eventFilter: 'all', // history event-type filter: all | text | tool | errors
|
|
28
|
+
sessionSearchQ: null, // the query the selected session was opened from (search-hit highlight)
|
|
29
|
+
historySlow: false, // first history fetch unresolved after 5s -> indexing copy
|
|
30
|
+
eventsSlow: false, // first events fetch unresolved after 5s -> indexing copy
|
|
19
31
|
sessions: [],
|
|
20
32
|
selectedSid: null,
|
|
21
33
|
events: [],
|
|
@@ -25,39 +37,62 @@ const state = {
|
|
|
25
37
|
showSubagents: false,
|
|
26
38
|
sessionsLimit: 60,
|
|
27
39
|
projectFilter: '',
|
|
28
|
-
live: { es: null, connected: false, lastEventTs: 0, error: null, eventCount: 0, reconnects: 0 },
|
|
40
|
+
live: { es: null, connected: false, lastEventTs: 0, error: null, eventCount: 0, reconnects: 0, stopping: new Set(), clockSkew: null },
|
|
29
41
|
active: [],
|
|
30
42
|
activeTimer: null,
|
|
31
43
|
eventsLimit: 300, // how many of the most-recent events to render; grows via "load older"
|
|
32
44
|
files: { path: '', segments: [], entries: [], roots: [], loading: false, error: null, preview: null, sort: 'name', sortDir: 'asc', filter: '' },
|
|
33
45
|
};
|
|
34
46
|
|
|
47
|
+
// Full routable param set. Every view-defining piece of state round-trips
|
|
48
|
+
// through the hash so reload and Back/forward restore the exact view.
|
|
49
|
+
const HASH_KEYS = ['tab', 'sid', 'dir', 'file', 'q', 'project', 'section'];
|
|
35
50
|
function readHash() {
|
|
36
51
|
const hash = location.hash || '';
|
|
37
|
-
const
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
52
|
+
const out = {};
|
|
53
|
+
for (const k of HASH_KEYS) {
|
|
54
|
+
const m = hash.match(new RegExp('(?:^#|&)' + k + '=([^&]*)'));
|
|
55
|
+
out[k] = m ? decodeURIComponent(m[1]) : null;
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
43
58
|
}
|
|
44
|
-
function buildHash(
|
|
59
|
+
function buildHash() {
|
|
45
60
|
const parts = [];
|
|
46
|
-
|
|
47
|
-
|
|
61
|
+
const tab = state.tab || 'chat';
|
|
62
|
+
// tab is omitted for the default chat tab, EXCEPT when a session id rides
|
|
63
|
+
// along - a bare #sid= historically meant history, so chat+sid must name its
|
|
64
|
+
// tab explicitly or the deep-link restores the wrong surface.
|
|
65
|
+
if (tab !== 'chat') parts.push('tab=' + encodeURIComponent(tab));
|
|
66
|
+
else if (state.selectedSid) parts.push('tab=chat');
|
|
67
|
+
// Keep sid whenever set (regardless of tab) so Back restores the selection.
|
|
68
|
+
if (state.selectedSid) parts.push('sid=' + encodeURIComponent(state.selectedSid));
|
|
69
|
+
if (tab === 'files' && state.files) {
|
|
70
|
+
if (state.files.path) parts.push('dir=' + encodeURIComponent(state.files.path));
|
|
71
|
+
if (state.files.preview && state.files.preview.path) parts.push('file=' + encodeURIComponent(state.files.preview.path));
|
|
72
|
+
}
|
|
73
|
+
if (tab === 'history') {
|
|
74
|
+
const q = (state.searchQ || '').trim();
|
|
75
|
+
if (q.length >= 2) parts.push('q=' + encodeURIComponent(q));
|
|
76
|
+
if (state.projectFilter) parts.push('project=' + encodeURIComponent(state.projectFilter));
|
|
77
|
+
}
|
|
78
|
+
if (tab === 'settings' && state.settingsSection) parts.push('section=' + encodeURIComponent(state.settingsSection));
|
|
48
79
|
return parts.length ? '#' + parts.join('&') : '';
|
|
49
80
|
}
|
|
50
|
-
function writeHash(
|
|
51
|
-
const h = buildHash(
|
|
81
|
+
function writeHash({ push = false } = {}) {
|
|
82
|
+
const h = buildHash();
|
|
52
83
|
const url = location.pathname + location.search + h;
|
|
53
|
-
if (location.hash === h) return;
|
|
54
|
-
// pushState for
|
|
55
|
-
// replaceState for
|
|
84
|
+
if (location.hash === h || (!location.hash && !h)) return;
|
|
85
|
+
// pushState for user navigation steps (tab visits, session selection,
|
|
86
|
+
// directory walks, preview opens) so Back retraces them; replaceState for
|
|
87
|
+
// passive state sync (search text, filter resets).
|
|
56
88
|
(push ? history.pushState : history.replaceState).call(history, null, '', url);
|
|
57
89
|
}
|
|
58
90
|
function fmtRelTime(ts) {
|
|
59
91
|
if (!ts) return '';
|
|
60
92
|
const s = Math.round((Date.now() - ts) / 1000);
|
|
93
|
+
// Clamp negative spans (server clock ahead of the client) to "just now" -
|
|
94
|
+
// "-12s ago" is clock skew, not information.
|
|
95
|
+
if (s <= 0) return 'just now';
|
|
61
96
|
if (s < 60) return s + 's ago';
|
|
62
97
|
if (s < 3600) return Math.round(s/60) + 'm ago';
|
|
63
98
|
if (s < 86400) return Math.round(s/3600) + 'h ago';
|
|
@@ -78,6 +113,18 @@ function scheduleRender() {
|
|
|
78
113
|
let streamRenderScheduled = false;
|
|
79
114
|
function scheduleStreamRender() {
|
|
80
115
|
if (streamRenderScheduled) return;
|
|
116
|
+
// Don't wipe an active text selection inside the streaming thread: a
|
|
117
|
+
// re-render replaces the live bubble's text nodes every frame, destroying a
|
|
118
|
+
// select-and-copy in progress. The stream's settle path (finally) still
|
|
119
|
+
// renders unconditionally, so nothing is lost - rendering just pauses while
|
|
120
|
+
// the selection is held.
|
|
121
|
+
try {
|
|
122
|
+
const sel = document.getSelection();
|
|
123
|
+
if (sel && !sel.isCollapsed && sel.anchorNode) {
|
|
124
|
+
const node = sel.anchorNode.nodeType === 1 ? sel.anchorNode : sel.anchorNode.parentElement;
|
|
125
|
+
if (node && node.closest && node.closest('.agentchat-thread, .chat-thread')) return;
|
|
126
|
+
}
|
|
127
|
+
} catch {}
|
|
81
128
|
streamRenderScheduled = true;
|
|
82
129
|
requestAnimationFrame(() => {
|
|
83
130
|
streamRenderScheduled = false;
|
|
@@ -206,7 +253,7 @@ function sortedAgents() {
|
|
|
206
253
|
.map(({ a }) => a);
|
|
207
254
|
}
|
|
208
255
|
|
|
209
|
-
function navTo(tab) {
|
|
256
|
+
function navTo(tab, { writeHash: doWriteHash = true, push = true } = {}) {
|
|
210
257
|
const prev = state.tab;
|
|
211
258
|
// Leaving chat clears any pending new-chat confirmation so it doesn't linger
|
|
212
259
|
// as a stale banner when the user returns.
|
|
@@ -215,7 +262,11 @@ function navTo(tab) {
|
|
|
215
262
|
// Live history SSE feeds both the History tab (event log) and the Live
|
|
216
263
|
// dashboard (per-session activity tally + stream-health signal); open it on
|
|
217
264
|
// either, close it when leaving both. Active-chat polling runs globally.
|
|
218
|
-
|
|
265
|
+
// The chat tab keeps the stream too while a chat is busy or sessions are in
|
|
266
|
+
// flight, so the rail's counters/timestamps don't freeze on the very tab
|
|
267
|
+
// where the streaming happens.
|
|
268
|
+
const wantsStream = (t) => t === 'history' || t === 'live'
|
|
269
|
+
|| (t === 'chat' && (state.chat.busy || (Array.isArray(state.active) && state.active.length > 0)));
|
|
219
270
|
if (wantsStream(tab)) {
|
|
220
271
|
if (tab === 'history') refreshHistory();
|
|
221
272
|
openLiveStream();
|
|
@@ -230,7 +281,9 @@ function navTo(tab) {
|
|
|
230
281
|
// filesMain (which runs during render) - a render-time fetch is fragile under
|
|
231
282
|
// a double-render and re-enters render() while building the tree.
|
|
232
283
|
if (tab === 'files' && !state.files.path && !state.files.loading && !state.files.error) loadDir('');
|
|
233
|
-
|
|
284
|
+
// popstate calls navTo with writeHash:false so it never replaceState-clobbers
|
|
285
|
+
// the entry it popped; user-initiated navigation pushes so Back walks tabs.
|
|
286
|
+
if (doWriteHash) writeHash({ push });
|
|
234
287
|
announce('Now on ' + tab + ' tab');
|
|
235
288
|
render();
|
|
236
289
|
// Move focus into the new region for keyboard/AT users.
|
|
@@ -268,13 +321,22 @@ function syncAriaCurrent() {
|
|
|
268
321
|
// re-renders every row on each render). Only re-render when the set actually
|
|
269
322
|
// changed (or while on the live tab, where elapsed advances every second).
|
|
270
323
|
function activeSig(list) {
|
|
271
|
-
|
|
324
|
+
// claudeSessionId arrives mid-turn (streaming_session) - it must be part of
|
|
325
|
+
// the signature so the rail/dashboard re-render when the real sid lands.
|
|
326
|
+
return (Array.isArray(list) ? list : []).map(a => a.sessionId + ':' + (a.claudeSessionId || '') + ':' + (a.model || '') + ':' + (a.startedAt || '')).sort().join('|');
|
|
272
327
|
}
|
|
273
328
|
async function refreshActive() {
|
|
274
329
|
let next;
|
|
275
330
|
try { next = await B.listActiveChats(state.backend); } catch { return; }
|
|
276
331
|
const changed = activeSig(next) !== activeSig(state.active);
|
|
277
332
|
state.active = next;
|
|
333
|
+
// A stopping sid that left the active set has genuinely stopped - clear it
|
|
334
|
+
// so the per-card 'stopping' state resolves.
|
|
335
|
+
const st = state.live.stopping;
|
|
336
|
+
if (st && st.size) {
|
|
337
|
+
const present = new Set(next.map(a => a.sessionId));
|
|
338
|
+
for (const sid of [...st]) if (!present.has(sid)) st.delete(sid);
|
|
339
|
+
}
|
|
278
340
|
// The live tab needs the steady elapsed tick regardless; elsewhere only
|
|
279
341
|
// re-render when the active set genuinely changed.
|
|
280
342
|
if (changed || state.tab === 'live') render();
|
|
@@ -305,8 +367,16 @@ function startLiveTick() {
|
|
|
305
367
|
}, 1000);
|
|
306
368
|
}
|
|
307
369
|
async function stopActiveChat(sid) {
|
|
370
|
+
const st = state.live.stopping = state.live.stopping || new Set();
|
|
371
|
+
if (st.has(sid)) return; // re-entry guard: one cancel per sid in flight
|
|
372
|
+
st.add(sid);
|
|
373
|
+
render();
|
|
308
374
|
try { await B.cancelChat(state.backend, sid); } catch {}
|
|
309
|
-
|
|
375
|
+
// Optimistically drop the row so the card doesn't read 'running' for up to
|
|
376
|
+
// 3s after the click; the immediate refresh confirms.
|
|
377
|
+
state.active = (Array.isArray(state.active) ? state.active : []).filter(a => a.sessionId !== sid);
|
|
378
|
+
await refreshActive();
|
|
379
|
+
render();
|
|
310
380
|
}
|
|
311
381
|
|
|
312
382
|
function openLiveStream() {
|
|
@@ -316,16 +386,27 @@ function openLiveStream() {
|
|
|
316
386
|
try {
|
|
317
387
|
state.live.es = B.streamHistory(state.backend, (kind, data) => {
|
|
318
388
|
state.live.lastEventTs = Date.now();
|
|
319
|
-
|
|
389
|
+
// Only real event frames count - hello/error frames inflated the number.
|
|
390
|
+
if (kind === 'event') state.live.eventCount++;
|
|
320
391
|
if (kind === 'hello') {
|
|
321
392
|
if (!state.live.connected) state.live.connected = true;
|
|
322
|
-
if (state.live.error) {
|
|
393
|
+
if (state.live.error) {
|
|
394
|
+
state.live.error = null; state.live.reconnects++;
|
|
395
|
+
// After a disconnect there is no way to know what was missed or
|
|
396
|
+
// replayed: re-baseline. Drop the tally (the index owns truth) and
|
|
397
|
+
// refetch the session list so counters never double-count a replay.
|
|
398
|
+
if (state.live.tally) state.live.tally.clear();
|
|
399
|
+
debouncedRefreshHistory();
|
|
400
|
+
}
|
|
323
401
|
} else if (kind === 'event' && data) {
|
|
324
402
|
// ccsniff's stream frame is { sid, payload: <flattened event> } - the
|
|
325
403
|
// real event (type/isError/i/ts) lives under .payload, not on the
|
|
326
404
|
// wrapper. Reading the wrapper gave every counter `undefined`, so live
|
|
327
405
|
// tool/error tallies never moved. Route by data.sid, read ev for the rest.
|
|
328
406
|
const ev = data.payload || data;
|
|
407
|
+
// Estimate server-vs-client clock skew once from the first near-realtime
|
|
408
|
+
// event; staleness comparisons apply it so pure skew never reads stale.
|
|
409
|
+
if (state.live.clockSkew == null && ev.ts) state.live.clockSkew = Date.now() - ev.ts;
|
|
329
410
|
if (state.selectedSid && data.sid === state.selectedSid) {
|
|
330
411
|
// Dedupe against the snapshot/prior pushes by event index - a
|
|
331
412
|
// reconnect or overlap would otherwise double-append the same event.
|
|
@@ -341,18 +422,32 @@ function openLiveStream() {
|
|
|
341
422
|
// exists yet.
|
|
342
423
|
if (data.sid) {
|
|
343
424
|
state.live.tally = state.live.tally || new Map();
|
|
344
|
-
const t = state.live.tally.get(data.sid) || { events: 0, tools: 0, errors: 0, last: 0 };
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
425
|
+
const t = state.live.tally.get(data.sid) || { events: 0, tools: 0, errors: 0, last: 0, maxI: null, toolRunning: false, toolName: '', lastErrorTs: 0 };
|
|
426
|
+
// Dedupe by event index: a reconnect replay/overlap re-delivers the
|
|
427
|
+
// same events and would double-count tools/errors otherwise.
|
|
428
|
+
const dup = ev.i != null && t.maxI != null && ev.i <= t.maxI;
|
|
429
|
+
if (!dup) {
|
|
430
|
+
if (ev.i != null) t.maxI = ev.i;
|
|
431
|
+
t.events++; t.last = ev.ts || Date.now();
|
|
432
|
+
// Per-sid running-tool truth: an unresolved tool_use means this
|
|
433
|
+
// session is busy-with-tool, not stalled - for EVERY session, not
|
|
434
|
+
// only the one resumed in the in-page chat.
|
|
435
|
+
if (ev.type === 'tool_use') { t.tools++; t.toolRunning = true; t.toolName = ev.tool || ev.name || ''; }
|
|
436
|
+
if (ev.type === 'tool_result') { t.toolRunning = false; t.toolName = ''; }
|
|
437
|
+
if (ev.isError) { t.errors++; t.lastErrorTs = t.last; }
|
|
438
|
+
}
|
|
348
439
|
state.live.tally.set(data.sid, t);
|
|
349
440
|
}
|
|
350
441
|
const sess = state.sessionsBySid ? state.sessionsBySid.get(data.sid) : null;
|
|
351
442
|
if (sess) {
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
443
|
+
const dupS = ev.i != null && sess._maxI != null && ev.i <= sess._maxI;
|
|
444
|
+
if (!dupS) {
|
|
445
|
+
if (ev.i != null) sess._maxI = ev.i;
|
|
446
|
+
sess.events = (sess.events || 0) + 1;
|
|
447
|
+
sess.last = ev.ts || Date.now();
|
|
448
|
+
if (ev.type === 'tool_use') sess.tools = (sess.tools || 0) + 1;
|
|
449
|
+
if (ev.isError) { sess.errors = (sess.errors || 0) + 1; sess.lastErrorTs = sess.last; }
|
|
450
|
+
}
|
|
356
451
|
} else {
|
|
357
452
|
// Unknown session: a burst of events for a new session would trigger
|
|
358
453
|
// a full session-list refetch per event - debounce it into one. The
|
|
@@ -390,6 +485,19 @@ function closeLiveStream() {
|
|
|
390
485
|
state.live.connected = false;
|
|
391
486
|
}
|
|
392
487
|
|
|
488
|
+
// ONE shortcuts definition consumed by BOTH the ?-overlay and the settings
|
|
489
|
+
// keyboard panel, so the two surfaces cannot drift from the keydown handler
|
|
490
|
+
// below (which implements exactly these: g+c/h/f/l/s, n, /, ?, Esc).
|
|
491
|
+
const SHORTCUTS = [
|
|
492
|
+
{ keys: 'g then c / h / f / l / s', desc: 'switch tabs (chat / history / files / live / settings)' },
|
|
493
|
+
{ keys: 'n', desc: 'new chat (on the chat tab)' },
|
|
494
|
+
{ keys: '/', desc: 'focus search / filter / composer' },
|
|
495
|
+
{ keys: 'Ctrl/Cmd+Shift+L', desc: 'focus the composer from anywhere' },
|
|
496
|
+
{ keys: 'Enter / Shift+Enter', desc: 'send / new line (in the composer)' },
|
|
497
|
+
{ keys: '?', desc: 'show shortcuts' },
|
|
498
|
+
{ keys: 'Esc', desc: 'close overlays, cancel confirms, stop generation, or blur the field' },
|
|
499
|
+
];
|
|
500
|
+
|
|
393
501
|
function view() {
|
|
394
502
|
const ok = state.health.status === 'ok';
|
|
395
503
|
const liveActive = state.tab === 'history' && state.live.connected && (Date.now() - state.live.lastEventTs < 30000);
|
|
@@ -397,7 +505,7 @@ function view() {
|
|
|
397
505
|
? (state.live.error
|
|
398
506
|
? state.live.error + (state.live.reconnects ? ' · ' + state.live.reconnects + ' reconnects' : '')
|
|
399
507
|
: (liveActive ? 'live · ' + state.live.eventCount : (state.live.connected ? 'live' : 'connecting…')))
|
|
400
|
-
: (ok ? (state.health.ws === 'reconnecting' ? '
|
|
508
|
+
: (ok ? (state.health.ws === 'reconnecting' ? 'connecting' : 'connected') : 'offline');
|
|
401
509
|
const dotLive = state.tab === 'history' ? (liveActive || state.live.connected) : ok;
|
|
402
510
|
// The status dot is drawn entirely by CSS (.status-dot::before) - a small
|
|
403
511
|
// colored disc, real product design, not a text glyph. State drives its colour
|
|
@@ -415,6 +523,7 @@ function view() {
|
|
|
415
523
|
// only the dot: on history/chat it names the selected session/agent, on files
|
|
416
524
|
// the current dir, on live the live count, on settings "configuration".
|
|
417
525
|
let crumbLeaf = '';
|
|
526
|
+
let crumbTrail = [];
|
|
418
527
|
if (state.tab === 'history') {
|
|
419
528
|
const sel = state.selectedSid && (Array.isArray(state.sessions) ? state.sessions : []).find(s => s.sid === state.selectedSid);
|
|
420
529
|
crumbLeaf = state.selectedSid
|
|
@@ -423,13 +532,17 @@ function view() {
|
|
|
423
532
|
} else if (state.tab === 'chat') {
|
|
424
533
|
crumbLeaf = state.selectedAgent ? (agentById(state.selectedAgent)?.name || state.selectedAgent) : 'no agent';
|
|
425
534
|
} else if (state.tab === 'files') {
|
|
426
|
-
|
|
535
|
+
// Location reads as hierarchy (parents / dir), not a mid-path ellipsis.
|
|
536
|
+
const segs = state.files?.segments || [];
|
|
537
|
+
crumbLeaf = segs.length ? truncate(segs[segs.length - 1], 18, 32) : 'files';
|
|
538
|
+
crumbTrail = segs.slice(Math.max(0, segs.length - 3), segs.length - 1).map(s => truncate(s, 12, 20));
|
|
427
539
|
} else if (state.tab === 'live') {
|
|
428
540
|
crumbLeaf = 'live · ' + ((state.active && state.active.length) || 0);
|
|
429
541
|
} else if (state.tab === 'settings') {
|
|
430
|
-
|
|
542
|
+
// Same word as the rail item - location chrome must not fork vocabulary.
|
|
543
|
+
crumbLeaf = 'settings';
|
|
431
544
|
}
|
|
432
|
-
const crumb = Crumb({ trail:
|
|
545
|
+
const crumb = Crumb({ trail: crumbTrail, leaf: crumbLeaf, right: [dot] });
|
|
433
546
|
|
|
434
547
|
const agentLabel = state.selectedAgent
|
|
435
548
|
? 'agent: ' + (agentById(state.selectedAgent)?.name || state.selectedAgent) + (state.selectedModel ? ' · ' + state.selectedModel : '')
|
|
@@ -444,7 +557,7 @@ function view() {
|
|
|
444
557
|
: 'min-height:0';
|
|
445
558
|
const shortcutsHint = state.showShortcuts
|
|
446
559
|
? Alert({ key: 'sc', kind: 'info', title: 'Keyboard shortcuts',
|
|
447
|
-
children:
|
|
560
|
+
children: SHORTCUTS.map(s => s.keys + ' - ' + s.desc).join(' · ') })
|
|
448
561
|
: null;
|
|
449
562
|
const main = h('div', { id: 'agentgui-main', role: 'region', 'aria-label': 'main content', 'data-chat-scroll': '', class: 'agentgui-main agentgui-main-' + state.tab, style: mainStyle }, [shortcutsHint, ...mainContent()].filter(Boolean));
|
|
450
563
|
|
|
@@ -466,7 +579,14 @@ function view() {
|
|
|
466
579
|
cwd: state.chatCwd || '',
|
|
467
580
|
toolCount: runningToolCount(),
|
|
468
581
|
usage: state.chat.usage || null,
|
|
469
|
-
|
|
582
|
+
session: {
|
|
583
|
+
turns: state.chat.messages.filter(m => m.role === 'user').length,
|
|
584
|
+
cost: state.chat.totalCost || null,
|
|
585
|
+
},
|
|
586
|
+
// One affordance per action: the cwd row opens the SAME inline editor as
|
|
587
|
+
// the composer context line (it validates via /api/stat) instead of
|
|
588
|
+
// navigating away to the Files tab.
|
|
589
|
+
onSetCwd: () => { state.cwdEditing = true; state.cwdDraft = state.chatCwd || ''; state.cwdError = null; render(); },
|
|
470
590
|
});
|
|
471
591
|
} else if (state.tab === 'files' && state.files && state.files.preview && !isNarrow()) {
|
|
472
592
|
pane = filePreviewPane();
|
|
@@ -513,7 +633,9 @@ const DATE_GROUP_ORDER = ['Running', 'Today', 'Yesterday', 'This week', 'Earlier
|
|
|
513
633
|
// chats are pinned to a "Running" section at the top (a live workspace surfaces
|
|
514
634
|
// in-flight work first), the rest bucket by recency. Returns { items, groups }.
|
|
515
635
|
function sessionGroups(sessionsView) {
|
|
516
|
-
|
|
636
|
+
// Join on the REAL claude/ccsniff sid when known (chat.active rows carry the
|
|
637
|
+
// ephemeral chat- id; claudeSessionId lands once streaming_session arrives).
|
|
638
|
+
const runningSids = new Set((Array.isArray(state.active) ? state.active : []).map(a => a.claudeSessionId || a.sessionId));
|
|
517
639
|
const buckets = new Map();
|
|
518
640
|
for (const s of sessionsView) {
|
|
519
641
|
const label = runningSids.has(s.sid) ? 'Running' : dateGroupLabel(s.last);
|
|
@@ -565,7 +687,7 @@ function sessionsColumn() {
|
|
|
565
687
|
}
|
|
566
688
|
const sessionsView = visibleSessions();
|
|
567
689
|
const sliced = sessionsView.slice(0, state.sessionsLimit);
|
|
568
|
-
const runningSids = new Set((Array.isArray(state.active) ? state.active : []).map(a => a.sessionId));
|
|
690
|
+
const runningSids = new Set((Array.isArray(state.active) ? state.active : []).map(a => a.claudeSessionId || a.sessionId));
|
|
569
691
|
const items = sliced.map((s) => ({
|
|
570
692
|
sid: s.sid,
|
|
571
693
|
title: projectLabel(s.title) || projectLabel(s.project) || s.sid,
|
|
@@ -593,6 +715,7 @@ function sessionsColumn() {
|
|
|
593
715
|
onNew: () => { navTo('chat'); newChat(); },
|
|
594
716
|
onSelect: (s) => { if (state.tab === 'chat') resumeInChat({ sid: s.sid }); else loadSession(s.sid); },
|
|
595
717
|
loading: state.tab === 'history' && !state.sessions.length && !state.historyError,
|
|
718
|
+
loadingText: state.historySlow ? 'Indexing your Claude history — the first load can take a minute…' : undefined,
|
|
596
719
|
error: state.historyError,
|
|
597
720
|
emptyText: 'No conversations yet',
|
|
598
721
|
});
|
|
@@ -607,16 +730,25 @@ function mainContent() {
|
|
|
607
730
|
}
|
|
608
731
|
|
|
609
732
|
// --- files (folder browser) ---
|
|
610
|
-
async function loadDir(dirPath) {
|
|
733
|
+
async function loadDir(dirPath, { fromHash = false } = {}) {
|
|
611
734
|
state.files = state.files || {};
|
|
612
735
|
state.files.loading = true; state.files.error = null; render();
|
|
613
736
|
try {
|
|
614
737
|
const j = await B.listDir(state.backend, dirPath || '');
|
|
738
|
+
// The filter text and show-more cap are per-directory state: keep them
|
|
739
|
+
// across an in-place refresh (same path after a mutation), reset them when
|
|
740
|
+
// the resolved directory actually changed.
|
|
741
|
+
if (j.path !== state.files.path) { state.files.filter = ''; state.files.shown = null; }
|
|
615
742
|
state.files.path = j.path;
|
|
616
743
|
state.files.segments = j.segments || [];
|
|
617
744
|
state.files.entries = j.entries || [];
|
|
618
745
|
state.files.roots = j.roots || [];
|
|
619
746
|
state.files.error = null;
|
|
747
|
+
// Deep-link the open directory: push so Back walks the tree; a load that
|
|
748
|
+
// came FROM the hash (popstate/boot) replaces instead - the entry already
|
|
749
|
+
// exists, only the URL needs to stay accurate (writeHash no-ops when the
|
|
750
|
+
// hash already matches, so popstate never loops).
|
|
751
|
+
if (state.tab === 'files') writeHash({ push: !fromHash });
|
|
620
752
|
} catch (e) {
|
|
621
753
|
// W9: translate HTTP status to plain, non-leaky copy.
|
|
622
754
|
state.files.error = e.status === 403
|
|
@@ -659,8 +791,163 @@ function closePreview() {
|
|
|
659
791
|
if (!state.files) return;
|
|
660
792
|
state.files.preview = null;
|
|
661
793
|
state.files.previewState = null;
|
|
794
|
+
// Clear file= from the URL (replace - the pushed open-entry stays poppable).
|
|
795
|
+
if (state.tab === 'files') writeHash();
|
|
796
|
+
render();
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// Restore (or clear) the file= preview named by the hash once the directory
|
|
800
|
+
// listing for dir= has resolved - popstate and boot both funnel through here.
|
|
801
|
+
function restoreFileFromHash(filePath) {
|
|
802
|
+
const f = state.files || {};
|
|
803
|
+
if (!filePath) {
|
|
804
|
+
if (f.preview) { f.preview = null; f.previewState = null; render(); }
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
if (f.preview && f.preview.path === filePath) return;
|
|
808
|
+
const entry = (f.entries || []).find(e => e.path === filePath);
|
|
809
|
+
if (entry && entry.type !== 'dir') openPreview(entry, { fromHash: true });
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// --- file mutations (rename / delete / new folder / upload) ---
|
|
813
|
+
// One dialog at a time lives in state.files.dialog: {kind, file, error, busy}.
|
|
814
|
+
// Errors from the confined endpoints map to plain copy by HTTP status.
|
|
815
|
+
function fileMutationCopy(e) {
|
|
816
|
+
if (e.status === 403) return 'Permission denied, or the target is outside the allowed roots.';
|
|
817
|
+
if (e.status === 404) return 'That file no longer exists.';
|
|
818
|
+
if (e.status === 409) return e.message || 'A file with that name already exists.';
|
|
819
|
+
if (e.status === 413) return 'Too large (50MB upload cap).';
|
|
820
|
+
return e.message || 'The operation failed.';
|
|
821
|
+
}
|
|
822
|
+
function openFileDialog(kind, file) {
|
|
823
|
+
state.files.dialog = { kind, file: file || null, error: null, busy: false };
|
|
824
|
+
render();
|
|
825
|
+
}
|
|
826
|
+
function closeFileDialog() {
|
|
827
|
+
// A mid-flight close would orphan the mutation's result and swallow its
|
|
828
|
+
// error - hold the dialog open until the operation settles.
|
|
829
|
+
if (state.files.dialog?.busy) { announce('still working - please wait'); return; }
|
|
830
|
+
state.files.dialog = null; render();
|
|
831
|
+
}
|
|
832
|
+
async function runFileMutation(fn, doneMsg) {
|
|
833
|
+
const d = state.files.dialog;
|
|
834
|
+
if (!d || d.busy) return;
|
|
835
|
+
d.busy = true; d.error = null; render();
|
|
836
|
+
try {
|
|
837
|
+
await fn();
|
|
838
|
+
state.files.dialog = null;
|
|
839
|
+
announce(doneMsg);
|
|
840
|
+
await loadDir(state.files.path, { fromHash: true }); // refresh in place, no history entry
|
|
841
|
+
} catch (e) {
|
|
842
|
+
// If the dialog detached anyway (e.g. state replaced), the error would be
|
|
843
|
+
// written onto a dead object and lost - announce it instead.
|
|
844
|
+
if (state.files.dialog !== d) { announce(fileMutationCopy(e)); render(); return; }
|
|
845
|
+
d.busy = false; d.error = fileMutationCopy(e); render();
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
// Upload a FileList into the current directory; per-file rows feed the kit
|
|
849
|
+
// UploadProgress (done/error per file - fetch has no chunk progress).
|
|
850
|
+
async function uploadFiles(fileList) {
|
|
851
|
+
const dir = state.files.path;
|
|
852
|
+
if (!dir || !fileList || !fileList.length) return;
|
|
853
|
+
const items = Array.from(fileList).map((f) => ({ name: f.name, pct: 0, done: false, error: null, status: null, _file: f, _dir: dir }));
|
|
854
|
+
// Concurrent drops APPEND to the shared queue (a second drop mid-upload must
|
|
855
|
+
// not replace the first batch's progress/errors); one running loop drains it.
|
|
856
|
+
state.files.uploads = (state.files.uploads || []).concat(items);
|
|
857
|
+
render();
|
|
858
|
+
if (state.files.uploading) return;
|
|
859
|
+
state.files.uploading = true;
|
|
860
|
+
try {
|
|
861
|
+
let it;
|
|
862
|
+
while ((it = (state.files.uploads || []).find(i => !i.done && !i.error && !i._started))) {
|
|
863
|
+
it._started = true;
|
|
864
|
+
try {
|
|
865
|
+
await B.uploadFile(state.backend, it._dir, it._file);
|
|
866
|
+
it.pct = 100; it.done = true;
|
|
867
|
+
} catch (e) {
|
|
868
|
+
it.status = e.status || null;
|
|
869
|
+
it.error = fileMutationCopy(e);
|
|
870
|
+
}
|
|
871
|
+
render();
|
|
872
|
+
}
|
|
873
|
+
} finally {
|
|
874
|
+
state.files.uploading = false;
|
|
875
|
+
}
|
|
876
|
+
announce('upload finished');
|
|
877
|
+
await loadDir(state.files.path, { fromHash: true });
|
|
878
|
+
// Keep error rows visible; clear the list entirely when everything landed.
|
|
879
|
+
if (!(state.files.uploads || []).some(i => i.error)) state.files.uploads = null;
|
|
662
880
|
render();
|
|
663
881
|
}
|
|
882
|
+
// 'replace' on a 409 row: re-PUT the same file with overwrite=1.
|
|
883
|
+
async function retryUploadOverwrite(it) {
|
|
884
|
+
if (!it || it._retrying || !it._file) return;
|
|
885
|
+
it._retrying = true; it.error = null; it.status = null; render();
|
|
886
|
+
try {
|
|
887
|
+
await B.uploadFile(state.backend, it._dir, it._file, true);
|
|
888
|
+
it.pct = 100; it.done = true;
|
|
889
|
+
} catch (e) {
|
|
890
|
+
it.status = e.status || null;
|
|
891
|
+
it.error = fileMutationCopy(e);
|
|
892
|
+
}
|
|
893
|
+
it._retrying = false;
|
|
894
|
+
await loadDir(state.files.path, { fromHash: true });
|
|
895
|
+
if (!(state.files.uploads || []).some(i => i.error)) state.files.uploads = null;
|
|
896
|
+
render();
|
|
897
|
+
}
|
|
898
|
+
function dismissUpload(it) {
|
|
899
|
+
const ups = state.files.uploads || [];
|
|
900
|
+
const i = ups.indexOf(it);
|
|
901
|
+
if (i >= 0) ups.splice(i, 1);
|
|
902
|
+
if (!ups.length) state.files.uploads = null;
|
|
903
|
+
render();
|
|
904
|
+
}
|
|
905
|
+
// The active file dialog (rename/delete/mkdir) as a kit modal, or null.
|
|
906
|
+
function fileDialog() {
|
|
907
|
+
const d = state.files && state.files.dialog;
|
|
908
|
+
if (!d) return null;
|
|
909
|
+
// error/busy live INSIDE the kit dialog (the modal overlay sits above page
|
|
910
|
+
// flow, so a sibling alert was invisible and outside the focus trap).
|
|
911
|
+
if (d.kind === 'rename') {
|
|
912
|
+
return PromptDialog({
|
|
913
|
+
title: 'Rename ' + d.file.name, value: d.file.name, placeholder: 'new name',
|
|
914
|
+
error: d.error || null, busy: !!d.busy,
|
|
915
|
+
confirmLabel: d.busy ? 'renaming...' : 'rename', cancelLabel: 'cancel',
|
|
916
|
+
onCancel: closeFileDialog,
|
|
917
|
+
onConfirm: (v) => {
|
|
918
|
+
// Every confirm press produces visible feedback - never a silent no-op.
|
|
919
|
+
if (!v || v === d.file.name) { d.error = 'enter a different name'; render(); return; }
|
|
920
|
+
runFileMutation(() => B.renameEntry(state.backend, d.file.path, v), 'renamed to ' + v);
|
|
921
|
+
},
|
|
922
|
+
});
|
|
923
|
+
}
|
|
924
|
+
if (d.kind === 'delete') {
|
|
925
|
+
const isDir = d.file.type === 'dir';
|
|
926
|
+
return ConfirmDialog({
|
|
927
|
+
title: 'Delete ' + d.file.name,
|
|
928
|
+
message: isDir
|
|
929
|
+
? 'Delete this folder and everything inside it? This cannot be undone.'
|
|
930
|
+
: 'Delete this file? This cannot be undone.',
|
|
931
|
+
error: d.error || null, busy: !!d.busy,
|
|
932
|
+
confirmLabel: d.busy ? 'deleting...' : 'delete', cancelLabel: 'cancel', destructive: true,
|
|
933
|
+
onCancel: closeFileDialog,
|
|
934
|
+
onConfirm: () => runFileMutation(() => B.deleteEntry(state.backend, d.file.path, isDir), 'deleted ' + d.file.name),
|
|
935
|
+
});
|
|
936
|
+
}
|
|
937
|
+
if (d.kind === 'mkdir') {
|
|
938
|
+
return PromptDialog({
|
|
939
|
+
title: 'New folder', value: '', placeholder: 'folder name',
|
|
940
|
+
error: d.error || null, busy: !!d.busy,
|
|
941
|
+
confirmLabel: d.busy ? 'creating...' : 'create', cancelLabel: 'cancel',
|
|
942
|
+
onCancel: closeFileDialog,
|
|
943
|
+
onConfirm: (v) => {
|
|
944
|
+
if (!v) { d.error = 'enter a folder name'; render(); return; }
|
|
945
|
+
runFileMutation(() => B.makeDir(state.backend, state.files.path, v), 'created ' + v);
|
|
946
|
+
},
|
|
947
|
+
});
|
|
948
|
+
}
|
|
949
|
+
return null;
|
|
950
|
+
}
|
|
664
951
|
|
|
665
952
|
// Build the inner preview body (image / code / text / loading / error) shared by
|
|
666
953
|
// the modal FileViewer (<900px) and the inline FilePreviewPane (split view).
|
|
@@ -697,8 +984,10 @@ function previewNeighbours() {
|
|
|
697
984
|
return { prev: i > 0 ? list[i - 1] : null, next: i < list.length - 1 ? list[i + 1] : null };
|
|
698
985
|
}
|
|
699
986
|
|
|
700
|
-
function openPreview(file) {
|
|
987
|
+
function openPreview(file, { fromHash = false } = {}) {
|
|
701
988
|
state.files.preview = file;
|
|
989
|
+
// Push file= so Back closes the preview; hash-driven opens replace instead.
|
|
990
|
+
if (state.tab === 'files') writeHash({ push: !fromHash });
|
|
702
991
|
if (file.type !== 'image') loadPreviewContent(file);
|
|
703
992
|
render();
|
|
704
993
|
}
|
|
@@ -785,6 +1074,7 @@ function filesMain() {
|
|
|
785
1074
|
// Click the active column to flip direction; a new column resets to asc.
|
|
786
1075
|
if (state.files.sort === k) state.files.sortDir = state.files.sortDir === 'asc' ? 'desc' : 'asc';
|
|
787
1076
|
else { state.files.sort = k; state.files.sortDir = 'asc'; }
|
|
1077
|
+
persistFilesPrefs();
|
|
788
1078
|
render();
|
|
789
1079
|
} },
|
|
790
1080
|
filter: { value: f.filter || '', placeholder: 'Filter files in this directory', onInput: (v) => { state.files.filter = v; state.files.shown = null; render(); } },
|
|
@@ -793,15 +1083,18 @@ function filesMain() {
|
|
|
793
1083
|
if (file.type === 'dir') loadDir(file.path);
|
|
794
1084
|
else openPreview(file);
|
|
795
1085
|
},
|
|
796
|
-
//
|
|
797
|
-
//
|
|
798
|
-
//
|
|
1086
|
+
// Full manager wiring: download streams the confined /api/download;
|
|
1087
|
+
// rename/delete open a kit dialog backed by the confined mutation
|
|
1088
|
+
// endpoints (the former read-only scope cut is reversed).
|
|
799
1089
|
onAction: (act, file) => {
|
|
1090
|
+
if (file.permissions === 'EACCES') { announce('no access to ' + file.name); return; }
|
|
800
1091
|
if (act === 'download' && file.type !== 'dir') {
|
|
801
1092
|
const a = document.createElement('a');
|
|
802
1093
|
a.href = B.downloadUrl(state.backend, file.path);
|
|
803
1094
|
a.download = file.name; document.body.appendChild(a); a.click(); a.remove();
|
|
804
1095
|
}
|
|
1096
|
+
if (act === 'rename') openFileDialog('rename', file);
|
|
1097
|
+
if (act === 'delete') openFileDialog('delete', file);
|
|
805
1098
|
},
|
|
806
1099
|
});
|
|
807
1100
|
}
|
|
@@ -819,27 +1112,113 @@ function filesMain() {
|
|
|
819
1112
|
// Kit FileToolbar (replaces the hand-built .ds-file-toolbar markup).
|
|
820
1113
|
const toolbar = FileToolbar({
|
|
821
1114
|
left: [crumb],
|
|
822
|
-
right: [
|
|
823
|
-
? Btn({ key: '
|
|
824
|
-
:
|
|
1115
|
+
right: [
|
|
1116
|
+
f.path ? Btn({ key: 'newdir', onClick: () => openFileDialog('mkdir'), children: 'new folder' }) : null,
|
|
1117
|
+
f.path ? Btn({ key: 'upload', onClick: () => {
|
|
1118
|
+
const inp = document.createElement('input');
|
|
1119
|
+
inp.type = 'file'; inp.multiple = true;
|
|
1120
|
+
inp.onchange = () => uploadFiles(inp.files);
|
|
1121
|
+
inp.click();
|
|
1122
|
+
}, children: 'upload' }) : null,
|
|
1123
|
+
targetCwd
|
|
1124
|
+
? Btn({ key: 'usecwd', onClick: () => { state.chatCwd = targetCwd; lsSet('agentgui.cwd', targetCwd); announce('working directory set to ' + targetCwd); navTo('chat'); }, children: 'use as chat cwd' })
|
|
1125
|
+
: null,
|
|
1126
|
+
].filter(Boolean),
|
|
825
1127
|
});
|
|
1128
|
+
// Drag-and-drop upload wraps the grid (keyboard path = the toolbar upload
|
|
1129
|
+
// button); per-file progress rows render above the grid while in flight.
|
|
1130
|
+
const droppableBody = f.path && !f.error
|
|
1131
|
+
? DropZone({
|
|
1132
|
+
dragover: !!f.dragover,
|
|
1133
|
+
label: 'drop files to upload to this folder',
|
|
1134
|
+
onDragOver: () => { if (!state.files.dragover) { state.files.dragover = true; render(); } },
|
|
1135
|
+
onDragLeave: () => { if (state.files.dragover) { state.files.dragover = false; render(); } },
|
|
1136
|
+
onDrop: (files) => { state.files.dragover = false; uploadFiles(files); },
|
|
1137
|
+
children: body,
|
|
1138
|
+
})
|
|
1139
|
+
: body;
|
|
826
1140
|
return [
|
|
827
1141
|
offlineBanner(),
|
|
828
|
-
PageHeader({ compact: true, title: 'Files', lede: 'Browse the server filesystem within the allowed roots. Click a folder to open it; pick one as the chat working directory.' }),
|
|
1142
|
+
PageHeader({ compact: true, title: 'Files', lede: 'Browse and manage the server filesystem within the allowed roots. Click a folder to open it; pick one as the chat working directory.' }),
|
|
829
1143
|
rootsRow ? h('div', { key: 'froots' }, rootsRow) : null,
|
|
830
1144
|
h('div', { key: 'ftb' }, toolbar),
|
|
831
|
-
h('div', { key: '
|
|
1145
|
+
(f.uploads && f.uploads.length) ? h('div', { key: 'fup' }, UploadProgress({
|
|
1146
|
+
// Recovery affordances per row: 'replace' on a name collision (409),
|
|
1147
|
+
// dismiss on any error row (errors otherwise persist until the next batch).
|
|
1148
|
+
items: f.uploads.map((it) => (it.error && it.status === 409 && !it._retrying)
|
|
1149
|
+
? { ...it, actions: [{ label: 'replace', onClick: () => retryUploadOverwrite(it) }] }
|
|
1150
|
+
: it),
|
|
1151
|
+
onDismiss: (item, i) => {
|
|
1152
|
+
const src = (state.files.uploads || [])[i];
|
|
1153
|
+
if (src && src.error) dismissUpload(src);
|
|
1154
|
+
},
|
|
1155
|
+
})) : null,
|
|
1156
|
+
h('div', { key: 'fbody' }, droppableBody),
|
|
1157
|
+
fileDialog(),
|
|
832
1158
|
// Inline pane handles wide-screen preview; the modal is only the <900px
|
|
833
1159
|
// fallback (the pane has no room there).
|
|
834
1160
|
(f.preview && isNarrow()) ? h('div', { key: 'fprev' }, filePreview()) : null,
|
|
835
1161
|
].filter(Boolean);
|
|
836
1162
|
}
|
|
837
1163
|
|
|
1164
|
+
// --- live/files preference persistence (sort, errors-only). The live
|
|
1165
|
+
// selection Set is deliberately NOT persisted - stale sids would arm
|
|
1166
|
+
// stop-selected against sessions that no longer exist.
|
|
1167
|
+
const LIVE_PREFS_KEY = 'agentgui.live';
|
|
1168
|
+
const FILES_PREFS_KEY = 'agentgui.files';
|
|
1169
|
+
function persistLivePrefs() {
|
|
1170
|
+
lsSet(LIVE_PREFS_KEY, JSON.stringify({ sort: state.live.sort || 'status', errorsOnly: !!state.live.errorsOnly }));
|
|
1171
|
+
}
|
|
1172
|
+
function persistFilesPrefs() {
|
|
1173
|
+
lsSet(FILES_PREFS_KEY, JSON.stringify({ sort: state.files.sort || 'name', sortDir: state.files.sortDir || 'asc' }));
|
|
1174
|
+
}
|
|
1175
|
+
function hydratePrefs() {
|
|
1176
|
+
try {
|
|
1177
|
+
const lv = JSON.parse(lsGet(LIVE_PREFS_KEY) || 'null');
|
|
1178
|
+
if (lv) { if (lv.sort) state.live.sort = lv.sort; state.live.errorsOnly = !!lv.errorsOnly; }
|
|
1179
|
+
} catch {}
|
|
1180
|
+
try {
|
|
1181
|
+
const fp = JSON.parse(lsGet(FILES_PREFS_KEY) || 'null');
|
|
1182
|
+
if (fp) { if (fp.sort) state.files.sort = fp.sort; if (fp.sortDir) state.files.sortDir = fp.sortDir; }
|
|
1183
|
+
} catch {}
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
// Two-step stop: the first click ARMS (confirming* flips the kit button to a
|
|
1187
|
+
// confirm state), a second click within 4s executes; the timer auto-resets.
|
|
1188
|
+
let _stopAllArmTimer = null;
|
|
1189
|
+
let _stopSelArmTimer = null;
|
|
1190
|
+
function armStopAll() {
|
|
1191
|
+
state.live.confirmingStopAll = true;
|
|
1192
|
+
clearTimeout(_stopAllArmTimer);
|
|
1193
|
+
_stopAllArmTimer = setTimeout(() => { state.live.confirmingStopAll = false; render(); }, 4000);
|
|
1194
|
+
render();
|
|
1195
|
+
}
|
|
1196
|
+
function armStopSelected() {
|
|
1197
|
+
state.live.confirmingStopSelected = true;
|
|
1198
|
+
clearTimeout(_stopSelArmTimer);
|
|
1199
|
+
_stopSelArmTimer = setTimeout(() => { state.live.confirmingStopSelected = false; render(); }, 4000);
|
|
1200
|
+
render();
|
|
1201
|
+
}
|
|
1202
|
+
|
|
838
1203
|
// Stop every in-flight chat at once (the dashboard "stop all" bulk control).
|
|
1204
|
+
// Awaits every cancel, reports partial failure, and returns the sids that
|
|
1205
|
+
// actually stopped so callers only clear those from a selection.
|
|
839
1206
|
async function stopAllActive(sessions) {
|
|
840
1207
|
const sids = (Array.isArray(sessions) ? sessions : (state.active || [])).map(s => s.sid || s.sessionId).filter(Boolean);
|
|
841
|
-
|
|
842
|
-
|
|
1208
|
+
if (!sids.length) return [];
|
|
1209
|
+
const st = state.live.stopping = state.live.stopping || new Set();
|
|
1210
|
+
for (const sid of sids) st.add(sid);
|
|
1211
|
+
render();
|
|
1212
|
+
const results = await Promise.allSettled(sids.map(sid => B.cancelChat(state.backend, sid)));
|
|
1213
|
+
const okSids = sids.filter((sid, i) => results[i].status === 'fulfilled');
|
|
1214
|
+
const failed = sids.length - okSids.length;
|
|
1215
|
+
state.live.bulkStopError = failed
|
|
1216
|
+
? failed + ' session' + (failed === 1 ? '' : 's') + ' did not stop - try again'
|
|
1217
|
+
: null;
|
|
1218
|
+
announce('stopped ' + okSids.length + ' of ' + sids.length + ' sessions');
|
|
1219
|
+
await refreshActive();
|
|
1220
|
+
render();
|
|
1221
|
+
return okSids;
|
|
843
1222
|
}
|
|
844
1223
|
|
|
845
1224
|
// How long a session can go without activity (and without a running tool)
|
|
@@ -856,75 +1235,154 @@ function liveMain() {
|
|
|
856
1235
|
const tally = state.live.tally || new Map();
|
|
857
1236
|
const now = Date.now();
|
|
858
1237
|
// Live-stream health: connected (recent event), connecting (opened, no event
|
|
859
|
-
// yet), or
|
|
860
|
-
|
|
861
|
-
const
|
|
1238
|
+
// yet), or offline (errored - one connection vocabulary across the GUI).
|
|
1239
|
+
const streamState = state.live.error ? 'offline' : (state.live.connected ? 'connected' : 'connecting');
|
|
1240
|
+
const stoppingSet = state.live.stopping || new Set();
|
|
1241
|
+
// Clock-skew-corrected "now" in server-timestamp terms, so pure skew never
|
|
1242
|
+
// derives a session stale.
|
|
1243
|
+
const nowS = now - (state.live.clockSkew || 0);
|
|
862
1244
|
let sessions = (Array.isArray(state.active) ? state.active : []).map((r) => {
|
|
863
|
-
//
|
|
864
|
-
//
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
const
|
|
868
|
-
const
|
|
869
|
-
const
|
|
870
|
-
|
|
1245
|
+
// The history index + SSE tally are keyed by claude's REAL session id, not
|
|
1246
|
+
// the ephemeral chat- id chat.active returns; join on the real one once
|
|
1247
|
+
// streaming_session has landed. The ephemeral id stays the card sid (it is
|
|
1248
|
+
// what chat.cancel takes).
|
|
1249
|
+
const realSid = r.claudeSessionId || r.sessionId;
|
|
1250
|
+
const sess = bySid.get(realSid);
|
|
1251
|
+
const t = tally.get(realSid);
|
|
1252
|
+
// Counters are MONOTONIC within a session: the index refresh lags the JSONL
|
|
1253
|
+
// flush, so take the max of index and live tally - never regress.
|
|
1254
|
+
const events = Math.max(sess?.events ?? -1, t?.events ?? -1);
|
|
1255
|
+
const tools = Math.max(sess?.tools || 0, t?.tools || 0);
|
|
1256
|
+
const errors = Math.max(sess?.errors || 0, t?.errors || 0);
|
|
1257
|
+
const lastTs = Math.max(sess?.last || 0, t?.last || 0);
|
|
1258
|
+
const lastErrorTs = Math.max(sess?.lastErrorTs || 0, t?.lastErrorTs || 0);
|
|
871
1259
|
const counterBits = [];
|
|
872
|
-
if (events
|
|
1260
|
+
if (events >= 0) counterBits.push(events + ' ev');
|
|
873
1261
|
if (tools) counterBits.push(tools + ' tools');
|
|
874
1262
|
if (errors) counterBits.push(errors + ' err');
|
|
875
|
-
// Current tool:
|
|
876
|
-
//
|
|
1263
|
+
// Current tool: the in-page chat knows its own running tool part; every
|
|
1264
|
+
// OTHER session gets it from the per-sid SSE tally (an unresolved tool_use
|
|
1265
|
+
// means busy-with-tool, not stalled).
|
|
877
1266
|
let currentTool = '';
|
|
878
|
-
if (state.chat.resumeSid ===
|
|
1267
|
+
if (state.chat.resumeSid === realSid && state.chat.busy) {
|
|
879
1268
|
const msgs = state.chat.messages || [];
|
|
880
1269
|
const last = msgs[msgs.length - 1];
|
|
881
1270
|
const running = last && Array.isArray(last.parts) && last.parts.filter(p => p && p.kind === 'tool' && p.status === 'running').slice(-1)[0];
|
|
882
1271
|
if (running) currentTool = running.name || '';
|
|
883
1272
|
}
|
|
884
|
-
|
|
1273
|
+
if (!currentTool && t && t.toolRunning) currentTool = t.toolName || 'tool';
|
|
1274
|
+
// Status reflects CURRENT reality: error only when an error is recent (a
|
|
1275
|
+
// recovered tool error hours ago is history, kept in the counter chip);
|
|
1276
|
+
// stale = no recent activity AND no running tool.
|
|
885
1277
|
let status = 'running';
|
|
886
|
-
if (
|
|
887
|
-
else if (!currentTool && lastTs && (
|
|
1278
|
+
if (lastErrorTs && (nowS - lastErrorTs) <= STALE_AFTER_MS) status = 'error';
|
|
1279
|
+
else if (!currentTool && lastTs && (nowS - lastTs) > STALE_AFTER_MS) status = 'stale';
|
|
1280
|
+
const startedTs = r.startedAt || 0;
|
|
1281
|
+
const elapsedMs = startedTs ? Math.max(0, now - startedTs) : 0;
|
|
1282
|
+
// One title source shared with the rails: projectLabel(title|project)|sid.
|
|
1283
|
+
const title = sess
|
|
1284
|
+
? (projectLabel(sess.title) || projectLabel(sess.project) || realSid)
|
|
1285
|
+
: (r.claudeSessionId ? realSid : '');
|
|
888
1286
|
return {
|
|
889
1287
|
sid: r.sessionId,
|
|
1288
|
+
realSid,
|
|
1289
|
+
title: title || undefined,
|
|
890
1290
|
agent: agentById(r.agentId)?.name || r.agentId || 'agent',
|
|
891
1291
|
model: r.model || '',
|
|
892
1292
|
cwd: r.cwd ? r.cwd.split(/[/\\]/).filter(Boolean).slice(-1)[0] : '',
|
|
893
|
-
elapsed:
|
|
1293
|
+
elapsed: elapsedMs ? fmtDuration(elapsedMs) : '',
|
|
1294
|
+
elapsedMs,
|
|
1295
|
+
startedTs,
|
|
894
1296
|
counter: counterBits.length ? counterBits.join(' · ') : null,
|
|
895
|
-
lastActivity: lastTs ? fmtRelTime(lastTs) : '',
|
|
1297
|
+
lastActivity: lastTs ? fmtRelTime(lastTs + (state.live.clockSkew || 0)) : '',
|
|
1298
|
+
lastTs,
|
|
1299
|
+
errors,
|
|
896
1300
|
currentTool,
|
|
897
1301
|
status,
|
|
1302
|
+
stopping: stoppingSet.has(r.sessionId),
|
|
898
1303
|
};
|
|
899
1304
|
});
|
|
1305
|
+
// External sessions (a claude CLI in a terminal, etc.): live SSE motion that
|
|
1306
|
+
// belongs to no agentgui-spawned chat. The page promises EVERY in-flight
|
|
1307
|
+
// session, so render them as read-only cards (we own no process to stop).
|
|
1308
|
+
const ownedReal = new Set(sessions.map(s => s.realSid));
|
|
1309
|
+
for (const [sid, t] of tally) {
|
|
1310
|
+
if (ownedReal.has(sid)) continue;
|
|
1311
|
+
if (!t.last || (nowS - t.last) >= STALE_AFTER_MS) continue;
|
|
1312
|
+
const sess = bySid.get(sid);
|
|
1313
|
+
const events = Math.max(sess?.events ?? -1, t.events ?? -1);
|
|
1314
|
+
const tools = Math.max(sess?.tools || 0, t.tools || 0);
|
|
1315
|
+
const errors = Math.max(sess?.errors || 0, t.errors || 0);
|
|
1316
|
+
const lastErrorTs = Math.max(sess?.lastErrorTs || 0, t.lastErrorTs || 0);
|
|
1317
|
+
const counterBits = [];
|
|
1318
|
+
if (events >= 0) counterBits.push(events + ' ev');
|
|
1319
|
+
if (tools) counterBits.push(tools + ' tools');
|
|
1320
|
+
if (errors) counterBits.push(errors + ' err');
|
|
1321
|
+
sessions.push({
|
|
1322
|
+
sid,
|
|
1323
|
+
realSid: sid,
|
|
1324
|
+
external: true,
|
|
1325
|
+
readOnly: true,
|
|
1326
|
+
title: sess ? (projectLabel(sess.title) || projectLabel(sess.project) || sid) : sid,
|
|
1327
|
+
agent: 'external session',
|
|
1328
|
+
model: '',
|
|
1329
|
+
cwd: sess && sess.cwd ? sess.cwd.split(/[/\\]/).filter(Boolean).slice(-1)[0] : '',
|
|
1330
|
+
elapsed: '',
|
|
1331
|
+
elapsedMs: 0,
|
|
1332
|
+
startedTs: 0,
|
|
1333
|
+
counter: counterBits.length ? counterBits.join(' · ') : null,
|
|
1334
|
+
lastActivity: fmtRelTime(t.last + (state.live.clockSkew || 0)),
|
|
1335
|
+
lastTs: t.last,
|
|
1336
|
+
errors,
|
|
1337
|
+
currentTool: t.toolRunning ? (t.toolName || 'tool') : '',
|
|
1338
|
+
status: (lastErrorTs && (nowS - lastErrorTs) <= STALE_AFTER_MS) ? 'error' : 'running',
|
|
1339
|
+
stopping: false,
|
|
1340
|
+
});
|
|
1341
|
+
}
|
|
900
1342
|
// W12: in-dir filter + errors-only toggle.
|
|
901
1343
|
const lv = state.live;
|
|
902
1344
|
if (lv.errorsOnly) sessions = sessions.filter(s => s.status === 'error');
|
|
903
1345
|
if (lv.filter) {
|
|
904
1346
|
const q = lv.filter.toLowerCase();
|
|
905
|
-
sessions = sessions.filter(s => (s.agent + ' ' + s.model + ' ' + s.cwd).toLowerCase().includes(q));
|
|
1347
|
+
sessions = sessions.filter(s => ((s.title || '') + ' ' + s.agent + ' ' + s.model + ' ' + s.cwd).toLowerCase().includes(q));
|
|
906
1348
|
}
|
|
907
|
-
//
|
|
1349
|
+
// Sort: real numeric comparisons (recency/elapsed/error count), owned before
|
|
1350
|
+
// external, and a deterministic sid tiebreaker so equal-rank cards never
|
|
1351
|
+
// reshuffle with the server's return order.
|
|
908
1352
|
const sortKey = lv.sort || 'status';
|
|
909
1353
|
sessions.sort((a, b) => {
|
|
910
|
-
|
|
911
|
-
if (
|
|
912
|
-
if (sortKey === '
|
|
913
|
-
|
|
1354
|
+
let d = (a.external ? 1 : 0) - (b.external ? 1 : 0);
|
|
1355
|
+
if (d) return d;
|
|
1356
|
+
if (sortKey === 'elapsed') d = (b.elapsedMs || 0) - (a.elapsedMs || 0);
|
|
1357
|
+
else if (sortKey === 'activity') d = (b.lastTs || 0) - (a.lastTs || 0);
|
|
1358
|
+
else if (sortKey === 'errors') d = (b.errors || 0) - (a.errors || 0);
|
|
1359
|
+
else d = (STATUS_RANK[a.status] ?? 9) - (STATUS_RANK[b.status] ?? 9);
|
|
1360
|
+
return d || String(a.sid).localeCompare(String(b.sid));
|
|
914
1361
|
});
|
|
915
1362
|
const selected = lv.selected instanceof Set ? lv.selected : new Set();
|
|
1363
|
+
// The in-page chat's card accent matches on the real sid, but the kit
|
|
1364
|
+
// compares activeSid against card.sid (the ephemeral id for owned cards).
|
|
1365
|
+
const activeCard = state.chat.resumeSid
|
|
1366
|
+
? sessions.find(s => s.realSid === state.chat.resumeSid)
|
|
1367
|
+
: null;
|
|
916
1368
|
return [
|
|
917
1369
|
offlineBanner(),
|
|
1370
|
+
lv.bulkStopError
|
|
1371
|
+
? Alert({ key: 'bulkstoperr', kind: 'warn', title: 'Some sessions did not stop',
|
|
1372
|
+
children: [
|
|
1373
|
+
h('span', { key: 'bsetxt' }, lv.bulkStopError + ' '),
|
|
1374
|
+
Btn({ key: 'bsedismiss', onClick: () => { state.live.bulkStopError = null; render(); }, children: 'dismiss' })] })
|
|
1375
|
+
: null,
|
|
918
1376
|
PageHeader({ compact: true, title: 'Live sessions', lede: 'Every in-flight agent session, managed at once. Stop, open, or jump to events per session.' }),
|
|
919
1377
|
SessionDashboard({
|
|
920
1378
|
sessions,
|
|
921
1379
|
offline,
|
|
922
1380
|
streamState,
|
|
923
|
-
activeSid: state.chat.resumeSid,
|
|
924
|
-
sort: { value: sortKey, onChange: (v) => { state.live.sort = v; render(); } },
|
|
1381
|
+
activeSid: activeCard ? activeCard.sid : state.chat.resumeSid, // W13
|
|
1382
|
+
sort: { value: sortKey, onChange: (v) => { state.live.sort = v; persistLivePrefs(); render(); } },
|
|
925
1383
|
filter: { value: lv.filter || '', placeholder: 'Filter sessions', onInput: (v) => { state.live.filter = v; render(); } },
|
|
926
1384
|
errorsOnly: !!lv.errorsOnly,
|
|
927
|
-
onErrorsOnly: (on) => { state.live.errorsOnly = on; render(); },
|
|
1385
|
+
onErrorsOnly: (on) => { state.live.errorsOnly = on; persistLivePrefs(); render(); },
|
|
928
1386
|
selectable: true,
|
|
929
1387
|
selected,
|
|
930
1388
|
onToggleSelect: (s) => {
|
|
@@ -932,12 +1390,32 @@ function liveMain() {
|
|
|
932
1390
|
if (set.has(s.sid)) set.delete(s.sid); else set.add(s.sid);
|
|
933
1391
|
state.live.selected = set; render();
|
|
934
1392
|
},
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
1393
|
+
// Two-step bulk stops: arm first, execute on the confirmed click.
|
|
1394
|
+
confirmingStopAll: !!lv.confirmingStopAll,
|
|
1395
|
+
confirmingStopSelected: !!lv.confirmingStopSelected,
|
|
1396
|
+
onArmStopAll: armStopAll,
|
|
1397
|
+
onArmStopSelected: armStopSelected,
|
|
1398
|
+
onStopSelected: async (sids) => {
|
|
1399
|
+
state.live.confirmingStopSelected = false;
|
|
1400
|
+
clearTimeout(_stopSelArmTimer);
|
|
1401
|
+
// Await the cancels and only clear the sids that actually stopped, so
|
|
1402
|
+
// a partially-failed bulk stop stays selected and visibly armed-again.
|
|
1403
|
+
const ok = await stopAllActive(sids.map(sid => ({ sid })));
|
|
1404
|
+
const sel = (state.live.selected instanceof Set) ? state.live.selected : new Set();
|
|
1405
|
+
for (const sid of ok) sel.delete(sid);
|
|
1406
|
+
state.live.selected = sel;
|
|
1407
|
+
render();
|
|
1408
|
+
},
|
|
1409
|
+
emptyText: 'No live sessions — start a chat or run a local agent.',
|
|
1410
|
+
onStop: (s) => { if (!s.external) stopActiveChat(s.sid); },
|
|
1411
|
+
onStopAll: async (all) => {
|
|
1412
|
+
state.live.confirmingStopAll = false;
|
|
1413
|
+
clearTimeout(_stopAllArmTimer);
|
|
1414
|
+
await stopAllActive((all || []).filter(s => !s.external));
|
|
1415
|
+
render();
|
|
1416
|
+
},
|
|
1417
|
+
onOpen: (s) => { resumeInChat({ sid: s.realSid || s.sid }); },
|
|
1418
|
+
onView: (s) => { navTo('history'); loadSession(s.realSid || s.sid); },
|
|
941
1419
|
}),
|
|
942
1420
|
].filter(Boolean);
|
|
943
1421
|
}
|
|
@@ -1016,15 +1494,29 @@ function applyToolResult(parts, block) {
|
|
|
1016
1494
|
}
|
|
1017
1495
|
}
|
|
1018
1496
|
|
|
1019
|
-
|
|
1497
|
+
// Map raw transport/server errors to plain-language copy. The raw string is
|
|
1498
|
+
// preserved via errTextRaw so renderers can carry it on a title attribute.
|
|
1499
|
+
const ERR_COPY = [
|
|
1500
|
+
[/^ws closed$/, 'Lost connection to the server.'],
|
|
1501
|
+
[/connection lost during stream/, 'Lost connection while the agent was responding - use retry.'],
|
|
1502
|
+
[/no sessionId from server/, 'The server could not start the agent - check it is installed.'],
|
|
1503
|
+
[/^sessions: \d+/, 'History is still indexing - try again in a moment.'],
|
|
1504
|
+
];
|
|
1505
|
+
function errTextRaw(e) {
|
|
1020
1506
|
if (e == null) return 'unknown error';
|
|
1021
1507
|
if (typeof e === 'string') return e;
|
|
1022
1508
|
if (e.message) return e.message;
|
|
1023
1509
|
try { return JSON.stringify(e); } catch { return String(e); }
|
|
1024
1510
|
}
|
|
1511
|
+
function errText(e) {
|
|
1512
|
+
const raw = errTextRaw(e);
|
|
1513
|
+
for (const [re, copy] of ERR_COPY) { if (re.test(raw)) return copy; }
|
|
1514
|
+
return raw;
|
|
1515
|
+
}
|
|
1025
1516
|
|
|
1026
1517
|
function chatMain() {
|
|
1027
1518
|
const agentName = agentById(state.selectedAgent)?.name || state.selectedAgent || 'agent';
|
|
1519
|
+
const userTurnCount = state.chat.messages.filter(m => m.role === 'user').length;
|
|
1028
1520
|
|
|
1029
1521
|
// Banners agentgui owns (resume, agent-switched, unavailable, confirm-clear,
|
|
1030
1522
|
// stream error) are pre-built here and handed to the kit, which renders them
|
|
@@ -1045,7 +1537,7 @@ function chatMain() {
|
|
|
1045
1537
|
// than as a separate stacked Alert, so resume context reads as one block.
|
|
1046
1538
|
banners.push(h('div', { key: 'rb', class: 'resume-banner', role: 'status' },
|
|
1047
1539
|
h('span', { key: 'rbtxt', class: 'lede' },
|
|
1048
|
-
'
|
|
1540
|
+
'next message continues session ' + state.chat.resumeSid.slice(0, 8) + '…'
|
|
1049
1541
|
+ (state.chat.resumeNote ? ' - ' + state.chat.resumeNote : '')),
|
|
1050
1542
|
Btn({ key: 'rclr', onClick: () => { state.chat.resumeSid = null; state.chat.resumeNote = null; render(); }, children: 'clear' })));
|
|
1051
1543
|
}
|
|
@@ -1057,19 +1549,51 @@ function chatMain() {
|
|
|
1057
1549
|
banners.push(Alert({ key: 'confnew', kind: 'warn', title: 'Clear chat history?',
|
|
1058
1550
|
children: [
|
|
1059
1551
|
h('span', { key: 'cntxt' }, 'This cannot be undone. '),
|
|
1060
|
-
Btn({ key: 'cnyes', danger: true, onClick:
|
|
1061
|
-
Btn({ key: 'cnno', onClick: () => { state.confirmingNewChat = false; render(); }, children: 'cancel' })] }));
|
|
1552
|
+
Btn({ key: 'cnyes', danger: true, onClick: confirmNewChat, children: 'yes, clear chat' }),
|
|
1553
|
+
Btn({ key: 'cnno', onClick: () => { clearTimeout(_newChatArmTimer); state.confirmingNewChat = false; render(); }, children: 'cancel' })] }));
|
|
1062
1554
|
}
|
|
1063
1555
|
if (state.cwdError) {
|
|
1064
1556
|
banners.push(Alert({ key: 'cwderr', kind: 'warn', title: 'Invalid working directory', children: state.cwdError }));
|
|
1065
1557
|
}
|
|
1558
|
+
if (state.chat.externalUpdate) {
|
|
1559
|
+
banners.push(Alert({ key: 'xupd', kind: 'info', title: 'This conversation was updated in another tab',
|
|
1560
|
+
children: [
|
|
1561
|
+
h('span', { key: 'xutxt' }, 'Reload it to see the latest turns, or dismiss to keep this tab\'s view. '),
|
|
1562
|
+
Btn({ key: 'xureload', disabled: state.chat.busy, onClick: () => {
|
|
1563
|
+
if (state.chat.busy) return;
|
|
1564
|
+
state.chat.externalUpdate = false;
|
|
1565
|
+
state.chat.messages = []; state.chat.resumeSid = null; state.chat.totalCost = 0;
|
|
1566
|
+
restoreChat(); render();
|
|
1567
|
+
}, children: 'reload it' }),
|
|
1568
|
+
Btn({ key: 'xudismiss', onClick: () => { state.chat.externalUpdate = false; render(); }, children: 'dismiss' })] }));
|
|
1569
|
+
}
|
|
1570
|
+
if (state.chat.persistError) {
|
|
1571
|
+
banners.push(Alert({ key: 'perr', kind: 'warn', title: 'Chat too large to save locally',
|
|
1572
|
+
children: [
|
|
1573
|
+
h('span', { key: 'petxt' }, 'New turns stay in this tab but will not survive a reload - export the transcript from settings. '),
|
|
1574
|
+
Btn({ key: 'pedismiss', onClick: () => { state.chat.persistError = false; render(); }, children: 'dismiss' })] }));
|
|
1575
|
+
}
|
|
1576
|
+
if (state.agentsError) {
|
|
1577
|
+
banners.push(Alert({ key: 'agerr', kind: 'error', title: 'Could not load agents from the server',
|
|
1578
|
+
children: [
|
|
1579
|
+
h('span', { key: 'agtxt', title: state.agentsError }, 'The agent list failed to load. '),
|
|
1580
|
+
Btn({ key: 'agretry', onClick: () => loadAgents(), children: 'retry' })] }));
|
|
1581
|
+
}
|
|
1582
|
+
if (state.chat.confirmingEdit) {
|
|
1583
|
+
banners.push(Alert({ key: 'confedit', kind: 'warn', title: 'Edit this message?',
|
|
1584
|
+
children: [
|
|
1585
|
+
h('span', { key: 'cetext' }, 'Editing will remove the later turns - continue? '),
|
|
1586
|
+
Btn({ key: 'ceyes', danger: true, onClick: confirmEditAndResend, children: 'continue' }),
|
|
1587
|
+
Btn({ key: 'ceno', onClick: cancelEditAndResend, children: 'cancel' })] }));
|
|
1588
|
+
}
|
|
1066
1589
|
const lastMsg = state.chat.messages.length ? state.chat.messages[state.chat.messages.length - 1] : null;
|
|
1067
1590
|
const lastErr = lastMsg ? lastMsg.error : null;
|
|
1068
1591
|
if (lastErr && !state.chat.busy) {
|
|
1069
1592
|
banners.push(Alert({ key: 'chaterr', kind: 'error', title: 'Stream error',
|
|
1070
1593
|
children: [
|
|
1071
|
-
h('span', { key: 'cetxt' }, lastErr + ' '),
|
|
1072
|
-
Btn({ key: '
|
|
1594
|
+
h('span', { key: 'cetxt', title: lastMsg.errorRaw || lastErr }, lastErr + ' '),
|
|
1595
|
+
Btn({ key: 'ceretry', onClick: () => { delete lastMsg.error; delete lastMsg.errorRaw; retryLastTurn(); }, children: 'retry' }),
|
|
1596
|
+
Btn({ key: 'cedismiss', onClick: () => { if (lastMsg) { delete lastMsg.error; delete lastMsg.errorRaw; persistChat(); render(); } }, children: 'dismiss' })] }));
|
|
1073
1597
|
}
|
|
1074
1598
|
|
|
1075
1599
|
const placeholder = !state.selectedAgent
|
|
@@ -1091,9 +1615,12 @@ function chatMain() {
|
|
|
1091
1615
|
messages: state.chat.messages,
|
|
1092
1616
|
busy: state.chat.busy,
|
|
1093
1617
|
draft: state.chat.draft,
|
|
1618
|
+
// Idle never reads 'resuming…' (nothing is in flight - the continuation
|
|
1619
|
+
// fact lives in the composer context line and banner); a remotely-stopped
|
|
1620
|
+
// turn reads 'stopped', not a normal finish.
|
|
1094
1621
|
status: state.chat.busy
|
|
1095
|
-
? (state.health.ws === 'reconnecting' ? '
|
|
1096
|
-
: (state.modelsLoading ? 'loading models…' : ((
|
|
1622
|
+
? (state.health.ws === 'reconnecting' ? 'connecting…' : 'streaming…')
|
|
1623
|
+
: (state.modelsLoading ? 'loading models…' : ((lastMsg && lastMsg.role === 'assistant' && lastMsg.stopped) ? 'stopped' : 'ready')),
|
|
1097
1624
|
agentName,
|
|
1098
1625
|
placeholder,
|
|
1099
1626
|
canSend: canSend(),
|
|
@@ -1103,25 +1630,68 @@ function chatMain() {
|
|
|
1103
1630
|
'What are the recent changes on this branch?',
|
|
1104
1631
|
'Find and explain the main entry point',
|
|
1105
1632
|
],
|
|
1106
|
-
|
|
1633
|
+
// Chips send their own text and never touch a typed draft (clicking a
|
|
1634
|
+
// chip with a half-typed message in the composer must not destroy it).
|
|
1635
|
+
onSuggestionClick: (t) => { if (!canSend()) return; sendChat(t); },
|
|
1107
1636
|
// W14: a stable per-agent product mark (a line-SVG, not a per-agent letter)
|
|
1108
1637
|
// and the active target shown inline above the composer.
|
|
1109
1638
|
avatar: state.selectedAgent ? h('span', { class: 'agentchat-avatar-mark', 'aria-hidden': 'true' }, Icon('forum', { size: 16 })) : undefined,
|
|
1110
1639
|
composerContext: state.selectedAgent ? {
|
|
1111
|
-
|
|
1112
|
-
|
|
1640
|
+
// Split click targets: only the cwd bit is interactive (it opens the
|
|
1641
|
+
// inline cwd editor); agent/model stay inert text - the picker above
|
|
1642
|
+
// already owns them. The resume fact is stated plainly at the point of
|
|
1643
|
+
// typing so send's behavior is never a surprise.
|
|
1644
|
+
bits: [
|
|
1645
|
+
agentName,
|
|
1646
|
+
state.selectedModel || null,
|
|
1647
|
+
{
|
|
1648
|
+
label: state.chatCwd ? state.chatCwd.split(/[/\\]/).filter(Boolean).slice(-1)[0] : 'server default',
|
|
1649
|
+
title: 'change working directory',
|
|
1650
|
+
onClick: () => { state.cwdEditing = true; state.cwdDraft = state.chatCwd || ''; state.cwdError = null; render(); },
|
|
1651
|
+
},
|
|
1652
|
+
userTurnCount > 0 ? userTurnCount + ' turns' : null,
|
|
1653
|
+
(state.chat.resumeSid && state.selectedAgent === 'claude-code')
|
|
1654
|
+
? 'continues session ' + state.chat.resumeSid.slice(0, 8)
|
|
1655
|
+
: null,
|
|
1656
|
+
].filter(Boolean),
|
|
1657
|
+
} : undefined,
|
|
1658
|
+
// W15: contextual follow-up chips derived from the settled last turn
|
|
1659
|
+
// (tool error / code fence / file path), seeded statically otherwise.
|
|
1660
|
+
followups: chatFollowups(),
|
|
1661
|
+
onFollowupClick: (t) => { if (!canSend()) return; sendChat(t); },
|
|
1662
|
+
// Transcript export: copy all / markdown / json.
|
|
1663
|
+
exportActions: state.chat.messages.length ? [
|
|
1664
|
+
{ label: 'copy all', title: 'Copy the whole transcript as markdown', onClick: () => copyText(transcriptToMarkdown(state.chat.messages), 'transcript copied') },
|
|
1665
|
+
{ label: 'export md', title: 'Download the transcript as markdown', onClick: () => downloadBlob(transcriptToMarkdown(state.chat.messages), 'agentgui-chat-' + dateStamp() + '.md', 'text/markdown') },
|
|
1666
|
+
{ label: 'export json', title: 'Download the transcript as JSON', onClick: () => downloadBlob(JSON.stringify(state.chat.messages, null, 2), 'agentgui-chat-' + dateStamp() + '.json', 'application/json') },
|
|
1667
|
+
] : [],
|
|
1668
|
+
// When the server reports agents but NONE is installed, show install
|
|
1669
|
+
// commands inline instead of a dead picker.
|
|
1670
|
+
installHint: (state.agents.length && state.agents.every(a => a.available === false)) ? {
|
|
1671
|
+
text: 'No agent CLIs found on the server.',
|
|
1672
|
+
commands: state.agents.filter(a => a.npxInstallable).map(a => ({ agent: a.name || a.id, command: 'npx ' + (a.npxPackage || a.id) })),
|
|
1673
|
+
onRecheck: () => loadAgents(),
|
|
1113
1674
|
} : undefined,
|
|
1114
|
-
// W15: contextual follow-up chips after a settled assistant turn.
|
|
1115
|
-
followups: state.chat.messages.length ? ['Explain that in more detail', 'Show me the diff', 'Run the tests'] : [],
|
|
1116
|
-
onFollowupClick: (t) => { if (!canSend()) return; state.chat.draft = t; sendChat(); },
|
|
1117
1675
|
// Per-message actions: copy any message; retry the last assistant turn;
|
|
1118
|
-
// edit-and-resend a user message (
|
|
1676
|
+
// edit-and-resend a user message (two-step: arm a confirm banner first,
|
|
1677
|
+
// since the truncation destroys the later turns).
|
|
1119
1678
|
onCopyMessage: (m) => copyMessageText(m),
|
|
1120
1679
|
onRetryMessage: () => retryLastTurn(),
|
|
1121
|
-
|
|
1680
|
+
confirmEdit: true,
|
|
1681
|
+
onArmEdit: (m) => armEditAndResend(m),
|
|
1682
|
+
onEditMessage: (m) => armEditAndResend(m),
|
|
1122
1683
|
cwd: state.chatCwd,
|
|
1123
1684
|
cwdEditing: !!state.cwdEditing,
|
|
1124
1685
|
cwdDraft: state.cwdDraft,
|
|
1686
|
+
cwdError: state.cwdError || null,
|
|
1687
|
+
cwdChecking: !!state.cwdChecking,
|
|
1688
|
+
// Pasting an image has no upload path on the chat surface yet - say so
|
|
1689
|
+
// politely instead of silently dropping it.
|
|
1690
|
+
onPasteFiles: () => announce('images cannot be attached here yet'),
|
|
1691
|
+
// Long threads render a capped window with a 'show earlier' row (the
|
|
1692
|
+
// chat-side equivalent of history's eventsLimit), reset per conversation.
|
|
1693
|
+
shownMessages: state.chat.shownMessages || undefined,
|
|
1694
|
+
onShowEarlier: (n) => { state.chat.shownMessages = n; render(); },
|
|
1125
1695
|
onSelectAgent: (v) => selectAgent(v),
|
|
1126
1696
|
onSelectModel: (v) => selectModel(v),
|
|
1127
1697
|
onNewChat: newChat,
|
|
@@ -1133,11 +1703,12 @@ function chatMain() {
|
|
|
1133
1703
|
const was = !!(state.chat.draft && state.chat.draft.trim());
|
|
1134
1704
|
const now = !!(v && v.trim());
|
|
1135
1705
|
state.chat.draft = v;
|
|
1706
|
+
debouncedPersistDraft(); // a typed-but-unsent draft survives reload
|
|
1136
1707
|
if (was !== now) render();
|
|
1137
1708
|
},
|
|
1138
1709
|
onSend: (v) => { state.chat.draft = v; sendChat(); },
|
|
1139
1710
|
onCwdEdit: () => { state.cwdEditing = true; state.cwdDraft = state.chatCwd || ''; state.cwdError = null; render(); },
|
|
1140
|
-
onCwdSave: () => {
|
|
1711
|
+
onCwdSave: async () => {
|
|
1141
1712
|
const path = (state.cwdDraft ?? '').trim();
|
|
1142
1713
|
// A relative cwd would resolve against the server process dir, not what
|
|
1143
1714
|
// the user means - require an absolute path (POSIX /..., UNC \\..., or
|
|
@@ -1147,14 +1718,28 @@ function chatMain() {
|
|
|
1147
1718
|
render();
|
|
1148
1719
|
return;
|
|
1149
1720
|
}
|
|
1721
|
+
// Validate against the server: the path must resolve and be a directory.
|
|
1722
|
+
if (path) {
|
|
1723
|
+
try {
|
|
1724
|
+
const st = await B.statPath(state.backend, path);
|
|
1725
|
+
if (!st || st.ok === false) { state.cwdError = 'directory not found on the server: ' + path; render(); return; }
|
|
1726
|
+
if (!st.dir) { state.cwdError = 'that path is not a directory'; render(); return; }
|
|
1727
|
+
} catch (e) {
|
|
1728
|
+
state.cwdError = e.status === 403 ? 'outside the allowed roots'
|
|
1729
|
+
: (e.status === 404 ? 'directory not found on the server: ' + path
|
|
1730
|
+
: (e.status ? 'directory not found on the server: ' + path : 'could not validate the path - server unreachable'));
|
|
1731
|
+
render();
|
|
1732
|
+
return;
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1150
1735
|
state.cwdError = null;
|
|
1151
1736
|
state.chatCwd = path;
|
|
1152
1737
|
if (state.chatCwd) lsSet('agentgui.cwd', state.chatCwd); else lsRemove('agentgui.cwd');
|
|
1153
1738
|
state.cwdEditing = false; state.cwdDraft = undefined; render();
|
|
1154
1739
|
},
|
|
1155
|
-
onCwdCancel: () => { state.cwdEditing = false; state.cwdDraft = undefined; state.cwdError = null; render(); },
|
|
1740
|
+
onCwdCancel: () => { state.cwdEditing = false; state.cwdDraft = undefined; state.cwdError = null; state.cwdChecking = false; render(); },
|
|
1156
1741
|
onCwdClear: () => { state.chatCwd = ''; lsRemove('agentgui.cwd'); render(); },
|
|
1157
|
-
onCwdDraft: (v) => { state.cwdDraft = v; },
|
|
1742
|
+
onCwdDraft: (v) => { state.cwdDraft = v; state.cwdError = null; debouncedCwdProbe(); },
|
|
1158
1743
|
}),
|
|
1159
1744
|
].filter(Boolean);
|
|
1160
1745
|
}
|
|
@@ -1168,14 +1753,28 @@ function offlineBanner() {
|
|
|
1168
1753
|
// (The working-directory bar now lives in the AgentChat kit; agentgui wires its
|
|
1169
1754
|
// cwd state + handlers as kit callbacks in chatMain.)
|
|
1170
1755
|
|
|
1756
|
+
// Destructive new-chat is two-step with DISTINCT arm/confirm controls: pressing
|
|
1757
|
+
// 'n' (or the rail action) only ARMS the banner; while armed, repeat presses
|
|
1758
|
+
// are no-ops and the arm auto-resets after 4s. Confirmation happens exclusively
|
|
1759
|
+
// via the banner's explicit 'clear' button - a double-tap can never wipe the
|
|
1760
|
+
// transcript.
|
|
1761
|
+
let _newChatArmTimer = null;
|
|
1171
1762
|
function newChat() {
|
|
1172
|
-
if (state.chat.messages.length
|
|
1173
|
-
state.confirmingNewChat
|
|
1763
|
+
if (state.chat.messages.length) {
|
|
1764
|
+
if (state.confirmingNewChat) { render(); return; } // armed: repeat press is a no-op
|
|
1765
|
+
state.confirmingNewChat = true;
|
|
1766
|
+
clearTimeout(_newChatArmTimer);
|
|
1767
|
+
_newChatArmTimer = setTimeout(() => { state.confirmingNewChat = false; render(); }, 4000);
|
|
1768
|
+
render();
|
|
1174
1769
|
return;
|
|
1175
1770
|
}
|
|
1771
|
+
confirmNewChat();
|
|
1772
|
+
}
|
|
1773
|
+
function confirmNewChat() {
|
|
1774
|
+
clearTimeout(_newChatArmTimer);
|
|
1176
1775
|
state.confirmingNewChat = false;
|
|
1177
1776
|
state.chat.abort?.abort();
|
|
1178
|
-
state.chat = { messages: [], busy: false, abort: null, draft: '', resumeSid: null, usage: null };
|
|
1777
|
+
state.chat = { messages: [], busy: false, abort: null, draft: '', resumeSid: null, usage: null, confirmingEdit: null, totalCost: 0, shownMessages: null };
|
|
1179
1778
|
lsRemove(CHAT_KEY);
|
|
1180
1779
|
render();
|
|
1181
1780
|
}
|
|
@@ -1186,10 +1785,13 @@ function cancelChat() {
|
|
|
1186
1785
|
state.chat.abort?.abort();
|
|
1187
1786
|
// Drop a trailing empty assistant shell (aborted before any content) from the
|
|
1188
1787
|
// live array so the message count + suggestions-empty check stay correct; the
|
|
1189
|
-
// paired user message is intentionally kept.
|
|
1788
|
+
// paired user message is intentionally kept. A turn stopped mid-content is
|
|
1789
|
+
// labeled 'stopped' so truncated output never reads as a finished answer.
|
|
1190
1790
|
const msgs = state.chat.messages;
|
|
1191
1791
|
if (msgs.length && isEmptyTurn(msgs[msgs.length - 1])) msgs.pop();
|
|
1792
|
+
else if (msgs.length && msgs[msgs.length - 1].role === 'assistant') msgs[msgs.length - 1].stopped = true;
|
|
1192
1793
|
if (state.chat.busy) { state.chat.busy = false; }
|
|
1794
|
+
refreshActive();
|
|
1193
1795
|
render();
|
|
1194
1796
|
}
|
|
1195
1797
|
|
|
@@ -1200,24 +1802,80 @@ const CHAT_KEY = 'agentgui.chat';
|
|
|
1200
1802
|
function isEmptyTurn(m) {
|
|
1201
1803
|
return m.role === 'assistant' && !m.content && !(Array.isArray(m.parts) && m.parts.length);
|
|
1202
1804
|
}
|
|
1805
|
+
// Cap the persisted footprint: the last 100 messages, with any tool args/result
|
|
1806
|
+
// payload over 4KB truncated in the SAVED copy only (in-memory state keeps the
|
|
1807
|
+
// full payload). A quota failure is surfaced once instead of silently dropping
|
|
1808
|
+
// persistence forever.
|
|
1809
|
+
const PERSIST_MSG_CAP = 100;
|
|
1810
|
+
const PERSIST_PART_CAP = 4096;
|
|
1811
|
+
let _persistFailedOnce = false;
|
|
1812
|
+
let _lastPersistTs = 0;
|
|
1813
|
+
function trimPartForStorage(p) {
|
|
1814
|
+
if (!p || typeof p !== 'object') return p;
|
|
1815
|
+
let out = p;
|
|
1816
|
+
for (const k of ['result', 'args']) {
|
|
1817
|
+
const v = out[k];
|
|
1818
|
+
if (v == null) continue;
|
|
1819
|
+
let s;
|
|
1820
|
+
if (typeof v === 'string') s = v;
|
|
1821
|
+
else { try { s = JSON.stringify(v); } catch { s = String(v); } }
|
|
1822
|
+
if (s.length > PERSIST_PART_CAP) {
|
|
1823
|
+
if (out === p) out = { ...p };
|
|
1824
|
+
out[k] = s.slice(0, PERSIST_PART_CAP);
|
|
1825
|
+
out.truncatedForStorage = true;
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
return out;
|
|
1829
|
+
}
|
|
1203
1830
|
function persistChat() {
|
|
1204
1831
|
try {
|
|
1205
1832
|
const msgs = state.chat.messages
|
|
1206
1833
|
.filter(m => !isEmptyTurn(m))
|
|
1207
|
-
.
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1834
|
+
.slice(-PERSIST_MSG_CAP)
|
|
1835
|
+
.map(m => ({ id: m.id, role: m.role, content: m.content, time: m.time, costUsd: m.costUsd, stopped: m.stopped || undefined, parts: Array.isArray(m.parts) ? m.parts.map(trimPartForStorage) : m.parts }));
|
|
1836
|
+
const draft = (state.chat.draft || '').trim() ? state.chat.draft : '';
|
|
1837
|
+
// Persist when there is a transcript OR a non-empty draft (a typed-but-not-
|
|
1838
|
+
// sent message must survive a reload too).
|
|
1839
|
+
if (!msgs.length && !draft) { lsRemove(CHAT_KEY); return; }
|
|
1840
|
+
_lastPersistTs = Date.now();
|
|
1841
|
+
localStorage.setItem(CHAT_KEY, JSON.stringify({ ts: _lastPersistTs, messages: msgs, draft, resumeSid: state.chat.resumeSid, totalCost: state.chat.totalCost || 0, agent: state.selectedAgent, model: state.selectedModel }));
|
|
1842
|
+
} catch {
|
|
1843
|
+
if (!_persistFailedOnce) {
|
|
1844
|
+
_persistFailedOnce = true;
|
|
1845
|
+
state.chat.persistError = true;
|
|
1846
|
+
announce('chat too large to save locally - export it from settings');
|
|
1847
|
+
scheduleRender();
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1211
1850
|
}
|
|
1851
|
+
const debouncedPersistDraft = debounce(persistChat, 500);
|
|
1852
|
+
// Another GUI tab rewrote the shared chat key: never silently diverge - surface
|
|
1853
|
+
// a banner offering to reload the newer copy (last-writer-wins with a ts guard).
|
|
1854
|
+
window.addEventListener('storage', (e) => {
|
|
1855
|
+
if (e.key !== CHAT_KEY || !e.newValue) return;
|
|
1856
|
+
try {
|
|
1857
|
+
const remote = JSON.parse(e.newValue);
|
|
1858
|
+
if (remote && remote.ts && remote.ts > _lastPersistTs) {
|
|
1859
|
+
state.chat.externalUpdate = true;
|
|
1860
|
+
scheduleRender();
|
|
1861
|
+
}
|
|
1862
|
+
} catch {}
|
|
1863
|
+
});
|
|
1212
1864
|
function restoreChat() {
|
|
1213
1865
|
try {
|
|
1214
1866
|
const raw = lsGet(CHAT_KEY);
|
|
1215
1867
|
if (!raw) return;
|
|
1216
1868
|
const saved = JSON.parse(raw);
|
|
1869
|
+
if (typeof saved.draft === 'string' && saved.draft.trim()) state.chat.draft = saved.draft;
|
|
1870
|
+
if (typeof saved.totalCost === 'number') state.chat.totalCost = saved.totalCost;
|
|
1217
1871
|
if (Array.isArray(saved.messages) && saved.messages.length) {
|
|
1218
1872
|
state.chat.messages = saved.messages
|
|
1219
1873
|
.filter(m => !isEmptyTurn(m))
|
|
1220
1874
|
.map(m => ({ ...m, parts: Array.isArray(m.parts) ? m.parts : [] }));
|
|
1875
|
+
// Prefer the per-message cost sum when present (it self-corrects after
|
|
1876
|
+
// edit/retry truncation); the scalar is only the legacy fallback.
|
|
1877
|
+
const derived = computeTotalCost();
|
|
1878
|
+
if (derived) state.chat.totalCost = derived;
|
|
1221
1879
|
state.chat.resumeSid = saved.resumeSid || null;
|
|
1222
1880
|
// Restore the agent/model the transcript belongs to, so a restored chat
|
|
1223
1881
|
// isn't silently shown under whatever agent the picker defaulted to.
|
|
@@ -1239,18 +1897,76 @@ function messageToText(m) {
|
|
|
1239
1897
|
return '';
|
|
1240
1898
|
}).filter(Boolean).join('\n');
|
|
1241
1899
|
}
|
|
1242
|
-
|
|
1243
|
-
|
|
1900
|
+
// Shared clipboard helper with an insecure-origin fallback; announces via the
|
|
1901
|
+
// aria-live region so AT users hear the result.
|
|
1902
|
+
function copyText(text, msg) {
|
|
1244
1903
|
if (!text) return;
|
|
1245
|
-
if (navigator.clipboard?.writeText) navigator.clipboard.writeText(text).then(() => announce(
|
|
1904
|
+
if (navigator.clipboard?.writeText) navigator.clipboard.writeText(text).then(() => announce(msg)).catch(() => {});
|
|
1246
1905
|
else {
|
|
1247
|
-
try { const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); announce(
|
|
1906
|
+
try { const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); announce(msg); } catch {}
|
|
1907
|
+
}
|
|
1908
|
+
}
|
|
1909
|
+
function copyMessageText(m) {
|
|
1910
|
+
copyText(messageToText(m), 'message copied');
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
// --- transcript export ---
|
|
1914
|
+
function dateStamp() { return new Date().toISOString().slice(0, 10); }
|
|
1915
|
+
function downloadBlob(content, filename, type) {
|
|
1916
|
+
const blob = new Blob([content], { type });
|
|
1917
|
+
const url = URL.createObjectURL(blob);
|
|
1918
|
+
const a = document.createElement('a');
|
|
1919
|
+
a.href = url; a.download = filename;
|
|
1920
|
+
document.body.appendChild(a); a.click(); a.remove();
|
|
1921
|
+
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
|
1922
|
+
}
|
|
1923
|
+
function transcriptToMarkdown(messages) {
|
|
1924
|
+
return (messages || []).map((m) => {
|
|
1925
|
+
const head = '## ' + (m.role === 'user' ? 'User' : 'Assistant') + (m.time ? ' (' + m.time + ')' : '');
|
|
1926
|
+
const parts = Array.isArray(m.parts) ? m.parts : [];
|
|
1927
|
+
const body = parts.length
|
|
1928
|
+
? parts.map((p) => {
|
|
1929
|
+
if (typeof p === 'string') return p;
|
|
1930
|
+
if (p.kind === 'md' || p.kind === 'text') return p.text || '';
|
|
1931
|
+
if (p.kind === 'tool' || p.kind === 'tool_result') {
|
|
1932
|
+
const bits = ['## tool: ' + (p.name || 'tool')];
|
|
1933
|
+
if (p.args && Object.keys(p.args).length) bits.push('```json\n' + JSON.stringify(p.args, null, 2) + '\n```');
|
|
1934
|
+
if (p.result != null) bits.push('```\n' + (typeof p.result === 'string' ? p.result : JSON.stringify(p.result, null, 2)) + '\n```');
|
|
1935
|
+
return bits.join('\n');
|
|
1936
|
+
}
|
|
1937
|
+
return '';
|
|
1938
|
+
}).filter(Boolean).join('\n\n')
|
|
1939
|
+
: (m.content || '');
|
|
1940
|
+
return head + '\n\n' + body;
|
|
1941
|
+
}).join('\n\n');
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
// Contextual follow-up chips derived from the settled last assistant turn;
|
|
1945
|
+
// falls back to the static seeds when nothing contextual applies.
|
|
1946
|
+
const FOLLOWUP_SEEDS = ['Explain that in more detail', 'Show me the diff', 'Run the tests'];
|
|
1947
|
+
function chatFollowups() {
|
|
1948
|
+
const msgs = state.chat.messages || [];
|
|
1949
|
+
if (!msgs.length || state.chat.busy) return [];
|
|
1950
|
+
const last = [...msgs].reverse().find(m => m.role === 'assistant');
|
|
1951
|
+
if (!last) return [];
|
|
1952
|
+
const out = [];
|
|
1953
|
+
const parts = Array.isArray(last.parts) ? last.parts : [];
|
|
1954
|
+
if (parts.some(p => p && p.kind === 'tool' && p.status === 'error')) out.push('Fix that error');
|
|
1955
|
+
const text = messageToText(last);
|
|
1956
|
+
if (/```/.test(text) || parts.some(p => p && p.kind === 'code')) out.push('Explain this code');
|
|
1957
|
+
const fm = text.match(/([A-Za-z]:\\\S+|\/[\w./-]+\.\w+)/);
|
|
1958
|
+
if (fm) {
|
|
1959
|
+
const base = fm[1].split(/[/\\]/).filter(Boolean).pop();
|
|
1960
|
+
if (base) out.push('Open ' + base.replace(/[^\w.\-]/g, '') + ' in files');
|
|
1248
1961
|
}
|
|
1962
|
+
return out.length ? out.slice(0, 3) : FOLLOWUP_SEEDS;
|
|
1249
1963
|
}
|
|
1250
1964
|
// Retry the last assistant turn: drop it and re-send the preceding user message.
|
|
1251
1965
|
function retryLastTurn() {
|
|
1252
1966
|
if (!canSend() && !state.chat.busy) { /* allow when idle */ }
|
|
1253
1967
|
if (state.chat.busy) return;
|
|
1968
|
+
// The armed edit confirm refers to indices this retry is about to shift.
|
|
1969
|
+
state.chat.confirmingEdit = null;
|
|
1254
1970
|
const msgs = state.chat.messages;
|
|
1255
1971
|
// Find the trailing assistant turn and the user message before it.
|
|
1256
1972
|
let ai = msgs.length - 1;
|
|
@@ -1262,33 +1978,65 @@ function retryLastTurn() {
|
|
|
1262
1978
|
const userText = msgs[ui].content || '';
|
|
1263
1979
|
// Drop the user+assistant pair (and anything after) and resend the user text.
|
|
1264
1980
|
state.chat.messages = msgs.slice(0, ui);
|
|
1981
|
+
state.chat.totalCost = computeTotalCost(); // discarded turns leave the spend
|
|
1265
1982
|
state.chat.draft = userText;
|
|
1266
1983
|
persistChat();
|
|
1267
1984
|
if (canSend()) sendChat();
|
|
1268
1985
|
else render();
|
|
1269
1986
|
}
|
|
1270
|
-
// Edit-and-resend a user message:
|
|
1271
|
-
//
|
|
1272
|
-
function
|
|
1987
|
+
// Edit-and-resend a user message: two-step. The edit click ARMS a confirmation
|
|
1988
|
+
// (truncating destroys the later turns), the banner's continue executes it.
|
|
1989
|
+
function armEditAndResend(m) {
|
|
1273
1990
|
if (state.chat.busy) return;
|
|
1274
1991
|
const idx = state.chat.messages.indexOf(m);
|
|
1275
1992
|
if (idx < 0) return;
|
|
1276
|
-
state.chat.
|
|
1277
|
-
|
|
1993
|
+
state.chat.confirmingEdit = { idx, text: m.content || messageToText(m) };
|
|
1994
|
+
render();
|
|
1995
|
+
}
|
|
1996
|
+
function confirmEditAndResend() {
|
|
1997
|
+
const ce = state.chat.confirmingEdit;
|
|
1998
|
+
if (!ce || state.chat.busy) return;
|
|
1999
|
+
state.chat.confirmingEdit = null;
|
|
2000
|
+
state.chat.messages = state.chat.messages.slice(0, ce.idx);
|
|
2001
|
+
state.chat.totalCost = computeTotalCost(); // discarded turns leave the spend
|
|
2002
|
+
// Never --resume a session whose tail diverged from what the server saw.
|
|
2003
|
+
state.chat.resumeSid = null;
|
|
2004
|
+
state.chat.resumeNote = null;
|
|
2005
|
+
state.chat.draft = ce.text;
|
|
1278
2006
|
persistChat();
|
|
1279
2007
|
render();
|
|
1280
2008
|
requestAnimationFrame(() => focusComposer());
|
|
1281
2009
|
}
|
|
2010
|
+
function cancelEditAndResend() {
|
|
2011
|
+
state.chat.confirmingEdit = null;
|
|
2012
|
+
render();
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
// Compute the conversation's spend from the per-message costUsd markers, so
|
|
2016
|
+
// edit/retry truncation self-corrects the total (no phantom spend from turns
|
|
2017
|
+
// that no longer exist in the transcript).
|
|
2018
|
+
function computeTotalCost() {
|
|
2019
|
+
return (state.chat.messages || []).reduce((s, m) => s + (typeof m.costUsd === 'number' ? m.costUsd : 0), 0);
|
|
2020
|
+
}
|
|
1282
2021
|
|
|
1283
|
-
|
|
1284
|
-
|
|
2022
|
+
// sendChat(text) sends an explicit text (suggestion/followup chips) WITHOUT
|
|
2023
|
+
// touching the typed draft; with no argument it sends + clears the draft.
|
|
2024
|
+
async function sendChat(textArg) {
|
|
2025
|
+
const text = ((textArg != null ? textArg : state.chat.draft) || '').trim();
|
|
1285
2026
|
if (!text || !canSend()) return;
|
|
2027
|
+
// The conversation is moving on: any armed edit-and-resend confirm refers to
|
|
2028
|
+
// an index that is about to be stale - disarm it.
|
|
2029
|
+
state.chat.confirmingEdit = null;
|
|
1286
2030
|
const t = timeNow();
|
|
1287
2031
|
const userMsg = { id: 'u' + Date.now(), role: 'user', content: text, time: t };
|
|
1288
2032
|
const curMsg = { id: 'a' + (Date.now() + 1), role: 'assistant', content: '', time: t, parts: [] };
|
|
1289
2033
|
state.chat.messages = [...state.chat.messages, userMsg, curMsg];
|
|
1290
|
-
state.chat.draft = '';
|
|
2034
|
+
if (textArg == null) state.chat.draft = '';
|
|
1291
2035
|
state.chat.busy = true;
|
|
2036
|
+
// Open the live stream + refresh the active list right away so the rail and
|
|
2037
|
+
// dashboard reflect this turn without waiting for the 3s poll.
|
|
2038
|
+
openLiveStream();
|
|
2039
|
+
refreshActive();
|
|
1292
2040
|
const ctrl = new AbortController();
|
|
1293
2041
|
state.chat.abort = ctrl;
|
|
1294
2042
|
persistChat();
|
|
@@ -1306,6 +2054,10 @@ async function sendChat() {
|
|
|
1306
2054
|
// another agent (it makes agy spuriously run --continue).
|
|
1307
2055
|
resumeSid: (state.selectedAgent === 'claude-code' && state.chat.resumeSid) || undefined,
|
|
1308
2056
|
})) {
|
|
2057
|
+
// After a stop, the iterator drains buffered events for a turn the user
|
|
2058
|
+
// aborted - applying them would set resumeSid / accrue cost / write text
|
|
2059
|
+
// into a popped shell. A stopped turn applies no further state.
|
|
2060
|
+
if (ctrl.signal.aborted) break;
|
|
1309
2061
|
if (ev.type === 'session') {
|
|
1310
2062
|
// Remember the server's session id so the NEXT turn resumes this
|
|
1311
2063
|
// conversation instead of cold-spawning. Only claude-code supports
|
|
@@ -1333,21 +2085,55 @@ async function sendChat() {
|
|
|
1333
2085
|
turns: b.num_turns != null ? b.num_turns : null,
|
|
1334
2086
|
durationMs: b.duration_ms != null ? b.duration_ms : null,
|
|
1335
2087
|
};
|
|
2088
|
+
// Per-message cost markers; the session total is DERIVED from the
|
|
2089
|
+
// visible messages so edit/retry truncation self-corrects the spend.
|
|
2090
|
+
const cost = typeof b.total_cost_usd === 'number' ? b.total_cost_usd : (typeof u.cost === 'number' ? u.cost : 0);
|
|
2091
|
+
if (cost) { cur.costUsd = (cur.costUsd || 0) + cost; state.chat.totalCost = computeTotalCost(); }
|
|
2092
|
+
scheduleStreamRender();
|
|
2093
|
+
}
|
|
2094
|
+
else if (ev.type === 'cancelled') {
|
|
2095
|
+
// Remote stop (another tab / dashboard): label the turn stopped so the
|
|
2096
|
+
// truncated output never reads as a finished answer.
|
|
2097
|
+
cur.stopped = true;
|
|
2098
|
+
announce('generation stopped');
|
|
1336
2099
|
scheduleStreamRender();
|
|
1337
2100
|
}
|
|
1338
|
-
else if (ev.type === 'error') { cur.error = errText(ev.error); render(); }
|
|
2101
|
+
else if (ev.type === 'error') { cur.error = errText(ev.error); cur.errorRaw = errTextRaw(ev.error); render(); }
|
|
1339
2102
|
}
|
|
1340
2103
|
} catch (e) {
|
|
1341
|
-
if (e.name !== 'AbortError') cur.error = errText(e.
|
|
2104
|
+
if (e.name !== 'AbortError') { cur.error = errText(e); cur.errorRaw = errTextRaw(e); }
|
|
1342
2105
|
} finally {
|
|
1343
2106
|
state.chat.busy = false;
|
|
1344
2107
|
state.chat.abort = null;
|
|
1345
2108
|
persistChat();
|
|
2109
|
+
refreshActive(); // settle the running panel/dashboard now, not at the next poll
|
|
1346
2110
|
render();
|
|
1347
2111
|
scrollChatToBottom();
|
|
1348
2112
|
}
|
|
1349
2113
|
}
|
|
1350
2114
|
|
|
2115
|
+
// Validate the cwd draft while editing (debounced) so an invalid path reads as
|
|
2116
|
+
// invalid before the save click, via the existing confined /api/stat endpoint.
|
|
2117
|
+
const debouncedCwdProbe = debounce(async () => {
|
|
2118
|
+
const path = (state.cwdDraft ?? '').trim();
|
|
2119
|
+
if (!state.cwdEditing) return;
|
|
2120
|
+
if (!path || !/^([/\\]|[A-Za-z]:[/\\])/.test(path)) { state.cwdChecking = false; render(); return; }
|
|
2121
|
+
state.cwdChecking = true; render();
|
|
2122
|
+
const probed = path;
|
|
2123
|
+
try {
|
|
2124
|
+
const st = await B.statPath(state.backend, probed);
|
|
2125
|
+
if ((state.cwdDraft ?? '').trim() !== probed) return; // draft moved on
|
|
2126
|
+
state.cwdError = (!st || st.ok === false) ? 'folder not found on the server'
|
|
2127
|
+
: (!st.dir ? 'that path is not a directory' : null);
|
|
2128
|
+
} catch (e) {
|
|
2129
|
+
if ((state.cwdDraft ?? '').trim() !== probed) return;
|
|
2130
|
+
state.cwdError = e.status === 403 ? 'outside the allowed roots'
|
|
2131
|
+
: (e.status === 404 ? 'folder not found on the server' : null);
|
|
2132
|
+
}
|
|
2133
|
+
state.cwdChecking = false;
|
|
2134
|
+
render();
|
|
2135
|
+
}, 400);
|
|
2136
|
+
|
|
1351
2137
|
// --- history ---
|
|
1352
2138
|
function reconnectAlert() {
|
|
1353
2139
|
if (!state.live.error) return null;
|
|
@@ -1359,6 +2145,48 @@ function reconnectAlert() {
|
|
|
1359
2145
|
});
|
|
1360
2146
|
}
|
|
1361
2147
|
|
|
2148
|
+
// Humanize a millisecond span (1m 30s scale words, no glyphs).
|
|
2149
|
+
function humanizeMs(ms) {
|
|
2150
|
+
if (ms == null || !isFinite(ms) || ms < 0) return '';
|
|
2151
|
+
const s = Math.round(ms / 1000);
|
|
2152
|
+
if (s < 60) return s + 's';
|
|
2153
|
+
if (s < 3600) return Math.floor(s / 60) + 'm ' + (s % 60) + 's';
|
|
2154
|
+
return Math.floor(s / 3600) + 'h ' + Math.floor((s % 3600) / 60) + 'm';
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
// First-to-last event timestamp span for the selected session, humanized.
|
|
2158
|
+
function sessionDuration() {
|
|
2159
|
+
const ts = (state.events || []).map(e => e.ts).filter(Boolean);
|
|
2160
|
+
if (ts.length < 2) return '';
|
|
2161
|
+
return humanizeMs(Math.max(...ts) - Math.min(...ts));
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
// Event-type filter predicate (all | text | tool | errors).
|
|
2165
|
+
function eventMatchesFilter(e, f) {
|
|
2166
|
+
if (f === 'tool') return e.type === 'tool_use' || e.type === 'tool_result';
|
|
2167
|
+
if (f === 'errors') return !!e.isError;
|
|
2168
|
+
if (f === 'text') return !e.isError && e.type !== 'tool_use' && e.type !== 'tool_result';
|
|
2169
|
+
return true;
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
// Scroll to + flash the first error event, widening the render window (and
|
|
2173
|
+
// clearing the type filter) so the row is actually rendered.
|
|
2174
|
+
function jumpToFirstError() {
|
|
2175
|
+
const idx = state.events.findIndex(e => e.isError);
|
|
2176
|
+
if (idx < 0) return;
|
|
2177
|
+
state.eventFilter = 'all';
|
|
2178
|
+
const fromEnd = state.events.length - idx;
|
|
2179
|
+
if (fromEnd > state.eventsLimit) state.eventsLimit = Math.ceil(fromEnd / 300) * 300;
|
|
2180
|
+
render();
|
|
2181
|
+
const sliceStart = Math.max(0, state.events.length - state.eventsLimit);
|
|
2182
|
+
const rowPos = idx - sliceStart;
|
|
2183
|
+
requestAnimationFrame(() => {
|
|
2184
|
+
const rows = document.querySelectorAll('.ds-event-list .row');
|
|
2185
|
+
const row = rows[rowPos];
|
|
2186
|
+
if (row) { row.scrollIntoView({ block: 'center' }); row.classList.add('event-flash'); setTimeout(() => row.classList.remove('event-flash'), 2000); }
|
|
2187
|
+
});
|
|
2188
|
+
}
|
|
2189
|
+
|
|
1362
2190
|
function historyMain() {
|
|
1363
2191
|
if (!state.selectedSid) {
|
|
1364
2192
|
const count = (Array.isArray(state.sessions) ? state.sessions : []).length;
|
|
@@ -1390,26 +2218,70 @@ function historyMain() {
|
|
|
1390
2218
|
lede,
|
|
1391
2219
|
});
|
|
1392
2220
|
|
|
1393
|
-
const
|
|
2221
|
+
const hasErrors = state.events.some(e => e.isError);
|
|
2222
|
+
const actions = h('div', { key: 'acts', class: 'history-actions', role: 'status', 'aria-live': 'polite' }, [
|
|
1394
2223
|
Btn({ key: 'resume', primary: true, onClick: () => resumeInChat(sess || { sid: state.selectedSid }), children: 'open in chat' }),
|
|
1395
2224
|
Btn({ key: 'copy', onClick: copySid, children: copyToast || 'copy sid' }),
|
|
1396
|
-
|
|
2225
|
+
Btn({ key: 'exportsess', disabled: !state.eventsLoaded, title: 'Download this session\'s events as JSON',
|
|
2226
|
+
onClick: () => downloadBlob(JSON.stringify(state.events, null, 2), (projectLabel(sess?.project) || 'session') + '-' + state.selectedSid + '.json', 'application/json'),
|
|
2227
|
+
children: 'export' }),
|
|
2228
|
+
hasErrors ? Btn({ key: 'jumperr', onClick: jumpToFirstError, children: 'jump to first error' }) : null,
|
|
2229
|
+
].filter(Boolean));
|
|
1397
2230
|
|
|
1398
2231
|
if (state.events.length === 0) {
|
|
1399
2232
|
// Distinguish "still loading" from "genuinely empty" so a 0-event session
|
|
1400
|
-
// doesn't spin forever.
|
|
2233
|
+
// doesn't spin forever. After 5s of an unresolved first fetch, swap to the
|
|
2234
|
+
// indexing copy (ccsniff's first JSONL walk can take a minute).
|
|
1401
2235
|
const body = state.eventsLoaded
|
|
1402
2236
|
? h('div', { key: 'noev', class: 'lede empty-state', role: 'status' },
|
|
1403
2237
|
h('span', { key: 'noevtxt' }, 'no events in this session - '),
|
|
1404
2238
|
Btn({ key: 'reload', onClick: () => loadSession(state.selectedSid), children: 'reload' }))
|
|
1405
|
-
: h('div', { key: 'loading', class: 'lede empty-state', role: 'status', 'aria-live': 'polite' }, Spinner({ key: 'spin', size: 'sm' }),
|
|
2239
|
+
: h('div', { key: 'loading', class: 'lede empty-state', role: 'status', 'aria-live': 'polite' }, Spinner({ key: 'spin', size: 'sm' }),
|
|
2240
|
+
state.eventsSlow ? 'Indexing your Claude history — the first load can take a minute…' : 'loading events…');
|
|
1406
2241
|
return [reconnectAlert(), head, actions, Panel({ title: 'events', children: body })].filter(Boolean);
|
|
1407
2242
|
}
|
|
1408
2243
|
|
|
1409
2244
|
if (!state.expandedEvents) state.expandedEvents = new Set();
|
|
1410
|
-
|
|
2245
|
+
// Event-type filter applies BEFORE the render-window slice so "errors" shows
|
|
2246
|
+
// every error in the session, not only errors among the most-recent 300.
|
|
2247
|
+
const ef = state.eventFilter || 'all';
|
|
2248
|
+
const filteredEvents = ef === 'all' ? state.events : state.events.filter(e => eventMatchesFilter(e, ef));
|
|
2249
|
+
const filterPills = FilterPills({
|
|
2250
|
+
options: [
|
|
2251
|
+
{ id: 'all', label: 'all' },
|
|
2252
|
+
{ id: 'text', label: 'text' },
|
|
2253
|
+
{ id: 'tool', label: 'tools' },
|
|
2254
|
+
{ id: 'errors', label: 'errors' },
|
|
2255
|
+
],
|
|
2256
|
+
selected: ef,
|
|
2257
|
+
onSelect: (id) => { state.eventFilter = id && id.id ? id.id : id; render(); },
|
|
2258
|
+
label: 'Filter events by type',
|
|
2259
|
+
});
|
|
2260
|
+
const meta = SessionMeta({
|
|
2261
|
+
items: [
|
|
2262
|
+
sess && sess.cwd ? { label: 'cwd', value: sess.cwd, title: sess.cwd } : null,
|
|
2263
|
+
sessionDuration() ? { label: 'duration', value: sessionDuration() } : null,
|
|
2264
|
+
{ label: 'sid', value: state.selectedSid.slice(0, 8) + '…', title: state.selectedSid, onCopy: () => copyText(state.selectedSid, 'sid copied') },
|
|
2265
|
+
// Spelled counter vocabulary in the detail strip (events/turns/tools/
|
|
2266
|
+
// errors); the abbreviated 'ev/tools/err' triple stays compact-row-only.
|
|
2267
|
+
{ label: 'events', value: String(state.events.length) },
|
|
2268
|
+
{ label: 'turns', value: String(sess?.userTurns ?? state.events.filter(e => e.role === 'user').length) },
|
|
2269
|
+
{ label: 'tools', value: String(state.events.filter(e => e.type === 'tool_use').length) },
|
|
2270
|
+
{ label: 'errors', value: String(state.events.filter(e => e.isError).length) },
|
|
2271
|
+
].filter(Boolean),
|
|
2272
|
+
});
|
|
2273
|
+
if (filteredEvents.length === 0) {
|
|
2274
|
+
return [reconnectAlert(), head, actions, Panel({ title: 'events', children: [
|
|
2275
|
+
h('div', { key: 'evmeta' }, meta),
|
|
2276
|
+
h('div', { key: 'evfp' }, filterPills),
|
|
2277
|
+
h('div', { key: 'nofilt', class: 'lede empty-state', role: 'status' },
|
|
2278
|
+
h('span', { key: 'noftxt' }, '0 events match this filter - '),
|
|
2279
|
+
Btn({ key: 'clearf', onClick: () => { state.eventFilter = 'all'; render(); }, children: 'clear filter' })),
|
|
2280
|
+
] })].filter(Boolean);
|
|
2281
|
+
}
|
|
2282
|
+
const total = filteredEvents.length;
|
|
1411
2283
|
const limit = state.eventsLimit;
|
|
1412
|
-
const shown =
|
|
2284
|
+
const shown = filteredEvents.slice(-limit);
|
|
1413
2285
|
const hiddenCount = total - shown.length;
|
|
1414
2286
|
// Keys of the currently-shown rows, so expand-all toggles only what's rendered.
|
|
1415
2287
|
const shownKeys = shown.map((e, i) => {
|
|
@@ -1432,8 +2304,8 @@ function historyMain() {
|
|
|
1432
2304
|
head,
|
|
1433
2305
|
actions,
|
|
1434
2306
|
Panel({
|
|
1435
|
-
title: total + ' events' + (hiddenCount > 0 ? ' (showing last ' + shown.length + '; ' + hiddenCount + ' older)' : ''),
|
|
1436
|
-
children: [eventControls, EventList({
|
|
2307
|
+
title: total + ' events' + (ef !== 'all' ? ' (' + ef + ' filter)' : '') + (hiddenCount > 0 ? ' (showing last ' + shown.length + '; ' + hiddenCount + ' older)' : ''),
|
|
2308
|
+
children: [h('div', { key: 'evmeta' }, meta), h('div', { key: 'evfp' }, filterPills), eventControls, EventList({
|
|
1437
2309
|
items: shown.map((e, i) => {
|
|
1438
2310
|
// Stable key: prefer the server-assigned event index, else the
|
|
1439
2311
|
// event timestamp + position, never a bare array index (which
|
|
@@ -1456,17 +2328,33 @@ function historyMain() {
|
|
|
1456
2328
|
// Rail tone matches the session/agents rail semantics so an event's
|
|
1457
2329
|
// kind is visible at a glance, consistent across the GUI:
|
|
1458
2330
|
// flame = error, purple = tool_use, green = normal turn.
|
|
1459
|
-
const rail = e.isError ? 'flame' : 'green';
|
|
2331
|
+
const rail = e.isError ? 'flame' : (e.type === 'tool_use' ? 'purple' : 'green');
|
|
2332
|
+
// When the session was opened from a search hit, window the collapsed
|
|
2333
|
+
// title AROUND the first query match (a match at char 5000 would
|
|
2334
|
+
// otherwise be invisible behind the 0-220 slice).
|
|
2335
|
+
let collapsedTitle = text.slice(0, 220);
|
|
2336
|
+
const q = state.sessionSearchQ;
|
|
2337
|
+
if (q && !expanded) {
|
|
2338
|
+
const qi = text.toLowerCase().indexOf(q.toLowerCase());
|
|
2339
|
+
if (qi > 60) collapsedTitle = '…' + text.slice(qi - 60, qi - 60 + 220);
|
|
2340
|
+
}
|
|
1460
2341
|
return {
|
|
1461
2342
|
key,
|
|
1462
2343
|
code: String(absIdx + 1).padStart(4, '0'),
|
|
1463
2344
|
rail,
|
|
1464
2345
|
expanded, // disclosure state -> kit Row sets aria-expanded
|
|
1465
|
-
|
|
2346
|
+
highlight: q || undefined,
|
|
2347
|
+
actions: expanded ? [{
|
|
2348
|
+
label: 'copy', title: 'copy event',
|
|
2349
|
+
onClick: () => copyText(full || raw || ('(' + type + ')'), 'event copied'),
|
|
2350
|
+
}] : undefined,
|
|
2351
|
+
title: expanded ? (full || '(' + type + ')') : (collapsedTitle || '(' + type + ')'),
|
|
1466
2352
|
// Guard ts: a missing/zero timestamp renders "Invalid Date" otherwise.
|
|
1467
2353
|
// Every row is click-to-expand, so always show the affordance word
|
|
1468
2354
|
// (not only when text overflows 220 chars).
|
|
1469
|
-
|
|
2355
|
+
// Relative time matches every other surface; the absolute stamp
|
|
2356
|
+
// appears when the row is expanded (forensic precision preserved).
|
|
2357
|
+
sub: (e.ts ? (expanded ? new Date(e.ts).toLocaleString() : fmtRelTime(e.ts)) : 'no time') + ' · ' + role + ' · ' + type + tool + errMark + ' · ' + (expanded ? 'collapse' : 'expand'),
|
|
1470
2358
|
onClick: () => { expanded ? state.expandedEvents.delete(key) : state.expandedEvents.add(key); render(); },
|
|
1471
2359
|
};
|
|
1472
2360
|
}),
|
|
@@ -1478,7 +2366,14 @@ function historyMain() {
|
|
|
1478
2366
|
let copyToast = null;
|
|
1479
2367
|
// Hold the toast long enough to read (2.5s); the copy button label is inside a
|
|
1480
2368
|
// role=status region (history-actions) so AT announces the change.
|
|
1481
|
-
|
|
2369
|
+
let _copyToastTimer = null;
|
|
2370
|
+
function setCopyToast(msg) {
|
|
2371
|
+
copyToast = msg; render();
|
|
2372
|
+
// One timer per invocation: a rapid second copy must not have its feedback
|
|
2373
|
+
// truncated by the first copy's expiring timeout.
|
|
2374
|
+
clearTimeout(_copyToastTimer);
|
|
2375
|
+
_copyToastTimer = setTimeout(() => { copyToast = null; render(); }, 2500);
|
|
2376
|
+
}
|
|
1482
2377
|
function copySid() {
|
|
1483
2378
|
const sid = state.selectedSid;
|
|
1484
2379
|
if (!sid) return;
|
|
@@ -1495,11 +2390,17 @@ function copySid() {
|
|
|
1495
2390
|
}
|
|
1496
2391
|
}
|
|
1497
2392
|
|
|
1498
|
-
function resumeInChat(sess) {
|
|
2393
|
+
function resumeInChat(sess, { fromHash = false } = {}) {
|
|
1499
2394
|
state.tab = 'chat';
|
|
1500
2395
|
closeLiveStream();
|
|
1501
2396
|
state.chat.resumeSid = sess?.sid || state.selectedSid;
|
|
2397
|
+
// Carry the sid on the chat tab too (tab=chat&sid=...) so reload/Back
|
|
2398
|
+
// restores the resumed conversation rather than a blank chat.
|
|
2399
|
+
state.selectedSid = state.chat.resumeSid || state.selectedSid;
|
|
2400
|
+
if (!fromHash) writeHash({ push: true });
|
|
1502
2401
|
state.chat.messages = [];
|
|
2402
|
+
state.chat.totalCost = 0;
|
|
2403
|
+
state.chat.shownMessages = null;
|
|
1503
2404
|
state.chat.draft = '';
|
|
1504
2405
|
// Only claude-code supports --resume by sid; warn if we have to switch the
|
|
1505
2406
|
// user's selected agent rather than silently discarding it.
|
|
@@ -1548,19 +2449,28 @@ function uniqueProjects() {
|
|
|
1548
2449
|
function runningPanel() {
|
|
1549
2450
|
const running = Array.isArray(state.active) ? state.active : [];
|
|
1550
2451
|
if (!running.length) return null;
|
|
2452
|
+
const stopping = state.live.stopping || new Set();
|
|
1551
2453
|
return Panel({
|
|
1552
2454
|
key: 'runningPanel',
|
|
1553
2455
|
title: 'running · ' + running.length,
|
|
1554
|
-
children:
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
2456
|
+
children: [
|
|
2457
|
+
// A discoverable path from "this running chat" to the management surface.
|
|
2458
|
+
h('div', { key: 'runall', class: 'resume-banner', role: 'group' },
|
|
2459
|
+
h('span', { key: 'runalllbl', class: 'lede' }, 'manage all running sessions'),
|
|
2460
|
+
Btn({ key: 'runalllive', onClick: () => navTo('live'), children: 'view all in live' })),
|
|
2461
|
+
...running.map((r) => {
|
|
2462
|
+
const agentName = agentById(r.agentId)?.name || r.agentId || 'agent';
|
|
2463
|
+
const elapsedMs = r.startedAt ? Math.max(0, Date.now() - r.startedAt) : 0;
|
|
2464
|
+
const isStopping = stopping.has(r.sessionId);
|
|
2465
|
+
// All children must be keyed VElements (mixing a keyed span with an
|
|
2466
|
+
// unkeyed one crashes webjsx applyDiff "reading 'key'").
|
|
2467
|
+
return h('div', { key: 'run' + r.sessionId, class: 'resume-banner', role: 'group' },
|
|
2468
|
+
h('span', { key: 'rd-' + r.sessionId, class: 'status-dot-disc status-dot-live', 'aria-hidden': 'true' }),
|
|
2469
|
+
h('span', { key: 'rl-' + r.sessionId, class: 'lede' }, agentName + (r.model ? ' · ' + r.model : '') + (elapsedMs ? ' · ' + fmtDuration(elapsedMs) : '') + (r.cwd ? ' · ' + r.cwd.split(/[/\\]/).filter(Boolean).slice(-1)[0] : '')),
|
|
2470
|
+
Btn({ key: 'open' + r.sessionId, onClick: () => navTo('live'), children: 'open in live' }),
|
|
2471
|
+
Btn({ key: 'stop' + r.sessionId, disabled: isStopping, onClick: () => stopActiveChat(r.sessionId), children: isStopping ? 'stopping…' : 'stop' }));
|
|
2472
|
+
}),
|
|
2473
|
+
],
|
|
1564
2474
|
});
|
|
1565
2475
|
}
|
|
1566
2476
|
|
|
@@ -1578,7 +2488,7 @@ function historySide() {
|
|
|
1578
2488
|
key: 'sr-' + (r.sid || '?') + '-' + i,
|
|
1579
2489
|
rank: String(i + 1).padStart(3, '0'),
|
|
1580
2490
|
title: r.snippet || '(no snippet)',
|
|
1581
|
-
sub: (r.project || '?') + ' · ' + (r.role || '?') + (r.tool ? ' · ' + r.tool : ''),
|
|
2491
|
+
sub: (projectLabel(r.project) || '?') + ' · ' + (r.role || '?') + (r.tool ? ' · ' + r.tool : '') + (r.ts ? ' · ' + fmtRelTime(r.ts) : ''),
|
|
1582
2492
|
// Rail carries the same semantics as session rows: error > subagent > normal.
|
|
1583
2493
|
rail: r.isError ? 'flame' : (r.isSubagent ? 'purple' : 'green'),
|
|
1584
2494
|
// Carry the matched event's index so loadSession scrolls to + flashes
|
|
@@ -1632,13 +2542,13 @@ function historySide() {
|
|
|
1632
2542
|
? h('p', { key: 'min2', class: 'lede empty-state' }, 'type at least 2 characters to search')
|
|
1633
2543
|
: null,
|
|
1634
2544
|
state.searchQ
|
|
1635
|
-
? Btn({ key: 'clearq', onClick: () => { state.searchQ = ''; state.searchHits = null; state.searchBusy = false; render(); }, children: 'clear search' })
|
|
2545
|
+
? Btn({ key: 'clearq', onClick: () => { state.searchQ = ''; state.searchHits = null; state.searchBusy = false; writeHash(); render(); }, children: 'clear search' })
|
|
1636
2546
|
: null,
|
|
1637
2547
|
!searching && projects.length > 1
|
|
1638
2548
|
? h('div', { key: 'projfilter', class: 'pill-row', role: 'group', 'aria-label': 'Filter sessions by project' },
|
|
1639
|
-
pillButton('allp', 'all', !state.projectFilter, 'Show all projects', () => { state.projectFilter = ''; render(); }),
|
|
2549
|
+
pillButton('allp', 'all', !state.projectFilter, 'Show all projects', () => { state.projectFilter = ''; writeHash(); render(); }),
|
|
1640
2550
|
...projects.slice(0, 8).map(([name, count]) =>
|
|
1641
|
-
pillButton('p'+name, truncate(projectLabel(name), 14, 20) + ' (' + count + ')', state.projectFilter === name, name, () => { state.projectFilter = state.projectFilter === name ? '' : name; render(); })))
|
|
2551
|
+
pillButton('p'+name, truncate(projectLabel(name), 14, 20) + ' (' + count + ')', state.projectFilter === name, name, () => { state.projectFilter = state.projectFilter === name ? '' : name; writeHash(); render(); })))
|
|
1642
2552
|
: null,
|
|
1643
2553
|
!searching && subagentCount
|
|
1644
2554
|
? h('label', { key: 'subtog', class: 'lede subagent-toggle' },
|
|
@@ -1682,11 +2592,13 @@ function normalizeBackend(s) {
|
|
|
1682
2592
|
async function saveBackend() {
|
|
1683
2593
|
if (!isValidUrl(state.backendDraft) || state.backendDraft === state.backend) return;
|
|
1684
2594
|
// Switching backend orphans the local chat transcript (it belongs to the old
|
|
1685
|
-
// server's sessions). Confirm once if there's a transcript to lose
|
|
1686
|
-
|
|
1687
|
-
|
|
2595
|
+
// server's sessions). Confirm once if there's a transcript to lose - and the
|
|
2596
|
+
// confirmation binds to the EXACT value confirmed: editing the URL after
|
|
2597
|
+
// arming re-arms instead of executing under a stale confirmation.
|
|
2598
|
+
if (state.chat.messages.length && state.confirmingBackend !== state.backendDraft) {
|
|
2599
|
+
state.confirmingBackend = state.backendDraft; render(); return;
|
|
1688
2600
|
}
|
|
1689
|
-
state.confirmingBackend =
|
|
2601
|
+
state.confirmingBackend = undefined;
|
|
1690
2602
|
const canonical = normalizeBackend(state.backendDraft);
|
|
1691
2603
|
state.backendDraft = canonical;
|
|
1692
2604
|
B.setBackend(canonical);
|
|
@@ -1730,15 +2642,19 @@ function settingsMain() {
|
|
|
1730
2642
|
PageHeader({
|
|
1731
2643
|
compact: true,
|
|
1732
2644
|
title: 'Settings',
|
|
1733
|
-
lede
|
|
2645
|
+
// The page lede describes the PAGE; the backend explanation lives in the
|
|
2646
|
+
// backend panel where it applies.
|
|
2647
|
+
lede: 'Connection, agents, appearance, keyboard, and local data.',
|
|
1734
2648
|
}),
|
|
1735
2649
|
h('div', { key: 'settings-grid', class: 'settings-grid' }, [
|
|
1736
2650
|
Panel({
|
|
2651
|
+
id: 'backend',
|
|
1737
2652
|
title: 'backend',
|
|
1738
2653
|
children: h('form', {
|
|
1739
2654
|
key: 'backendForm',
|
|
1740
2655
|
onSubmit: (e) => { e.preventDefault(); saveBackend(); },
|
|
1741
2656
|
}, [
|
|
2657
|
+
h('p', { key: 'blede', class: 'lede', style: 'margin:0 0 .5em' }, 'Point agentgui at any backend. Blank = same-origin (ccsniff in-process). ?backend=... or the field below persists via localStorage.'),
|
|
1742
2658
|
TextField({
|
|
1743
2659
|
key: 'backendField',
|
|
1744
2660
|
label: 'backend url',
|
|
@@ -1747,13 +2663,19 @@ function settingsMain() {
|
|
|
1747
2663
|
'aria-describedby': !isValid ? 'backend-url-error' : undefined,
|
|
1748
2664
|
'aria-invalid': !isValid ? 'true' : 'false',
|
|
1749
2665
|
title: isValid ? 'Enter a valid URL or leave blank for same-origin' : 'Invalid URL format',
|
|
1750
|
-
onInput: (v) => {
|
|
2666
|
+
onInput: (v) => {
|
|
2667
|
+
state.backendDraft = v;
|
|
2668
|
+
// The armed confirmation refers to a different value now - disarm.
|
|
2669
|
+
if (state.confirmingBackend !== undefined && state.confirmingBackend !== v) state.confirmingBackend = undefined;
|
|
2670
|
+
render();
|
|
2671
|
+
},
|
|
1751
2672
|
}),
|
|
1752
2673
|
!isValid ? h('p', { key: 'err', id: 'backend-url-error', class: 'lede field-error', role: 'alert' }, 'Invalid URL format') : null,
|
|
1753
2674
|
state.backendStatus === 'connecting' ? h('p', { key: 'bst-connecting', class: 'lede', role: 'status' }, 'connecting…') : null,
|
|
1754
2675
|
state.backendStatus === 'ok' ? h('p', { key: 'bst-ok', class: 'lede', role: 'status' }, 'connected') : null,
|
|
1755
2676
|
state.backendStatus === 'failed' ? h('p', { key: 'bst-failed', class: 'lede field-error', role: 'alert' }, 'connection failed - check the URL') : null,
|
|
1756
|
-
state.confirmingBackend
|
|
2677
|
+
(state.confirmingBackend !== undefined && state.confirmingBackend === state.backendDraft && isValid && state.backendDraft !== state.backend)
|
|
2678
|
+
? h('p', { key: 'bcw', class: 'lede field-error', role: 'alert' }, 'changing backend discards this browser\'s chat transcript - press save again to confirm') : null,
|
|
1757
2679
|
healthSummary(),
|
|
1758
2680
|
Btn({
|
|
1759
2681
|
key: 'savebtn',
|
|
@@ -1766,19 +2688,56 @@ function settingsMain() {
|
|
|
1766
2688
|
}),
|
|
1767
2689
|
]),
|
|
1768
2690
|
}),
|
|
2691
|
+
// Panel order: connection group (backend + server) first, then agents,
|
|
2692
|
+
// then personal preferences (appearance / keyboard / data).
|
|
2693
|
+
serverPanel(),
|
|
2694
|
+
agentsPanel(),
|
|
1769
2695
|
Panel({
|
|
2696
|
+
id: 'appearance',
|
|
1770
2697
|
title: 'appearance',
|
|
1771
2698
|
children: [
|
|
1772
2699
|
h('div', { key: 'apl', class: 'lede', style: 'margin-bottom:.5em' }, 'theme - follows the OS in auto, or pick light/dark.'),
|
|
1773
2700
|
ThemeToggle({ key: 'tt' }),
|
|
1774
2701
|
],
|
|
1775
2702
|
}),
|
|
1776
|
-
|
|
2703
|
+
keyboardPanel(),
|
|
1777
2704
|
preferencesPanel(),
|
|
1778
2705
|
]),
|
|
1779
2706
|
];
|
|
1780
2707
|
}
|
|
1781
2708
|
|
|
2709
|
+
// Server info from /health: version, uptime, ws clients, allowed roots (with
|
|
2710
|
+
// per-root copy), projects dir. Renders unknowns plainly rather than hiding.
|
|
2711
|
+
function serverPanel() {
|
|
2712
|
+
const hh = state.health || {};
|
|
2713
|
+
const roots = Array.isArray(hh.allowRoots) ? hh.allowRoots : [];
|
|
2714
|
+
const upMs = hh.uptimeMs != null ? hh.uptimeMs : (hh.uptime != null ? hh.uptime * 1000 : null);
|
|
2715
|
+
const wsClients = hh.wsClients != null ? hh.wsClients : (hh.clients != null ? hh.clients : null);
|
|
2716
|
+
return Panel({
|
|
2717
|
+
id: 'server',
|
|
2718
|
+
title: 'server',
|
|
2719
|
+
children: [
|
|
2720
|
+
h('div', { key: 'sv', class: 'lede' }, 'version: ' + (hh.version ? 'v' + hh.version : 'unknown')),
|
|
2721
|
+
h('div', { key: 'sup', class: 'lede' }, 'uptime: ' + (upMs != null ? humanizeMs(upMs) : 'unknown')),
|
|
2722
|
+
h('div', { key: 'swc', class: 'lede' }, 'ws clients: ' + (wsClients != null ? wsClients : 'unknown')),
|
|
2723
|
+
h('div', { key: 'spd', class: 'lede' }, 'projects dir: ' + (hh.projectsDir || 'unknown')),
|
|
2724
|
+
roots.length
|
|
2725
|
+
? SessionMeta({ key: 'sroots', items: roots.map((r, i) => ({ label: 'root ' + (i + 1), value: r, title: r, onCopy: () => copyText(r, 'root copied') })) })
|
|
2726
|
+
: h('div', { key: 'snoroots', class: 'lede' }, 'allowed roots: unknown'),
|
|
2727
|
+
],
|
|
2728
|
+
});
|
|
2729
|
+
}
|
|
2730
|
+
|
|
2731
|
+
// The keyboard panel consumes the same SHORTCUTS definition as the ?-overlay.
|
|
2732
|
+
function keyboardPanel() {
|
|
2733
|
+
return Panel({
|
|
2734
|
+
id: 'keyboard',
|
|
2735
|
+
title: 'keyboard',
|
|
2736
|
+
children: SHORTCUTS.map((s, i) =>
|
|
2737
|
+
h('div', { key: 'kb' + i, class: 'lede' }, s.keys + ' - ' + s.desc)),
|
|
2738
|
+
});
|
|
2739
|
+
}
|
|
2740
|
+
|
|
1782
2741
|
function clearLocalData() {
|
|
1783
2742
|
if (!state.confirmingClearData) { state.confirmingClearData = true; render(); return; }
|
|
1784
2743
|
state.confirmingClearData = false;
|
|
@@ -1788,19 +2747,38 @@ function clearLocalData() {
|
|
|
1788
2747
|
// re-persist them, so the "clear" wouldn't survive. A reload re-inits from
|
|
1789
2748
|
// defaults with the keys gone.
|
|
1790
2749
|
state.chat.abort?.abort(); // stop any in-flight stream before we drop the page
|
|
1791
|
-
for (const k of ['agentgui.chat', 'agentgui.agent', 'agentgui.model', 'agentgui.cwd', 'agentgui.backend']) lsRemove(k);
|
|
1792
|
-
state.chat = { messages: [], busy: false, abort: null, draft: '', resumeSid: null };
|
|
2750
|
+
for (const k of ['agentgui.chat', 'agentgui.agent', 'agentgui.model', 'agentgui.cwd', 'agentgui.backend', 'agentgui.live', 'agentgui.files']) lsRemove(k);
|
|
2751
|
+
state.chat = { messages: [], busy: false, abort: null, draft: '', resumeSid: null, confirmingEdit: null, totalCost: 0 };
|
|
1793
2752
|
location.reload();
|
|
1794
2753
|
}
|
|
1795
2754
|
|
|
1796
2755
|
function preferencesPanel() {
|
|
1797
2756
|
const hh = state.health || {};
|
|
2757
|
+
const savedChat = lsGet(CHAT_KEY);
|
|
2758
|
+
// Footprint of agentgui's own localStorage keys.
|
|
2759
|
+
let lsKeys = 0, lsBytes = 0;
|
|
2760
|
+
try {
|
|
2761
|
+
for (let i = 0; i < localStorage.length; i++) {
|
|
2762
|
+
const k = localStorage.key(i);
|
|
2763
|
+
if (k && k.startsWith('agentgui.')) { lsKeys++; lsBytes += (localStorage.getItem(k) || '').length; }
|
|
2764
|
+
}
|
|
2765
|
+
} catch {}
|
|
1798
2766
|
return Panel({
|
|
1799
|
-
|
|
2767
|
+
id: 'data',
|
|
2768
|
+
title: 'data',
|
|
1800
2769
|
children: [
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
2770
|
+
// The server version lives in the server panel; carry only a build stamp
|
|
2771
|
+
// here, and only when it differs from the server version.
|
|
2772
|
+
(window.__SERVER_VERSION && window.__SERVER_VERSION !== hh.version)
|
|
2773
|
+
? h('div', { key: 'ver', class: 'lede' }, 'build ' + window.__SERVER_VERSION)
|
|
2774
|
+
: null,
|
|
2775
|
+
h('div', { key: 'lsize', class: 'lede', style: 'margin:.5em 0' },
|
|
2776
|
+
'local data: ' + fmtBytes(lsBytes) + ' across ' + lsKeys + ' key' + (lsKeys === 1 ? '' : 's')),
|
|
2777
|
+
h('div', { key: 'expchatrow', style: 'margin:.5em 0' },
|
|
2778
|
+
Btn({ key: 'expchat', disabled: !savedChat,
|
|
2779
|
+
title: savedChat ? 'Download the saved chat transcript as JSON' : 'no saved chat',
|
|
2780
|
+
onClick: savedChat ? () => downloadBlob(savedChat, 'agentgui-chat-' + dateStamp() + '.json', 'application/json') : undefined,
|
|
2781
|
+
children: savedChat ? 'export chat' : 'no saved chat' })),
|
|
1804
2782
|
h('div', { key: 'cwdnote', class: 'lede', style: 'margin:.5em 0' },
|
|
1805
2783
|
'working directory: set per-chat in the chat composer’s cwd bar' + (state.chatCwd ? ' (current: ' + state.chatCwd + ')' : ' (currently: server default)')),
|
|
1806
2784
|
state.confirmingClearData
|
|
@@ -1810,7 +2788,7 @@ function preferencesPanel() {
|
|
|
1810
2788
|
Btn({ key: 'cldyes', danger: true, onClick: clearLocalData, children: 'clear' }),
|
|
1811
2789
|
Btn({ key: 'cldno', onClick: () => { state.confirmingClearData = false; render(); }, children: 'cancel' })] })
|
|
1812
2790
|
: Btn({ key: 'cldbtn', onClick: clearLocalData, children: 'clear local data' }),
|
|
1813
|
-
],
|
|
2791
|
+
].filter(Boolean),
|
|
1814
2792
|
});
|
|
1815
2793
|
}
|
|
1816
2794
|
|
|
@@ -1823,6 +2801,7 @@ function agentsPanel() {
|
|
|
1823
2801
|
const installed = state.agents.filter(a => a.available !== false);
|
|
1824
2802
|
const hasAcp = (Array.isArray(state.health.acp) ? state.health.acp : []).length > 0;
|
|
1825
2803
|
return Panel({
|
|
2804
|
+
id: 'agents',
|
|
1826
2805
|
title: 'agents · ' + installed.length + '/' + state.agents.length + ' installed',
|
|
1827
2806
|
children: [
|
|
1828
2807
|
// ACP agents (opencode/kilo/codex) are managed automatically: started
|
|
@@ -1863,11 +2842,33 @@ function agentsPanel() {
|
|
|
1863
2842
|
|
|
1864
2843
|
// --- data ---
|
|
1865
2844
|
async function refreshHistory() {
|
|
2845
|
+
// Warmup copy: the FIRST sessions fetch can sit behind ccsniff's 30-90s
|
|
2846
|
+
// JSONL walk; after 5s swap the loading copy to indexing language.
|
|
2847
|
+
const firstLoad = !state._historyLoadedOnce;
|
|
2848
|
+
const slowTimer = firstLoad
|
|
2849
|
+
? setTimeout(() => { if (!state._historyLoadedOnce) { state.historySlow = true; render(); } }, 5000)
|
|
2850
|
+
: null;
|
|
1866
2851
|
try {
|
|
1867
2852
|
state.sessions = await B.listSessions(state.backend);
|
|
2853
|
+
state._historyLoadedOnce = true;
|
|
2854
|
+
state.historySlow = false;
|
|
1868
2855
|
// Index by sid so each live SSE event is an O(1) lookup, not an O(sessions)
|
|
1869
2856
|
// linear scan per event during a burst load.
|
|
1870
2857
|
state.sessionsBySid = new Map((state.sessions || []).map(s => [s.sid, s]));
|
|
2858
|
+
// Bound the live tally: drop entries with no activity in 24h and cap the
|
|
2859
|
+
// Map at ~200 most-recent sids (a long-lived tab otherwise accumulates
|
|
2860
|
+
// every sid ever seen, and dead entries could resurrect wrong externals).
|
|
2861
|
+
if (state.live.tally) {
|
|
2862
|
+
const cutoff = Date.now() - 24 * 3600 * 1000;
|
|
2863
|
+
for (const [sid, t] of [...state.live.tally]) {
|
|
2864
|
+
if (!t.last || t.last < cutoff) state.live.tally.delete(sid);
|
|
2865
|
+
}
|
|
2866
|
+
if (state.live.tally.size > 200) {
|
|
2867
|
+
state.live.tally = new Map([...state.live.tally.entries()]
|
|
2868
|
+
.sort((a, b) => (b[1].last || 0) - (a[1].last || 0))
|
|
2869
|
+
.slice(0, 200));
|
|
2870
|
+
}
|
|
2871
|
+
}
|
|
1871
2872
|
// If the selected session vanished from the list (deleted/aged out server-side),
|
|
1872
2873
|
// drop the selection so the main pane doesn't sit on stale events that can no
|
|
1873
2874
|
// longer be reloaded; fall back to the no-selection empty state.
|
|
@@ -1875,34 +2876,42 @@ async function refreshHistory() {
|
|
|
1875
2876
|
state.selectedSid = null;
|
|
1876
2877
|
state.events = [];
|
|
1877
2878
|
state.eventsLoaded = false;
|
|
1878
|
-
writeHash(
|
|
2879
|
+
writeHash();
|
|
1879
2880
|
}
|
|
1880
2881
|
state.historyError = null;
|
|
1881
2882
|
} catch (e) {
|
|
1882
2883
|
// Only a genuine fetch/list failure is a history error. A render exception
|
|
1883
2884
|
// must not masquerade as one (it would poison the sessions panel with a
|
|
1884
2885
|
// render-stack string and never clear), so render() lives outside this try.
|
|
1885
|
-
state.historyError = e
|
|
2886
|
+
state.historyError = errText(e);
|
|
1886
2887
|
console.warn('history fetch failed:', e.message);
|
|
1887
2888
|
}
|
|
2889
|
+
if (slowTimer) clearTimeout(slowTimer);
|
|
1888
2890
|
render();
|
|
1889
2891
|
}
|
|
1890
2892
|
const debouncedRefreshHistory = debounce(refreshHistory, 500);
|
|
1891
2893
|
|
|
1892
2894
|
async function runSearch() {
|
|
1893
2895
|
const q = state.searchQ.trim();
|
|
1894
|
-
if (!q) { state.searchHits = null; state.searchBusy = false; render(); return; }
|
|
1895
|
-
if (q.length < 2) { state.searchHits = null; state.searchBusy = false; render(); return; }
|
|
2896
|
+
if (!q) { state.searchHits = null; state.searchBusy = false; writeHash(); render(); return; }
|
|
2897
|
+
if (q.length < 2) { state.searchHits = null; state.searchBusy = false; writeHash(); render(); return; }
|
|
1896
2898
|
// The project-filter pills are hidden while searching; clear the filter so it
|
|
1897
2899
|
// doesn't silently re-apply (and surprise the user) when they later clear the
|
|
1898
2900
|
// search and the now-visible session list is unexpectedly narrowed.
|
|
1899
2901
|
state.projectFilter = '';
|
|
2902
|
+
// The debounced search keeps the URL's q=/project= in sync via replaceState
|
|
2903
|
+
// so a reload (or share) restores the search, without flooding history.
|
|
2904
|
+
writeHash();
|
|
1900
2905
|
state.searchBusy = true;
|
|
1901
2906
|
render();
|
|
1902
2907
|
try {
|
|
1903
2908
|
state.searchHits = await B.searchHistory(state.backend, q, 60);
|
|
2909
|
+
// Announce the settled count for AT - the sessions-column count is only
|
|
2910
|
+
// rendered visually (the history actions row is the only aria-live region).
|
|
2911
|
+
const n = (state.searchHits.results || []).length;
|
|
2912
|
+
announce((n || 'no') + ' matches for ' + q);
|
|
1904
2913
|
} catch (e) {
|
|
1905
|
-
state.searchHits = { query: q, results: [], error: e
|
|
2914
|
+
state.searchHits = { query: q, results: [], error: errText(e) };
|
|
1906
2915
|
} finally {
|
|
1907
2916
|
state.searchBusy = false;
|
|
1908
2917
|
render();
|
|
@@ -1910,18 +2919,26 @@ async function runSearch() {
|
|
|
1910
2919
|
}
|
|
1911
2920
|
const debouncedSearch = debounce(runSearch, 300);
|
|
1912
2921
|
|
|
1913
|
-
async function loadSession(sid, { focusEventI = null, focusEventTs = null } = {}) {
|
|
2922
|
+
async function loadSession(sid, { focusEventI = null, focusEventTs = null, fromHash = false } = {}) {
|
|
1914
2923
|
// Guard against a bad sid from a malformed hash (e.g. "?sid=undefined").
|
|
1915
2924
|
if (!sid || sid === 'undefined' || sid === 'null') { state.selectedSid = null; render(); return; }
|
|
1916
2925
|
state.selectedSid = sid;
|
|
1917
2926
|
state.events = [];
|
|
1918
2927
|
state.eventsLoaded = false;
|
|
2928
|
+
state.eventsSlow = false;
|
|
1919
2929
|
state.eventsLimit = 300; // reset the render window per session
|
|
2930
|
+
state.eventFilter = 'all'; // don't carry the type filter across sessions
|
|
1920
2931
|
state.expandedEvents = new Set(); // don't carry expansion to the new session
|
|
2932
|
+
// Remember the query this session was opened FROM (search hit) so the event
|
|
2933
|
+
// rows can highlight + window around the match; a plain selection clears it.
|
|
2934
|
+
state.sessionSearchQ = (focusEventI != null || focusEventTs != null) && state.searchQ.trim().length >= 2
|
|
2935
|
+
? state.searchQ.trim() : null;
|
|
1921
2936
|
// The live "live · N" crumb counter reads as the selected session's activity,
|
|
1922
2937
|
// so reset it per selection rather than letting it accrue across all sessions.
|
|
1923
2938
|
state.live.eventCount = 0;
|
|
1924
|
-
writeHash(
|
|
2939
|
+
writeHash({ push: !fromHash });
|
|
2940
|
+
// Warmup copy: a first events fetch can sit behind ccsniff's JSONL walk.
|
|
2941
|
+
const slowTimer = setTimeout(() => { if (!state.eventsLoaded && state.selectedSid === sid) { state.eventsSlow = true; render(); } }, 5000);
|
|
1925
2942
|
// Close the mobile sidebar drawer on selection. The DS only auto-closes when
|
|
1926
2943
|
// the clicked element is an <a>; agentgui's session rows are onClick divs, so
|
|
1927
2944
|
// we close it explicitly here.
|
|
@@ -1934,6 +2951,12 @@ async function loadSession(sid, { focusEventI = null, focusEventTs = null } = {}
|
|
|
1934
2951
|
});
|
|
1935
2952
|
try {
|
|
1936
2953
|
state.events = await B.getSessionEvents(state.backend, sid);
|
|
2954
|
+
// ccsniff's events route has no ?limit= (checked: router.js returns the
|
|
2955
|
+
// whole session) - cap in-memory state at the most-recent 5000 so a
|
|
2956
|
+
// monster session can't pin the tab; the render window stays 300+load-older.
|
|
2957
|
+
if (state.events.length > 5000) state.events = state.events.slice(-5000);
|
|
2958
|
+
clearTimeout(slowTimer);
|
|
2959
|
+
state.eventsSlow = false;
|
|
1937
2960
|
state.eventsLoaded = true;
|
|
1938
2961
|
// If we arrived from a search hit, make sure the matched event is within the
|
|
1939
2962
|
// render window, then scroll to + flash it so the match isn't lost.
|
|
@@ -1961,21 +2984,20 @@ async function loadSession(sid, { focusEventI = null, focusEventTs = null } = {}
|
|
|
1961
2984
|
ts: Date.now(),
|
|
1962
2985
|
role: 'error',
|
|
1963
2986
|
type: 'fetch',
|
|
1964
|
-
text: 'Failed to load session: ' + e
|
|
2987
|
+
text: 'Failed to load session: ' + errText(e) + ' - retry via sidebar',
|
|
1965
2988
|
}];
|
|
2989
|
+
clearTimeout(slowTimer);
|
|
2990
|
+
state.eventsSlow = false;
|
|
1966
2991
|
state.eventsLoaded = true;
|
|
1967
2992
|
render();
|
|
1968
2993
|
}
|
|
1969
2994
|
}
|
|
1970
2995
|
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
state.health = { status: 'error', error: e.message };
|
|
1977
|
-
}
|
|
1978
|
-
render();
|
|
2996
|
+
// Fetch agents + pick the active one. Reusable: boot, backend save, and the
|
|
2997
|
+
// reconnect path all re-run it. Returns true on success; failure lands in
|
|
2998
|
+
// state.agentsError so the chat tab can surface it with a retry control.
|
|
2999
|
+
async function loadAgents() {
|
|
3000
|
+
state.agentsError = null;
|
|
1979
3001
|
try {
|
|
1980
3002
|
state.agents = await B.listAgents(state.backend);
|
|
1981
3003
|
// Agent selection priority: the agent a restored transcript belongs to (so
|
|
@@ -1986,15 +3008,85 @@ async function init() {
|
|
|
1986
3008
|
if (!target) target = state.agents.find(a => a.available !== false) || state.agents[0];
|
|
1987
3009
|
if (target) await selectAgent(target.id);
|
|
1988
3010
|
render();
|
|
1989
|
-
|
|
3011
|
+
return true;
|
|
3012
|
+
} catch (e) {
|
|
3013
|
+
state.agentsError = errText(e);
|
|
3014
|
+
console.warn('agents fetch failed:', e.message);
|
|
3015
|
+
render();
|
|
3016
|
+
return false;
|
|
3017
|
+
}
|
|
3018
|
+
}
|
|
3019
|
+
|
|
3020
|
+
// Boot-time automatic retries with backoff when the first agents fetch fails
|
|
3021
|
+
// (the server may still be warming up).
|
|
3022
|
+
async function retryLoadAgents() {
|
|
3023
|
+
for (const ms of [2000, 5000, 10000]) {
|
|
3024
|
+
await new Promise(r => setTimeout(r, ms));
|
|
3025
|
+
if (!state.agentsError) return; // a manual retry already succeeded
|
|
3026
|
+
if (await loadAgents()) return;
|
|
3027
|
+
}
|
|
3028
|
+
}
|
|
3029
|
+
|
|
3030
|
+
// While the backend is unreachable, poll /health every ~10s; on recovery
|
|
3031
|
+
// re-fetch agents + models and announce, so a mid-session outage self-heals.
|
|
3032
|
+
let _healthPollTimer = null;
|
|
3033
|
+
function stopHealthPolling() { if (_healthPollTimer) { clearInterval(_healthPollTimer); _healthPollTimer = null; } }
|
|
3034
|
+
function startHealthPolling() {
|
|
3035
|
+
if (_healthPollTimer) return;
|
|
3036
|
+
_healthPollTimer = setInterval(() => {
|
|
3037
|
+
if (state.health.status === 'ok') { stopHealthPolling(); return; }
|
|
3038
|
+
recheckHealth();
|
|
3039
|
+
}, 10000);
|
|
3040
|
+
}
|
|
3041
|
+
async function recheckHealth() {
|
|
3042
|
+
const wasOk = state.health.status === 'ok';
|
|
3043
|
+
const r = await B.probeBackend(state.backend);
|
|
3044
|
+
if (r.ok) {
|
|
3045
|
+
state.health = { status: 'ok', ...r.info };
|
|
3046
|
+
stopHealthPolling();
|
|
3047
|
+
if (!wasOk) { announce('reconnected'); loadAgents(); }
|
|
3048
|
+
} else {
|
|
3049
|
+
state.health = { status: 'down', ...r };
|
|
3050
|
+
startHealthPolling();
|
|
3051
|
+
}
|
|
3052
|
+
render();
|
|
3053
|
+
}
|
|
1990
3054
|
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
3055
|
+
async function init() {
|
|
3056
|
+
try {
|
|
3057
|
+
const r = await B.probeBackend(state.backend);
|
|
3058
|
+
state.health = r.ok ? { status: 'ok', ...r.info } : { status: 'down', ...r };
|
|
3059
|
+
} catch (e) {
|
|
3060
|
+
state.health = { status: 'error', error: e.message };
|
|
3061
|
+
}
|
|
3062
|
+
render();
|
|
3063
|
+
if (!(await loadAgents())) retryLoadAgents();
|
|
3064
|
+
|
|
3065
|
+
const hp = readHash();
|
|
3066
|
+
const bootTab = hp.tab || (hp.sid ? 'history' : 'chat');
|
|
3067
|
+
if (hp.sid && bootTab === 'chat') {
|
|
3068
|
+
// tab=chat&sid deep link: resume the conversation in chat.
|
|
3069
|
+
resumeInChat({ sid: hp.sid }, { fromHash: true });
|
|
3070
|
+
refreshHistory();
|
|
3071
|
+
} else if (hp.sid) {
|
|
3072
|
+
if (hp.q) state.searchQ = hp.q;
|
|
3073
|
+
if (hp.project) state.projectFilter = hp.project;
|
|
3074
|
+
navTo('history', { push: false });
|
|
1994
3075
|
await refreshHistory();
|
|
1995
|
-
await loadSession(
|
|
1996
|
-
|
|
1997
|
-
|
|
3076
|
+
await loadSession(hp.sid, { fromHash: true });
|
|
3077
|
+
if (state.searchQ.trim().length >= 2) runSearch();
|
|
3078
|
+
} else if (bootTab !== state.tab) {
|
|
3079
|
+
// Files deep-link: restore the directory the URL names (reload keeps
|
|
3080
|
+
// context instead of resetting to the default root). Kick the load BEFORE
|
|
3081
|
+
// navTo - loadDir sets loading=true synchronously, so navTo's default
|
|
3082
|
+
// loadDir('') guard skips and the two never race. Once the listing
|
|
3083
|
+
// resolves, restore the file= preview on top of it.
|
|
3084
|
+
if (bootTab === 'files' && hp.dir) loadDir(hp.dir, { fromHash: true }).then(() => restoreFileFromHash(hp.file));
|
|
3085
|
+
if (bootTab === 'history' && hp.q) state.searchQ = hp.q;
|
|
3086
|
+
if (bootTab === 'history' && hp.project) state.projectFilter = hp.project;
|
|
3087
|
+
navTo(bootTab, { push: false });
|
|
3088
|
+
if (bootTab === 'history' && state.searchQ.trim().length >= 2) runSearch();
|
|
3089
|
+
if (bootTab === 'settings' && hp.section) focusSettingsSection(hp.section);
|
|
1998
3090
|
}
|
|
1999
3091
|
|
|
2000
3092
|
registerWsStatusOnce();
|
|
@@ -2015,12 +3107,17 @@ function registerWsStatusOnce() {
|
|
|
2015
3107
|
B.onWsStatus?.((s) => {
|
|
2016
3108
|
if (s === 'closed' || s === 'error') {
|
|
2017
3109
|
if (state.health.status === 'ok') { state.health = { ...state.health, ws: 'reconnecting' }; render(); }
|
|
3110
|
+
// The WS dropping often means the whole server went away - re-probe
|
|
3111
|
+
// /health so the offline banner appears mid-session (and the 10s
|
|
3112
|
+
// recovery poll starts) instead of silently retrying forever.
|
|
3113
|
+
recheckHealth();
|
|
2018
3114
|
} else if (s === 'open') {
|
|
2019
3115
|
if (state.health.ws) { delete state.health.ws; render(); }
|
|
2020
3116
|
}
|
|
2021
3117
|
});
|
|
2022
3118
|
}
|
|
2023
3119
|
|
|
3120
|
+
hydratePrefs();
|
|
2024
3121
|
restoreChat();
|
|
2025
3122
|
render = mount(document.getElementById('app'), view);
|
|
2026
3123
|
requestAnimationFrame(syncAriaCurrent);
|
|
@@ -2029,12 +3126,60 @@ requestAnimationFrame(syncAriaCurrent);
|
|
|
2029
3126
|
// (they read window.innerWidth only at render time).
|
|
2030
3127
|
window.addEventListener('resize', debounce(() => scheduleRender(), 150));
|
|
2031
3128
|
|
|
2032
|
-
//
|
|
3129
|
+
// Scroll to + focus a settings panel by its id (section= deep link), reusing
|
|
3130
|
+
// the data-prog-focus pattern so the programmatic focus ring is suppressed.
|
|
3131
|
+
function focusSettingsSection(id) {
|
|
3132
|
+
state.settingsSection = id;
|
|
3133
|
+
requestAnimationFrame(() => {
|
|
3134
|
+
const el = document.getElementById(id);
|
|
3135
|
+
if (!el) return;
|
|
3136
|
+
el.scrollIntoView({ block: 'start' });
|
|
3137
|
+
if (!el.hasAttribute('tabindex')) el.setAttribute('tabindex', '-1');
|
|
3138
|
+
el.setAttribute('data-prog-focus', '');
|
|
3139
|
+
try { el.focus({ preventScroll: true }); } catch {}
|
|
3140
|
+
const clear = () => { el.removeAttribute('data-prog-focus'); el.removeEventListener('blur', clear); };
|
|
3141
|
+
el.addEventListener('blur', clear);
|
|
3142
|
+
});
|
|
3143
|
+
}
|
|
3144
|
+
|
|
3145
|
+
// Browser Back/forward: diff the FULL hash param set against state and re-sync
|
|
3146
|
+
// each piece. Everything here runs with writeHash:false / fromHash:true so the
|
|
3147
|
+
// handler never clobbers or re-pushes the entry it just popped.
|
|
2033
3148
|
window.addEventListener('popstate', () => {
|
|
2034
|
-
const
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
3149
|
+
const hp = readHash();
|
|
3150
|
+
const tab = hp.tab || 'chat';
|
|
3151
|
+
if (tab !== state.tab) navTo(tab, { writeHash: false });
|
|
3152
|
+
// Session selection: honor tab= when present (tab=chat&sid resumes in chat,
|
|
3153
|
+
// anything else - or a bare sid - opens it in history).
|
|
3154
|
+
if (hp.sid && hp.sid !== state.selectedSid) {
|
|
3155
|
+
if (tab === 'chat') resumeInChat({ sid: hp.sid }, { fromHash: true });
|
|
3156
|
+
else loadSession(hp.sid, { fromHash: true });
|
|
3157
|
+
} else if (!hp.sid && state.selectedSid && tab === 'history') {
|
|
3158
|
+
state.selectedSid = null;
|
|
3159
|
+
state.events = [];
|
|
3160
|
+
state.eventsLoaded = false;
|
|
3161
|
+
}
|
|
3162
|
+
if (tab === 'files') {
|
|
3163
|
+
const cur = state.files && state.files.path;
|
|
3164
|
+
if (hp.dir && hp.dir !== cur) {
|
|
3165
|
+
// Restore the file= preview only once the directory listing resolves
|
|
3166
|
+
// (the entry object lives in state.files.entries).
|
|
3167
|
+
loadDir(hp.dir, { fromHash: true }).then(() => restoreFileFromHash(hp.file));
|
|
3168
|
+
} else {
|
|
3169
|
+
restoreFileFromHash(hp.file);
|
|
3170
|
+
}
|
|
3171
|
+
}
|
|
3172
|
+
if (tab === 'history') {
|
|
3173
|
+
const q = hp.q || '';
|
|
3174
|
+
if (q !== state.searchQ) {
|
|
3175
|
+
state.searchQ = q;
|
|
3176
|
+
if (q.trim().length >= 2) runSearch();
|
|
3177
|
+
else { state.searchHits = null; state.searchBusy = false; }
|
|
3178
|
+
}
|
|
3179
|
+
state.projectFilter = hp.project || '';
|
|
3180
|
+
}
|
|
3181
|
+
if (tab === 'settings' && hp.section) focusSettingsSection(hp.section);
|
|
3182
|
+
render();
|
|
2038
3183
|
});
|
|
2039
3184
|
|
|
2040
3185
|
window.__agentgui = { state, render };
|
|
@@ -2050,14 +3195,49 @@ function focusSearch() {
|
|
|
2050
3195
|
const el = document.querySelector('#app input[type="search"]');
|
|
2051
3196
|
el?.focus();
|
|
2052
3197
|
}
|
|
3198
|
+
// Focus the active surface's filter input (Files grid filter / Live dashboard
|
|
3199
|
+
// filter) - both are search-type inputs inside the main region.
|
|
3200
|
+
function focusFilter() {
|
|
3201
|
+
const el = document.querySelector('#agentgui-main input[type="search"]')
|
|
3202
|
+
|| document.querySelector('#agentgui-main .ds-file-filter-input')
|
|
3203
|
+
|| document.querySelector('#agentgui-main input[type="text"]');
|
|
3204
|
+
el?.focus();
|
|
3205
|
+
return !!el;
|
|
3206
|
+
}
|
|
2053
3207
|
window.addEventListener('keydown', (e) => {
|
|
2054
3208
|
const t = e.target;
|
|
2055
3209
|
const typing = t && (t.tagName === 'TEXTAREA' || t.tagName === 'INPUT' || t.isContentEditable);
|
|
3210
|
+
// One explicit chord BEFORE the modifier early-return: Mod+Shift+L focuses
|
|
3211
|
+
// the composer from anywhere, even while typing in another field.
|
|
3212
|
+
if ((e.metaKey || e.ctrlKey) && e.shiftKey && (e.key === 'L' || e.key === 'l')) {
|
|
3213
|
+
e.preventDefault();
|
|
3214
|
+
navTo('chat');
|
|
3215
|
+
requestAnimationFrame(() => focusComposer());
|
|
3216
|
+
announce('composer focused');
|
|
3217
|
+
return;
|
|
3218
|
+
}
|
|
2056
3219
|
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
|
2057
3220
|
if (typing) {
|
|
2058
3221
|
if (e.key === 'Escape') t.blur();
|
|
2059
3222
|
return;
|
|
2060
3223
|
}
|
|
3224
|
+
if (e.key === 'Escape') {
|
|
3225
|
+
// Priority ladder for transient state (modals/drawers are kit-handled):
|
|
3226
|
+
// shortcuts overlay > armed confirms > stop a streaming generation.
|
|
3227
|
+
if (state.showShortcuts) { state.showShortcuts = false; render(); announce('shortcuts closed'); return; }
|
|
3228
|
+
// File-mutation dialog: close on Escape wherever focus sits (the kit's
|
|
3229
|
+
// backdrop listener covers in-dialog focus; this covers everything else).
|
|
3230
|
+
if (state.files.dialog) { if (!state.files.dialog.busy) closeFileDialog(); return; }
|
|
3231
|
+
if (state.chat.confirmingEdit) { state.chat.confirmingEdit = null; render(); announce('edit cancelled'); return; }
|
|
3232
|
+
if (state.confirmingNewChat) { clearTimeout(_newChatArmTimer); state.confirmingNewChat = false; render(); announce('new chat cancelled'); return; }
|
|
3233
|
+
if (state.live.confirmingStopAll || state.live.confirmingStopSelected) {
|
|
3234
|
+
state.live.confirmingStopAll = false; state.live.confirmingStopSelected = false;
|
|
3235
|
+
clearTimeout(_stopAllArmTimer); clearTimeout(_stopSelArmTimer);
|
|
3236
|
+
render(); announce('stop cancelled'); return;
|
|
3237
|
+
}
|
|
3238
|
+
if (state.chat.busy && state.tab === 'chat') { cancelChat(); announce('generation stopped'); return; }
|
|
3239
|
+
return;
|
|
3240
|
+
}
|
|
2061
3241
|
if (gPending) {
|
|
2062
3242
|
gPending = false;
|
|
2063
3243
|
if (e.key === 'c') { navTo('chat'); return; }
|
|
@@ -2070,13 +3250,24 @@ window.addEventListener('keydown', (e) => {
|
|
|
2070
3250
|
if (e.key === 'g') { gPending = true; setTimeout(() => { gPending = false; }, 1000); return; }
|
|
2071
3251
|
if (e.key === 'n' && state.tab === 'chat') { e.preventDefault(); newChat(); return; }
|
|
2072
3252
|
if (e.key === '/') {
|
|
2073
|
-
// /
|
|
2074
|
-
//
|
|
3253
|
+
// / targets the active surface's find affordance: search on history,
|
|
3254
|
+
// composer on chat, the filter inputs on files/live. Settings has no
|
|
3255
|
+
// field - the only documented no-op.
|
|
2075
3256
|
if (state.tab === 'history') { e.preventDefault(); focusSearch(); announce('search focused'); }
|
|
2076
3257
|
else if (state.tab === 'chat') { e.preventDefault(); focusComposer(); announce('composer focused'); }
|
|
3258
|
+
else if (state.tab === 'files' || state.tab === 'live') { e.preventDefault(); if (focusFilter()) announce('filter focused'); }
|
|
2077
3259
|
return;
|
|
2078
3260
|
}
|
|
2079
3261
|
if (e.key === '?') { state.showShortcuts = !state.showShortcuts; render(); return; }
|
|
2080
3262
|
});
|
|
2081
3263
|
|
|
3264
|
+
// A file dropped anywhere outside a DropZone must never navigate the browser
|
|
3265
|
+
// away (destroying the live session view). DropZones handle their own events.
|
|
3266
|
+
window.addEventListener('dragover', (e) => {
|
|
3267
|
+
if (!(e.target instanceof Element) || !e.target.closest('.ds-dropzone')) e.preventDefault();
|
|
3268
|
+
});
|
|
3269
|
+
window.addEventListener('drop', (e) => {
|
|
3270
|
+
if (!(e.target instanceof Element) || !e.target.closest('.ds-dropzone')) e.preventDefault();
|
|
3271
|
+
});
|
|
3272
|
+
|
|
2082
3273
|
init();
|