agentgui 1.0.959 → 1.0.961
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-claude-code-parity.js +88 -0
- package/.claude/workflows/gui-logic-predictability.js +88 -0
- package/AGENTS.md +27 -9
- package/PUNCHLIST-LOGIC.md +405 -0
- package/PUNCHLIST-PARITY.md +36 -0
- package/lib/broadcast.js +1 -0
- package/lib/ws-handlers-util.js +42 -4
- package/package.json +1 -1
- package/server.js +27 -0
- package/site/app/js/app.js +676 -137
- package/site/app/js/backend.js +25 -1
- package/site/app/vendor/anentrypoint-design/247420.css +363 -50
- package/site/app/vendor/anentrypoint-design/247420.js +15 -13
package/site/app/js/app.js
CHANGED
|
@@ -5,6 +5,12 @@ installStyles().catch(() => {});
|
|
|
5
5
|
|
|
6
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
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');
|
|
13
|
+
|
|
8
14
|
const state = {
|
|
9
15
|
backend: B.getBackend(),
|
|
10
16
|
backendDraft: B.getBackend(),
|
|
@@ -31,7 +37,7 @@ const state = {
|
|
|
31
37
|
showSubagents: false,
|
|
32
38
|
sessionsLimit: 60,
|
|
33
39
|
projectFilter: '',
|
|
34
|
-
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 },
|
|
35
41
|
active: [],
|
|
36
42
|
activeTimer: null,
|
|
37
43
|
eventsLimit: 300, // how many of the most-recent events to render; grows via "load older"
|
|
@@ -84,6 +90,9 @@ function writeHash({ push = false } = {}) {
|
|
|
84
90
|
function fmtRelTime(ts) {
|
|
85
91
|
if (!ts) return '';
|
|
86
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';
|
|
87
96
|
if (s < 60) return s + 's ago';
|
|
88
97
|
if (s < 3600) return Math.round(s/60) + 'm ago';
|
|
89
98
|
if (s < 86400) return Math.round(s/3600) + 'h ago';
|
|
@@ -104,6 +113,18 @@ function scheduleRender() {
|
|
|
104
113
|
let streamRenderScheduled = false;
|
|
105
114
|
function scheduleStreamRender() {
|
|
106
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 {}
|
|
107
128
|
streamRenderScheduled = true;
|
|
108
129
|
requestAnimationFrame(() => {
|
|
109
130
|
streamRenderScheduled = false;
|
|
@@ -241,7 +262,11 @@ function navTo(tab, { writeHash: doWriteHash = true, push = true } = {}) {
|
|
|
241
262
|
// Live history SSE feeds both the History tab (event log) and the Live
|
|
242
263
|
// dashboard (per-session activity tally + stream-health signal); open it on
|
|
243
264
|
// either, close it when leaving both. Active-chat polling runs globally.
|
|
244
|
-
|
|
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)));
|
|
245
270
|
if (wantsStream(tab)) {
|
|
246
271
|
if (tab === 'history') refreshHistory();
|
|
247
272
|
openLiveStream();
|
|
@@ -296,13 +321,22 @@ function syncAriaCurrent() {
|
|
|
296
321
|
// re-renders every row on each render). Only re-render when the set actually
|
|
297
322
|
// changed (or while on the live tab, where elapsed advances every second).
|
|
298
323
|
function activeSig(list) {
|
|
299
|
-
|
|
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('|');
|
|
300
327
|
}
|
|
301
328
|
async function refreshActive() {
|
|
302
329
|
let next;
|
|
303
330
|
try { next = await B.listActiveChats(state.backend); } catch { return; }
|
|
304
331
|
const changed = activeSig(next) !== activeSig(state.active);
|
|
305
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
|
+
}
|
|
306
340
|
// The live tab needs the steady elapsed tick regardless; elsewhere only
|
|
307
341
|
// re-render when the active set genuinely changed.
|
|
308
342
|
if (changed || state.tab === 'live') render();
|
|
@@ -333,8 +367,16 @@ function startLiveTick() {
|
|
|
333
367
|
}, 1000);
|
|
334
368
|
}
|
|
335
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();
|
|
336
374
|
try { await B.cancelChat(state.backend, sid); } catch {}
|
|
337
|
-
|
|
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();
|
|
338
380
|
}
|
|
339
381
|
|
|
340
382
|
function openLiveStream() {
|
|
@@ -344,16 +386,27 @@ function openLiveStream() {
|
|
|
344
386
|
try {
|
|
345
387
|
state.live.es = B.streamHistory(state.backend, (kind, data) => {
|
|
346
388
|
state.live.lastEventTs = Date.now();
|
|
347
|
-
|
|
389
|
+
// Only real event frames count - hello/error frames inflated the number.
|
|
390
|
+
if (kind === 'event') state.live.eventCount++;
|
|
348
391
|
if (kind === 'hello') {
|
|
349
392
|
if (!state.live.connected) state.live.connected = true;
|
|
350
|
-
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
|
+
}
|
|
351
401
|
} else if (kind === 'event' && data) {
|
|
352
402
|
// ccsniff's stream frame is { sid, payload: <flattened event> } - the
|
|
353
403
|
// real event (type/isError/i/ts) lives under .payload, not on the
|
|
354
404
|
// wrapper. Reading the wrapper gave every counter `undefined`, so live
|
|
355
405
|
// tool/error tallies never moved. Route by data.sid, read ev for the rest.
|
|
356
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;
|
|
357
410
|
if (state.selectedSid && data.sid === state.selectedSid) {
|
|
358
411
|
// Dedupe against the snapshot/prior pushes by event index - a
|
|
359
412
|
// reconnect or overlap would otherwise double-append the same event.
|
|
@@ -369,18 +422,32 @@ function openLiveStream() {
|
|
|
369
422
|
// exists yet.
|
|
370
423
|
if (data.sid) {
|
|
371
424
|
state.live.tally = state.live.tally || new Map();
|
|
372
|
-
const t = state.live.tally.get(data.sid) || { events: 0, tools: 0, errors: 0, last: 0 };
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
+
}
|
|
376
439
|
state.live.tally.set(data.sid, t);
|
|
377
440
|
}
|
|
378
441
|
const sess = state.sessionsBySid ? state.sessionsBySid.get(data.sid) : null;
|
|
379
442
|
if (sess) {
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
+
}
|
|
384
451
|
} else {
|
|
385
452
|
// Unknown session: a burst of events for a new session would trigger
|
|
386
453
|
// a full session-list refetch per event - debounce it into one. The
|
|
@@ -424,9 +491,11 @@ function closeLiveStream() {
|
|
|
424
491
|
const SHORTCUTS = [
|
|
425
492
|
{ keys: 'g then c / h / f / l / s', desc: 'switch tabs (chat / history / files / live / settings)' },
|
|
426
493
|
{ keys: 'n', desc: 'new chat (on the chat tab)' },
|
|
427
|
-
{ keys: '/', desc: 'focus search
|
|
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)' },
|
|
428
497
|
{ keys: '?', desc: 'show shortcuts' },
|
|
429
|
-
{ keys: 'Esc', desc: 'blur the
|
|
498
|
+
{ keys: 'Esc', desc: 'close overlays, cancel confirms, stop generation, or blur the field' },
|
|
430
499
|
];
|
|
431
500
|
|
|
432
501
|
function view() {
|
|
@@ -436,7 +505,7 @@ function view() {
|
|
|
436
505
|
? (state.live.error
|
|
437
506
|
? state.live.error + (state.live.reconnects ? ' · ' + state.live.reconnects + ' reconnects' : '')
|
|
438
507
|
: (liveActive ? 'live · ' + state.live.eventCount : (state.live.connected ? 'live' : 'connecting…')))
|
|
439
|
-
: (ok ? (state.health.ws === 'reconnecting' ? '
|
|
508
|
+
: (ok ? (state.health.ws === 'reconnecting' ? 'connecting' : 'connected') : 'offline');
|
|
440
509
|
const dotLive = state.tab === 'history' ? (liveActive || state.live.connected) : ok;
|
|
441
510
|
// The status dot is drawn entirely by CSS (.status-dot::before) - a small
|
|
442
511
|
// colored disc, real product design, not a text glyph. State drives its colour
|
|
@@ -454,6 +523,7 @@ function view() {
|
|
|
454
523
|
// only the dot: on history/chat it names the selected session/agent, on files
|
|
455
524
|
// the current dir, on live the live count, on settings "configuration".
|
|
456
525
|
let crumbLeaf = '';
|
|
526
|
+
let crumbTrail = [];
|
|
457
527
|
if (state.tab === 'history') {
|
|
458
528
|
const sel = state.selectedSid && (Array.isArray(state.sessions) ? state.sessions : []).find(s => s.sid === state.selectedSid);
|
|
459
529
|
crumbLeaf = state.selectedSid
|
|
@@ -462,13 +532,17 @@ function view() {
|
|
|
462
532
|
} else if (state.tab === 'chat') {
|
|
463
533
|
crumbLeaf = state.selectedAgent ? (agentById(state.selectedAgent)?.name || state.selectedAgent) : 'no agent';
|
|
464
534
|
} else if (state.tab === 'files') {
|
|
465
|
-
|
|
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));
|
|
466
539
|
} else if (state.tab === 'live') {
|
|
467
540
|
crumbLeaf = 'live · ' + ((state.active && state.active.length) || 0);
|
|
468
541
|
} else if (state.tab === 'settings') {
|
|
469
|
-
|
|
542
|
+
// Same word as the rail item - location chrome must not fork vocabulary.
|
|
543
|
+
crumbLeaf = 'settings';
|
|
470
544
|
}
|
|
471
|
-
const crumb = Crumb({ trail:
|
|
545
|
+
const crumb = Crumb({ trail: crumbTrail, leaf: crumbLeaf, right: [dot] });
|
|
472
546
|
|
|
473
547
|
const agentLabel = state.selectedAgent
|
|
474
548
|
? 'agent: ' + (agentById(state.selectedAgent)?.name || state.selectedAgent) + (state.selectedModel ? ' · ' + state.selectedModel : '')
|
|
@@ -509,12 +583,17 @@ function view() {
|
|
|
509
583
|
turns: state.chat.messages.filter(m => m.role === 'user').length,
|
|
510
584
|
cost: state.chat.totalCost || null,
|
|
511
585
|
},
|
|
512
|
-
|
|
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(); },
|
|
513
590
|
});
|
|
514
591
|
} else if (state.tab === 'files' && state.files && state.files.preview && !isNarrow()) {
|
|
515
592
|
pane = filePreviewPane();
|
|
516
593
|
}
|
|
517
|
-
|
|
594
|
+
// The chat tab manages its own --measure gutter, so it opts out of the content
|
|
595
|
+
// column padding; every other surface (files/live/history/settings) keeps it.
|
|
596
|
+
return WorkspaceShell({ rail, sessions, main, pane, crumb, status, narrow: isNarrow(), stableFrame: true, mainFlush: state.tab === 'chat' });
|
|
518
597
|
}
|
|
519
598
|
|
|
520
599
|
// The left workspace rail: brand, New chat action, and the primary view nav.
|
|
@@ -556,7 +635,9 @@ const DATE_GROUP_ORDER = ['Running', 'Today', 'Yesterday', 'This week', 'Earlier
|
|
|
556
635
|
// chats are pinned to a "Running" section at the top (a live workspace surfaces
|
|
557
636
|
// in-flight work first), the rest bucket by recency. Returns { items, groups }.
|
|
558
637
|
function sessionGroups(sessionsView) {
|
|
559
|
-
|
|
638
|
+
// Join on the REAL claude/ccsniff sid when known (chat.active rows carry the
|
|
639
|
+
// ephemeral chat- id; claudeSessionId lands once streaming_session arrives).
|
|
640
|
+
const runningSids = new Set((Array.isArray(state.active) ? state.active : []).map(a => a.claudeSessionId || a.sessionId));
|
|
560
641
|
const buckets = new Map();
|
|
561
642
|
for (const s of sessionsView) {
|
|
562
643
|
const label = runningSids.has(s.sid) ? 'Running' : dateGroupLabel(s.last);
|
|
@@ -608,7 +689,7 @@ function sessionsColumn() {
|
|
|
608
689
|
}
|
|
609
690
|
const sessionsView = visibleSessions();
|
|
610
691
|
const sliced = sessionsView.slice(0, state.sessionsLimit);
|
|
611
|
-
const runningSids = new Set((Array.isArray(state.active) ? state.active : []).map(a => a.sessionId));
|
|
692
|
+
const runningSids = new Set((Array.isArray(state.active) ? state.active : []).map(a => a.claudeSessionId || a.sessionId));
|
|
612
693
|
const items = sliced.map((s) => ({
|
|
613
694
|
sid: s.sid,
|
|
614
695
|
title: projectLabel(s.title) || projectLabel(s.project) || s.sid,
|
|
@@ -636,7 +717,7 @@ function sessionsColumn() {
|
|
|
636
717
|
onNew: () => { navTo('chat'); newChat(); },
|
|
637
718
|
onSelect: (s) => { if (state.tab === 'chat') resumeInChat({ sid: s.sid }); else loadSession(s.sid); },
|
|
638
719
|
loading: state.tab === 'history' && !state.sessions.length && !state.historyError,
|
|
639
|
-
loadingText: state.historySlow ? 'Indexing your Claude history
|
|
720
|
+
loadingText: state.historySlow ? 'Indexing your Claude history — the first load can take a minute…' : undefined,
|
|
640
721
|
error: state.historyError,
|
|
641
722
|
emptyText: 'No conversations yet',
|
|
642
723
|
});
|
|
@@ -656,6 +737,10 @@ async function loadDir(dirPath, { fromHash = false } = {}) {
|
|
|
656
737
|
state.files.loading = true; state.files.error = null; render();
|
|
657
738
|
try {
|
|
658
739
|
const j = await B.listDir(state.backend, dirPath || '');
|
|
740
|
+
// The filter text and show-more cap are per-directory state: keep them
|
|
741
|
+
// across an in-place refresh (same path after a mutation), reset them when
|
|
742
|
+
// the resolved directory actually changed.
|
|
743
|
+
if (j.path !== state.files.path) { state.files.filter = ''; state.files.shown = null; }
|
|
659
744
|
state.files.path = j.path;
|
|
660
745
|
state.files.segments = j.segments || [];
|
|
661
746
|
state.files.entries = j.entries || [];
|
|
@@ -740,7 +825,12 @@ function openFileDialog(kind, file) {
|
|
|
740
825
|
state.files.dialog = { kind, file: file || null, error: null, busy: false };
|
|
741
826
|
render();
|
|
742
827
|
}
|
|
743
|
-
function closeFileDialog() {
|
|
828
|
+
function closeFileDialog() {
|
|
829
|
+
// A mid-flight close would orphan the mutation's result and swallow its
|
|
830
|
+
// error - hold the dialog open until the operation settles.
|
|
831
|
+
if (state.files.dialog?.busy) { announce('still working - please wait'); return; }
|
|
832
|
+
state.files.dialog = null; render();
|
|
833
|
+
}
|
|
744
834
|
async function runFileMutation(fn, doneMsg) {
|
|
745
835
|
const d = state.files.dialog;
|
|
746
836
|
if (!d || d.busy) return;
|
|
@@ -751,6 +841,9 @@ async function runFileMutation(fn, doneMsg) {
|
|
|
751
841
|
announce(doneMsg);
|
|
752
842
|
await loadDir(state.files.path, { fromHash: true }); // refresh in place, no history entry
|
|
753
843
|
} catch (e) {
|
|
844
|
+
// If the dialog detached anyway (e.g. state replaced), the error would be
|
|
845
|
+
// written onto a dead object and lost - announce it instead.
|
|
846
|
+
if (state.files.dialog !== d) { announce(fileMutationCopy(e)); render(); return; }
|
|
754
847
|
d.busy = false; d.error = fileMutationCopy(e); render();
|
|
755
848
|
}
|
|
756
849
|
}
|
|
@@ -759,55 +852,101 @@ async function runFileMutation(fn, doneMsg) {
|
|
|
759
852
|
async function uploadFiles(fileList) {
|
|
760
853
|
const dir = state.files.path;
|
|
761
854
|
if (!dir || !fileList || !fileList.length) return;
|
|
762
|
-
const items = Array.from(fileList).map((f) => ({ name: f.name, pct: 0, done: false, error: null, _file: f }));
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
855
|
+
const items = Array.from(fileList).map((f) => ({ name: f.name, pct: 0, done: false, error: null, status: null, _file: f, _dir: dir }));
|
|
856
|
+
// Concurrent drops APPEND to the shared queue (a second drop mid-upload must
|
|
857
|
+
// not replace the first batch's progress/errors); one running loop drains it.
|
|
858
|
+
state.files.uploads = (state.files.uploads || []).concat(items);
|
|
859
|
+
render();
|
|
860
|
+
if (state.files.uploading) return;
|
|
861
|
+
state.files.uploading = true;
|
|
862
|
+
try {
|
|
863
|
+
let it;
|
|
864
|
+
while ((it = (state.files.uploads || []).find(i => !i.done && !i.error && !i._started))) {
|
|
865
|
+
it._started = true;
|
|
866
|
+
try {
|
|
867
|
+
await B.uploadFile(state.backend, it._dir, it._file);
|
|
868
|
+
it.pct = 100; it.done = true;
|
|
869
|
+
} catch (e) {
|
|
870
|
+
it.status = e.status || null;
|
|
871
|
+
it.error = fileMutationCopy(e);
|
|
872
|
+
}
|
|
873
|
+
render();
|
|
770
874
|
}
|
|
771
|
-
|
|
875
|
+
} finally {
|
|
876
|
+
state.files.uploading = false;
|
|
772
877
|
}
|
|
773
878
|
announce('upload finished');
|
|
774
|
-
await loadDir(
|
|
879
|
+
await loadDir(state.files.path, { fromHash: true });
|
|
775
880
|
// Keep error rows visible; clear the list entirely when everything landed.
|
|
776
|
-
if (!
|
|
881
|
+
if (!(state.files.uploads || []).some(i => i.error)) state.files.uploads = null;
|
|
882
|
+
render();
|
|
883
|
+
}
|
|
884
|
+
// 'replace' on a 409 row: re-PUT the same file with overwrite=1.
|
|
885
|
+
async function retryUploadOverwrite(it) {
|
|
886
|
+
if (!it || it._retrying || !it._file) return;
|
|
887
|
+
it._retrying = true; it.error = null; it.status = null; render();
|
|
888
|
+
try {
|
|
889
|
+
await B.uploadFile(state.backend, it._dir, it._file, true);
|
|
890
|
+
it.pct = 100; it.done = true;
|
|
891
|
+
} catch (e) {
|
|
892
|
+
it.status = e.status || null;
|
|
893
|
+
it.error = fileMutationCopy(e);
|
|
894
|
+
}
|
|
895
|
+
it._retrying = false;
|
|
896
|
+
await loadDir(state.files.path, { fromHash: true });
|
|
897
|
+
if (!(state.files.uploads || []).some(i => i.error)) state.files.uploads = null;
|
|
898
|
+
render();
|
|
899
|
+
}
|
|
900
|
+
function dismissUpload(it) {
|
|
901
|
+
const ups = state.files.uploads || [];
|
|
902
|
+
const i = ups.indexOf(it);
|
|
903
|
+
if (i >= 0) ups.splice(i, 1);
|
|
904
|
+
if (!ups.length) state.files.uploads = null;
|
|
777
905
|
render();
|
|
778
906
|
}
|
|
779
907
|
// The active file dialog (rename/delete/mkdir) as a kit modal, or null.
|
|
780
908
|
function fileDialog() {
|
|
781
909
|
const d = state.files && state.files.dialog;
|
|
782
910
|
if (!d) return null;
|
|
783
|
-
|
|
911
|
+
// error/busy live INSIDE the kit dialog (the modal overlay sits above page
|
|
912
|
+
// flow, so a sibling alert was invisible and outside the focus trap).
|
|
784
913
|
if (d.kind === 'rename') {
|
|
785
|
-
return
|
|
914
|
+
return PromptDialog({
|
|
786
915
|
title: 'Rename ' + d.file.name, value: d.file.name, placeholder: 'new name',
|
|
916
|
+
error: d.error || null, busy: !!d.busy,
|
|
787
917
|
confirmLabel: d.busy ? 'renaming...' : 'rename', cancelLabel: 'cancel',
|
|
788
918
|
onCancel: closeFileDialog,
|
|
789
|
-
onConfirm: (v) => {
|
|
790
|
-
|
|
919
|
+
onConfirm: (v) => {
|
|
920
|
+
// Every confirm press produces visible feedback - never a silent no-op.
|
|
921
|
+
if (!v || v === d.file.name) { d.error = 'enter a different name'; render(); return; }
|
|
922
|
+
runFileMutation(() => B.renameEntry(state.backend, d.file.path, v), 'renamed to ' + v);
|
|
923
|
+
},
|
|
924
|
+
});
|
|
791
925
|
}
|
|
792
926
|
if (d.kind === 'delete') {
|
|
793
927
|
const isDir = d.file.type === 'dir';
|
|
794
|
-
return
|
|
928
|
+
return ConfirmDialog({
|
|
795
929
|
title: 'Delete ' + d.file.name,
|
|
796
930
|
message: isDir
|
|
797
931
|
? 'Delete this folder and everything inside it? This cannot be undone.'
|
|
798
932
|
: 'Delete this file? This cannot be undone.',
|
|
933
|
+
error: d.error || null, busy: !!d.busy,
|
|
799
934
|
confirmLabel: d.busy ? 'deleting...' : 'delete', cancelLabel: 'cancel', destructive: true,
|
|
800
935
|
onCancel: closeFileDialog,
|
|
801
936
|
onConfirm: () => runFileMutation(() => B.deleteEntry(state.backend, d.file.path, isDir), 'deleted ' + d.file.name),
|
|
802
|
-
})
|
|
937
|
+
});
|
|
803
938
|
}
|
|
804
939
|
if (d.kind === 'mkdir') {
|
|
805
|
-
return
|
|
940
|
+
return PromptDialog({
|
|
806
941
|
title: 'New folder', value: '', placeholder: 'folder name',
|
|
942
|
+
error: d.error || null, busy: !!d.busy,
|
|
807
943
|
confirmLabel: d.busy ? 'creating...' : 'create', cancelLabel: 'cancel',
|
|
808
944
|
onCancel: closeFileDialog,
|
|
809
|
-
onConfirm: (v) => {
|
|
810
|
-
|
|
945
|
+
onConfirm: (v) => {
|
|
946
|
+
if (!v) { d.error = 'enter a folder name'; render(); return; }
|
|
947
|
+
runFileMutation(() => B.makeDir(state.backend, state.files.path, v), 'created ' + v);
|
|
948
|
+
},
|
|
949
|
+
});
|
|
811
950
|
}
|
|
812
951
|
return null;
|
|
813
952
|
}
|
|
@@ -1005,7 +1144,17 @@ function filesMain() {
|
|
|
1005
1144
|
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.' }),
|
|
1006
1145
|
rootsRow ? h('div', { key: 'froots' }, rootsRow) : null,
|
|
1007
1146
|
h('div', { key: 'ftb' }, toolbar),
|
|
1008
|
-
(f.uploads && f.uploads.length) ? h('div', { key: 'fup' }, UploadProgress({
|
|
1147
|
+
(f.uploads && f.uploads.length) ? h('div', { key: 'fup' }, UploadProgress({
|
|
1148
|
+
// Recovery affordances per row: 'replace' on a name collision (409),
|
|
1149
|
+
// dismiss on any error row (errors otherwise persist until the next batch).
|
|
1150
|
+
items: f.uploads.map((it) => (it.error && it.status === 409 && !it._retrying)
|
|
1151
|
+
? { ...it, actions: [{ label: 'replace', onClick: () => retryUploadOverwrite(it) }] }
|
|
1152
|
+
: it),
|
|
1153
|
+
onDismiss: (item, i) => {
|
|
1154
|
+
const src = (state.files.uploads || [])[i];
|
|
1155
|
+
if (src && src.error) dismissUpload(src);
|
|
1156
|
+
},
|
|
1157
|
+
})) : null,
|
|
1009
1158
|
h('div', { key: 'fbody' }, droppableBody),
|
|
1010
1159
|
fileDialog(),
|
|
1011
1160
|
// Inline pane handles wide-screen preview; the modal is only the <900px
|
|
@@ -1054,10 +1203,24 @@ function armStopSelected() {
|
|
|
1054
1203
|
}
|
|
1055
1204
|
|
|
1056
1205
|
// Stop every in-flight chat at once (the dashboard "stop all" bulk control).
|
|
1206
|
+
// Awaits every cancel, reports partial failure, and returns the sids that
|
|
1207
|
+
// actually stopped so callers only clear those from a selection.
|
|
1057
1208
|
async function stopAllActive(sessions) {
|
|
1058
1209
|
const sids = (Array.isArray(sessions) ? sessions : (state.active || [])).map(s => s.sid || s.sessionId).filter(Boolean);
|
|
1059
|
-
|
|
1060
|
-
|
|
1210
|
+
if (!sids.length) return [];
|
|
1211
|
+
const st = state.live.stopping = state.live.stopping || new Set();
|
|
1212
|
+
for (const sid of sids) st.add(sid);
|
|
1213
|
+
render();
|
|
1214
|
+
const results = await Promise.allSettled(sids.map(sid => B.cancelChat(state.backend, sid)));
|
|
1215
|
+
const okSids = sids.filter((sid, i) => results[i].status === 'fulfilled');
|
|
1216
|
+
const failed = sids.length - okSids.length;
|
|
1217
|
+
state.live.bulkStopError = failed
|
|
1218
|
+
? failed + ' session' + (failed === 1 ? '' : 's') + ' did not stop - try again'
|
|
1219
|
+
: null;
|
|
1220
|
+
announce('stopped ' + okSids.length + ' of ' + sids.length + ' sessions');
|
|
1221
|
+
await refreshActive();
|
|
1222
|
+
render();
|
|
1223
|
+
return okSids;
|
|
1061
1224
|
}
|
|
1062
1225
|
|
|
1063
1226
|
// How long a session can go without activity (and without a running tool)
|
|
@@ -1074,71 +1237,156 @@ function liveMain() {
|
|
|
1074
1237
|
const tally = state.live.tally || new Map();
|
|
1075
1238
|
const now = Date.now();
|
|
1076
1239
|
// Live-stream health: connected (recent event), connecting (opened, no event
|
|
1077
|
-
// yet), or
|
|
1078
|
-
|
|
1079
|
-
const
|
|
1240
|
+
// yet), or offline (errored - one connection vocabulary across the GUI).
|
|
1241
|
+
const streamState = state.live.error ? 'offline' : (state.live.connected ? 'connected' : 'connecting');
|
|
1242
|
+
const stoppingSet = state.live.stopping || new Set();
|
|
1243
|
+
// Clock-skew-corrected "now" in server-timestamp terms, so pure skew never
|
|
1244
|
+
// derives a session stale.
|
|
1245
|
+
const nowS = now - (state.live.clockSkew || 0);
|
|
1080
1246
|
let sessions = (Array.isArray(state.active) ? state.active : []).map((r) => {
|
|
1081
|
-
//
|
|
1082
|
-
//
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
const
|
|
1086
|
-
const
|
|
1087
|
-
const
|
|
1088
|
-
|
|
1247
|
+
// The history index + SSE tally are keyed by claude's REAL session id, not
|
|
1248
|
+
// the ephemeral chat- id chat.active returns; join on the real one once
|
|
1249
|
+
// streaming_session has landed. The ephemeral id stays the card sid (it is
|
|
1250
|
+
// what chat.cancel takes).
|
|
1251
|
+
const realSid = r.claudeSessionId || r.sessionId;
|
|
1252
|
+
const sess = bySid.get(realSid);
|
|
1253
|
+
const t = tally.get(realSid);
|
|
1254
|
+
// Counters are MONOTONIC within a session: the index refresh lags the JSONL
|
|
1255
|
+
// flush, so take the max of index and live tally - never regress.
|
|
1256
|
+
const events = Math.max(sess?.events ?? -1, t?.events ?? -1);
|
|
1257
|
+
const tools = Math.max(sess?.tools || 0, t?.tools || 0);
|
|
1258
|
+
const errors = Math.max(sess?.errors || 0, t?.errors || 0);
|
|
1259
|
+
const lastTs = Math.max(sess?.last || 0, t?.last || 0);
|
|
1260
|
+
const lastErrorTs = Math.max(sess?.lastErrorTs || 0, t?.lastErrorTs || 0);
|
|
1089
1261
|
const counterBits = [];
|
|
1090
|
-
if (events
|
|
1262
|
+
if (events >= 0) counterBits.push(events + ' ev');
|
|
1091
1263
|
if (tools) counterBits.push(tools + ' tools');
|
|
1092
1264
|
if (errors) counterBits.push(errors + ' err');
|
|
1093
|
-
// Current tool:
|
|
1094
|
-
//
|
|
1265
|
+
// Current tool: the in-page chat knows its own running tool part; every
|
|
1266
|
+
// OTHER session gets it from the per-sid SSE tally (an unresolved tool_use
|
|
1267
|
+
// means busy-with-tool, not stalled).
|
|
1095
1268
|
let currentTool = '';
|
|
1096
|
-
if (state.chat.resumeSid ===
|
|
1269
|
+
if (state.chat.resumeSid === realSid && state.chat.busy) {
|
|
1097
1270
|
const msgs = state.chat.messages || [];
|
|
1098
1271
|
const last = msgs[msgs.length - 1];
|
|
1099
1272
|
const running = last && Array.isArray(last.parts) && last.parts.filter(p => p && p.kind === 'tool' && p.status === 'running').slice(-1)[0];
|
|
1100
1273
|
if (running) currentTool = running.name || '';
|
|
1101
1274
|
}
|
|
1102
|
-
|
|
1275
|
+
if (!currentTool && t && t.toolRunning) currentTool = t.toolName || 'tool';
|
|
1276
|
+
// Status reflects CURRENT reality: error only when an error is recent (a
|
|
1277
|
+
// recovered tool error hours ago is history, kept in the counter chip);
|
|
1278
|
+
// stale = no recent activity AND no running tool.
|
|
1103
1279
|
let status = 'running';
|
|
1104
|
-
if (
|
|
1105
|
-
else if (!currentTool && lastTs && (
|
|
1280
|
+
if (lastErrorTs && (nowS - lastErrorTs) <= STALE_AFTER_MS) status = 'error';
|
|
1281
|
+
else if (!currentTool && lastTs && (nowS - lastTs) > STALE_AFTER_MS) status = 'stale';
|
|
1282
|
+
const startedTs = r.startedAt || 0;
|
|
1283
|
+
const elapsedMs = startedTs ? Math.max(0, now - startedTs) : 0;
|
|
1284
|
+
// One title source shared with the rails: projectLabel(title|project)|sid.
|
|
1285
|
+
const title = sess
|
|
1286
|
+
? (projectLabel(sess.title) || projectLabel(sess.project) || realSid)
|
|
1287
|
+
: (r.claudeSessionId ? realSid : '');
|
|
1106
1288
|
return {
|
|
1107
1289
|
sid: r.sessionId,
|
|
1290
|
+
realSid,
|
|
1291
|
+
title: title || undefined,
|
|
1108
1292
|
agent: agentById(r.agentId)?.name || r.agentId || 'agent',
|
|
1109
1293
|
model: r.model || '',
|
|
1110
1294
|
cwd: r.cwd ? r.cwd.split(/[/\\]/).filter(Boolean).slice(-1)[0] : '',
|
|
1111
|
-
elapsed:
|
|
1295
|
+
elapsed: elapsedMs ? fmtDuration(elapsedMs) : '',
|
|
1296
|
+
elapsedMs,
|
|
1297
|
+
startedTs,
|
|
1112
1298
|
counter: counterBits.length ? counterBits.join(' · ') : null,
|
|
1113
|
-
lastActivity: lastTs ? fmtRelTime(lastTs) : '',
|
|
1299
|
+
lastActivity: lastTs ? fmtRelTime(lastTs + (state.live.clockSkew || 0)) : '',
|
|
1300
|
+
lastTs,
|
|
1301
|
+
errors,
|
|
1114
1302
|
currentTool,
|
|
1115
1303
|
status,
|
|
1304
|
+
stopping: stoppingSet.has(r.sessionId),
|
|
1305
|
+
// Arrival cue for a freshly-started session (a brief enter animation).
|
|
1306
|
+
isNew: startedTs ? (now - startedTs < 3000) : false,
|
|
1307
|
+
// Surface the in-page chat's own running cost on its card (the only
|
|
1308
|
+
// session we hold a reliable per-session cost for; others omit it).
|
|
1309
|
+
cost: (state.chat.resumeSid && (r.claudeSessionId === state.chat.resumeSid || r.sessionId === state.chat.resumeSid))
|
|
1310
|
+
? (state.chat.totalCost || null) : null,
|
|
1116
1311
|
};
|
|
1117
1312
|
});
|
|
1313
|
+
// External sessions (a claude CLI in a terminal, etc.): live SSE motion that
|
|
1314
|
+
// belongs to no agentgui-spawned chat. The page promises EVERY in-flight
|
|
1315
|
+
// session, so render them as read-only cards (we own no process to stop).
|
|
1316
|
+
const ownedReal = new Set(sessions.map(s => s.realSid));
|
|
1317
|
+
for (const [sid, t] of tally) {
|
|
1318
|
+
if (ownedReal.has(sid)) continue;
|
|
1319
|
+
if (!t.last || (nowS - t.last) >= STALE_AFTER_MS) continue;
|
|
1320
|
+
const sess = bySid.get(sid);
|
|
1321
|
+
const events = Math.max(sess?.events ?? -1, t.events ?? -1);
|
|
1322
|
+
const tools = Math.max(sess?.tools || 0, t.tools || 0);
|
|
1323
|
+
const errors = Math.max(sess?.errors || 0, t.errors || 0);
|
|
1324
|
+
const lastErrorTs = Math.max(sess?.lastErrorTs || 0, t.lastErrorTs || 0);
|
|
1325
|
+
const counterBits = [];
|
|
1326
|
+
if (events >= 0) counterBits.push(events + ' ev');
|
|
1327
|
+
if (tools) counterBits.push(tools + ' tools');
|
|
1328
|
+
if (errors) counterBits.push(errors + ' err');
|
|
1329
|
+
sessions.push({
|
|
1330
|
+
sid,
|
|
1331
|
+
realSid: sid,
|
|
1332
|
+
external: true,
|
|
1333
|
+
readOnly: true,
|
|
1334
|
+
title: sess ? (projectLabel(sess.title) || projectLabel(sess.project) || sid) : sid,
|
|
1335
|
+
agent: 'external session',
|
|
1336
|
+
model: '',
|
|
1337
|
+
cwd: sess && sess.cwd ? sess.cwd.split(/[/\\]/).filter(Boolean).slice(-1)[0] : '',
|
|
1338
|
+
elapsed: '',
|
|
1339
|
+
elapsedMs: 0,
|
|
1340
|
+
startedTs: 0,
|
|
1341
|
+
counter: counterBits.length ? counterBits.join(' · ') : null,
|
|
1342
|
+
lastActivity: fmtRelTime(t.last + (state.live.clockSkew || 0)),
|
|
1343
|
+
lastTs: t.last,
|
|
1344
|
+
errors,
|
|
1345
|
+
currentTool: t.toolRunning ? (t.toolName || 'tool') : '',
|
|
1346
|
+
status: (lastErrorTs && (nowS - lastErrorTs) <= STALE_AFTER_MS) ? 'error' : 'running',
|
|
1347
|
+
stopping: false,
|
|
1348
|
+
});
|
|
1349
|
+
}
|
|
1118
1350
|
// W12: in-dir filter + errors-only toggle.
|
|
1119
1351
|
const lv = state.live;
|
|
1120
1352
|
if (lv.errorsOnly) sessions = sessions.filter(s => s.status === 'error');
|
|
1121
1353
|
if (lv.filter) {
|
|
1122
1354
|
const q = lv.filter.toLowerCase();
|
|
1123
|
-
sessions = sessions.filter(s => (s.agent + ' ' + s.model + ' ' + s.cwd).toLowerCase().includes(q));
|
|
1355
|
+
sessions = sessions.filter(s => ((s.title || '') + ' ' + s.agent + ' ' + s.model + ' ' + s.cwd).toLowerCase().includes(q));
|
|
1124
1356
|
}
|
|
1125
|
-
//
|
|
1357
|
+
// Sort: real numeric comparisons (recency/elapsed/error count), owned before
|
|
1358
|
+
// external, and a deterministic sid tiebreaker so equal-rank cards never
|
|
1359
|
+
// reshuffle with the server's return order.
|
|
1126
1360
|
const sortKey = lv.sort || 'status';
|
|
1127
1361
|
sessions.sort((a, b) => {
|
|
1128
|
-
|
|
1129
|
-
if (
|
|
1130
|
-
if (sortKey === '
|
|
1131
|
-
|
|
1362
|
+
let d = (a.external ? 1 : 0) - (b.external ? 1 : 0);
|
|
1363
|
+
if (d) return d;
|
|
1364
|
+
if (sortKey === 'elapsed') d = (b.elapsedMs || 0) - (a.elapsedMs || 0);
|
|
1365
|
+
else if (sortKey === 'activity') d = (b.lastTs || 0) - (a.lastTs || 0);
|
|
1366
|
+
else if (sortKey === 'errors') d = (b.errors || 0) - (a.errors || 0);
|
|
1367
|
+
else d = (STATUS_RANK[a.status] ?? 9) - (STATUS_RANK[b.status] ?? 9);
|
|
1368
|
+
return d || String(a.sid).localeCompare(String(b.sid));
|
|
1132
1369
|
});
|
|
1133
1370
|
const selected = lv.selected instanceof Set ? lv.selected : new Set();
|
|
1371
|
+
// The in-page chat's card accent matches on the real sid, but the kit
|
|
1372
|
+
// compares activeSid against card.sid (the ephemeral id for owned cards).
|
|
1373
|
+
const activeCard = state.chat.resumeSid
|
|
1374
|
+
? sessions.find(s => s.realSid === state.chat.resumeSid)
|
|
1375
|
+
: null;
|
|
1134
1376
|
return [
|
|
1135
1377
|
offlineBanner(),
|
|
1378
|
+
lv.bulkStopError
|
|
1379
|
+
? Alert({ key: 'bulkstoperr', kind: 'warn', title: 'Some sessions did not stop',
|
|
1380
|
+
children: [
|
|
1381
|
+
h('span', { key: 'bsetxt' }, lv.bulkStopError + ' '),
|
|
1382
|
+
Btn({ key: 'bsedismiss', onClick: () => { state.live.bulkStopError = null; render(); }, children: 'dismiss' })] })
|
|
1383
|
+
: null,
|
|
1136
1384
|
PageHeader({ compact: true, title: 'Live sessions', lede: 'Every in-flight agent session, managed at once. Stop, open, or jump to events per session.' }),
|
|
1137
1385
|
SessionDashboard({
|
|
1138
1386
|
sessions,
|
|
1139
1387
|
offline,
|
|
1140
1388
|
streamState,
|
|
1141
|
-
activeSid: state.chat.resumeSid,
|
|
1389
|
+
activeSid: activeCard ? activeCard.sid : state.chat.resumeSid, // W13
|
|
1142
1390
|
sort: { value: sortKey, onChange: (v) => { state.live.sort = v; persistLivePrefs(); render(); } },
|
|
1143
1391
|
filter: { value: lv.filter || '', placeholder: 'Filter sessions', onInput: (v) => { state.live.filter = v; render(); } },
|
|
1144
1392
|
errorsOnly: !!lv.errorsOnly,
|
|
@@ -1150,26 +1398,39 @@ function liveMain() {
|
|
|
1150
1398
|
if (set.has(s.sid)) set.delete(s.sid); else set.add(s.sid);
|
|
1151
1399
|
state.live.selected = set; render();
|
|
1152
1400
|
},
|
|
1401
|
+
// Tri-state select-all over the currently-visible selectable sids.
|
|
1402
|
+
onSelectAll: (sids) => {
|
|
1403
|
+
const set = (state.live.selected instanceof Set) ? state.live.selected : new Set();
|
|
1404
|
+
for (const sid of sids) set.add(sid);
|
|
1405
|
+
state.live.selected = set; render();
|
|
1406
|
+
},
|
|
1407
|
+
onClearSelection: () => { state.live.selected = new Set(); render(); },
|
|
1153
1408
|
// Two-step bulk stops: arm first, execute on the confirmed click.
|
|
1154
1409
|
confirmingStopAll: !!lv.confirmingStopAll,
|
|
1155
1410
|
confirmingStopSelected: !!lv.confirmingStopSelected,
|
|
1156
1411
|
onArmStopAll: armStopAll,
|
|
1157
1412
|
onArmStopSelected: armStopSelected,
|
|
1158
|
-
onStopSelected: (sids) => {
|
|
1413
|
+
onStopSelected: async (sids) => {
|
|
1159
1414
|
state.live.confirmingStopSelected = false;
|
|
1160
1415
|
clearTimeout(_stopSelArmTimer);
|
|
1161
|
-
|
|
1162
|
-
|
|
1416
|
+
// Await the cancels and only clear the sids that actually stopped, so
|
|
1417
|
+
// a partially-failed bulk stop stays selected and visibly armed-again.
|
|
1418
|
+
const ok = await stopAllActive(sids.map(sid => ({ sid })));
|
|
1419
|
+
const sel = (state.live.selected instanceof Set) ? state.live.selected : new Set();
|
|
1420
|
+
for (const sid of ok) sel.delete(sid);
|
|
1421
|
+
state.live.selected = sel;
|
|
1422
|
+
render();
|
|
1163
1423
|
},
|
|
1164
|
-
emptyText: 'No live sessions
|
|
1165
|
-
onStop: (s) => stopActiveChat(s.sid),
|
|
1166
|
-
onStopAll: (all) => {
|
|
1424
|
+
emptyText: 'No live sessions — start a chat or run a local agent.',
|
|
1425
|
+
onStop: (s) => { if (!s.external) stopActiveChat(s.sid); },
|
|
1426
|
+
onStopAll: async (all) => {
|
|
1167
1427
|
state.live.confirmingStopAll = false;
|
|
1168
1428
|
clearTimeout(_stopAllArmTimer);
|
|
1169
|
-
stopAllActive(all);
|
|
1429
|
+
await stopAllActive((all || []).filter(s => !s.external));
|
|
1430
|
+
render();
|
|
1170
1431
|
},
|
|
1171
|
-
onOpen: (s) => { resumeInChat({ sid: s.sid }); },
|
|
1172
|
-
onView: (s) => { navTo('history'); loadSession(s.sid); },
|
|
1432
|
+
onOpen: (s) => { resumeInChat({ sid: s.realSid || s.sid }); },
|
|
1433
|
+
onView: (s) => { navTo('history'); loadSession(s.realSid || s.sid); },
|
|
1173
1434
|
}),
|
|
1174
1435
|
].filter(Boolean);
|
|
1175
1436
|
}
|
|
@@ -1291,7 +1552,7 @@ function chatMain() {
|
|
|
1291
1552
|
// than as a separate stacked Alert, so resume context reads as one block.
|
|
1292
1553
|
banners.push(h('div', { key: 'rb', class: 'resume-banner', role: 'status' },
|
|
1293
1554
|
h('span', { key: 'rbtxt', class: 'lede' },
|
|
1294
|
-
'
|
|
1555
|
+
'next message continues session ' + state.chat.resumeSid.slice(0, 8) + '…'
|
|
1295
1556
|
+ (state.chat.resumeNote ? ' - ' + state.chat.resumeNote : '')),
|
|
1296
1557
|
Btn({ key: 'rclr', onClick: () => { state.chat.resumeSid = null; state.chat.resumeNote = null; render(); }, children: 'clear' })));
|
|
1297
1558
|
}
|
|
@@ -1303,12 +1564,30 @@ function chatMain() {
|
|
|
1303
1564
|
banners.push(Alert({ key: 'confnew', kind: 'warn', title: 'Clear chat history?',
|
|
1304
1565
|
children: [
|
|
1305
1566
|
h('span', { key: 'cntxt' }, 'This cannot be undone. '),
|
|
1306
|
-
Btn({ key: 'cnyes', danger: true, onClick:
|
|
1307
|
-
Btn({ key: 'cnno', onClick: () => { state.confirmingNewChat = false; render(); }, children: 'cancel' })] }));
|
|
1567
|
+
Btn({ key: 'cnyes', danger: true, onClick: confirmNewChat, children: 'yes, clear chat' }),
|
|
1568
|
+
Btn({ key: 'cnno', onClick: () => { clearTimeout(_newChatArmTimer); state.confirmingNewChat = false; render(); }, children: 'cancel' })] }));
|
|
1308
1569
|
}
|
|
1309
1570
|
if (state.cwdError) {
|
|
1310
1571
|
banners.push(Alert({ key: 'cwderr', kind: 'warn', title: 'Invalid working directory', children: state.cwdError }));
|
|
1311
1572
|
}
|
|
1573
|
+
if (state.chat.externalUpdate) {
|
|
1574
|
+
banners.push(Alert({ key: 'xupd', kind: 'info', title: 'This conversation was updated in another tab',
|
|
1575
|
+
children: [
|
|
1576
|
+
h('span', { key: 'xutxt' }, 'Reload it to see the latest turns, or dismiss to keep this tab\'s view. '),
|
|
1577
|
+
Btn({ key: 'xureload', disabled: state.chat.busy, onClick: () => {
|
|
1578
|
+
if (state.chat.busy) return;
|
|
1579
|
+
state.chat.externalUpdate = false;
|
|
1580
|
+
state.chat.messages = []; state.chat.resumeSid = null; state.chat.totalCost = 0;
|
|
1581
|
+
restoreChat(); render();
|
|
1582
|
+
}, children: 'reload it' }),
|
|
1583
|
+
Btn({ key: 'xudismiss', onClick: () => { state.chat.externalUpdate = false; render(); }, children: 'dismiss' })] }));
|
|
1584
|
+
}
|
|
1585
|
+
if (state.chat.persistError) {
|
|
1586
|
+
banners.push(Alert({ key: 'perr', kind: 'warn', title: 'Chat too large to save locally',
|
|
1587
|
+
children: [
|
|
1588
|
+
h('span', { key: 'petxt' }, 'New turns stay in this tab but will not survive a reload - export the transcript from settings. '),
|
|
1589
|
+
Btn({ key: 'pedismiss', onClick: () => { state.chat.persistError = false; render(); }, children: 'dismiss' })] }));
|
|
1590
|
+
}
|
|
1312
1591
|
if (state.agentsError) {
|
|
1313
1592
|
banners.push(Alert({ key: 'agerr', kind: 'error', title: 'Could not load agents from the server',
|
|
1314
1593
|
children: [
|
|
@@ -1351,9 +1630,12 @@ function chatMain() {
|
|
|
1351
1630
|
messages: state.chat.messages,
|
|
1352
1631
|
busy: state.chat.busy,
|
|
1353
1632
|
draft: state.chat.draft,
|
|
1633
|
+
// Idle never reads 'resuming…' (nothing is in flight - the continuation
|
|
1634
|
+
// fact lives in the composer context line and banner); a remotely-stopped
|
|
1635
|
+
// turn reads 'stopped', not a normal finish.
|
|
1354
1636
|
status: state.chat.busy
|
|
1355
|
-
? (state.health.ws === 'reconnecting' ? '
|
|
1356
|
-
: (state.modelsLoading ? 'loading models…' : ((
|
|
1637
|
+
? (state.health.ws === 'reconnecting' ? 'connecting…' : 'streaming…')
|
|
1638
|
+
: (state.modelsLoading ? 'loading models…' : ((lastMsg && lastMsg.role === 'assistant' && lastMsg.stopped) ? 'stopped' : 'ready')),
|
|
1357
1639
|
agentName,
|
|
1358
1640
|
placeholder,
|
|
1359
1641
|
canSend: canSend(),
|
|
@@ -1363,18 +1645,35 @@ function chatMain() {
|
|
|
1363
1645
|
'What are the recent changes on this branch?',
|
|
1364
1646
|
'Find and explain the main entry point',
|
|
1365
1647
|
],
|
|
1366
|
-
|
|
1648
|
+
// Chips send their own text and never touch a typed draft (clicking a
|
|
1649
|
+
// chip with a half-typed message in the composer must not destroy it).
|
|
1650
|
+
onSuggestionClick: (t) => { if (!canSend()) return; sendChat(t); },
|
|
1367
1651
|
// W14: a stable per-agent product mark (a line-SVG, not a per-agent letter)
|
|
1368
1652
|
// and the active target shown inline above the composer.
|
|
1369
1653
|
avatar: state.selectedAgent ? h('span', { class: 'agentchat-avatar-mark', 'aria-hidden': 'true' }, Icon('forum', { size: 16 })) : undefined,
|
|
1370
1654
|
composerContext: state.selectedAgent ? {
|
|
1371
|
-
|
|
1372
|
-
|
|
1655
|
+
// Split click targets: only the cwd bit is interactive (it opens the
|
|
1656
|
+
// inline cwd editor); agent/model stay inert text - the picker above
|
|
1657
|
+
// already owns them. The resume fact is stated plainly at the point of
|
|
1658
|
+
// typing so send's behavior is never a surprise.
|
|
1659
|
+
bits: [
|
|
1660
|
+
agentName,
|
|
1661
|
+
state.selectedModel || null,
|
|
1662
|
+
{
|
|
1663
|
+
label: state.chatCwd ? state.chatCwd.split(/[/\\]/).filter(Boolean).slice(-1)[0] : 'server default',
|
|
1664
|
+
title: 'change working directory',
|
|
1665
|
+
onClick: () => { state.cwdEditing = true; state.cwdDraft = state.chatCwd || ''; state.cwdError = null; render(); },
|
|
1666
|
+
},
|
|
1667
|
+
userTurnCount > 0 ? userTurnCount + ' turns' : null,
|
|
1668
|
+
(state.chat.resumeSid && state.selectedAgent === 'claude-code')
|
|
1669
|
+
? 'continues session ' + state.chat.resumeSid.slice(0, 8)
|
|
1670
|
+
: null,
|
|
1671
|
+
].filter(Boolean),
|
|
1373
1672
|
} : undefined,
|
|
1374
1673
|
// W15: contextual follow-up chips derived from the settled last turn
|
|
1375
1674
|
// (tool error / code fence / file path), seeded statically otherwise.
|
|
1376
1675
|
followups: chatFollowups(),
|
|
1377
|
-
onFollowupClick: (t) => { if (!canSend()) return;
|
|
1676
|
+
onFollowupClick: (t) => { if (!canSend()) return; sendChat(t); },
|
|
1378
1677
|
// Transcript export: copy all / markdown / json.
|
|
1379
1678
|
exportActions: state.chat.messages.length ? [
|
|
1380
1679
|
{ label: 'copy all', title: 'Copy the whole transcript as markdown', onClick: () => copyText(transcriptToMarkdown(state.chat.messages), 'transcript copied') },
|
|
@@ -1399,6 +1698,15 @@ function chatMain() {
|
|
|
1399
1698
|
cwd: state.chatCwd,
|
|
1400
1699
|
cwdEditing: !!state.cwdEditing,
|
|
1401
1700
|
cwdDraft: state.cwdDraft,
|
|
1701
|
+
cwdError: state.cwdError || null,
|
|
1702
|
+
cwdChecking: !!state.cwdChecking,
|
|
1703
|
+
// Pasting an image has no upload path on the chat surface yet - say so
|
|
1704
|
+
// politely instead of silently dropping it.
|
|
1705
|
+
onPasteFiles: () => announce('images cannot be attached here yet'),
|
|
1706
|
+
// Long threads render a capped window with a 'show earlier' row (the
|
|
1707
|
+
// chat-side equivalent of history's eventsLimit), reset per conversation.
|
|
1708
|
+
shownMessages: state.chat.shownMessages || undefined,
|
|
1709
|
+
onShowEarlier: (n) => { state.chat.shownMessages = n; render(); },
|
|
1402
1710
|
onSelectAgent: (v) => selectAgent(v),
|
|
1403
1711
|
onSelectModel: (v) => selectModel(v),
|
|
1404
1712
|
onNewChat: newChat,
|
|
@@ -1444,9 +1752,9 @@ function chatMain() {
|
|
|
1444
1752
|
if (state.chatCwd) lsSet('agentgui.cwd', state.chatCwd); else lsRemove('agentgui.cwd');
|
|
1445
1753
|
state.cwdEditing = false; state.cwdDraft = undefined; render();
|
|
1446
1754
|
},
|
|
1447
|
-
onCwdCancel: () => { state.cwdEditing = false; state.cwdDraft = undefined; state.cwdError = null; render(); },
|
|
1755
|
+
onCwdCancel: () => { state.cwdEditing = false; state.cwdDraft = undefined; state.cwdError = null; state.cwdChecking = false; render(); },
|
|
1448
1756
|
onCwdClear: () => { state.chatCwd = ''; lsRemove('agentgui.cwd'); render(); },
|
|
1449
|
-
onCwdDraft: (v) => { state.cwdDraft = v; },
|
|
1757
|
+
onCwdDraft: (v) => { state.cwdDraft = v; state.cwdError = null; debouncedCwdProbe(); },
|
|
1450
1758
|
}),
|
|
1451
1759
|
].filter(Boolean);
|
|
1452
1760
|
}
|
|
@@ -1460,14 +1768,28 @@ function offlineBanner() {
|
|
|
1460
1768
|
// (The working-directory bar now lives in the AgentChat kit; agentgui wires its
|
|
1461
1769
|
// cwd state + handlers as kit callbacks in chatMain.)
|
|
1462
1770
|
|
|
1771
|
+
// Destructive new-chat is two-step with DISTINCT arm/confirm controls: pressing
|
|
1772
|
+
// 'n' (or the rail action) only ARMS the banner; while armed, repeat presses
|
|
1773
|
+
// are no-ops and the arm auto-resets after 4s. Confirmation happens exclusively
|
|
1774
|
+
// via the banner's explicit 'clear' button - a double-tap can never wipe the
|
|
1775
|
+
// transcript.
|
|
1776
|
+
let _newChatArmTimer = null;
|
|
1463
1777
|
function newChat() {
|
|
1464
|
-
if (state.chat.messages.length
|
|
1465
|
-
state.confirmingNewChat
|
|
1778
|
+
if (state.chat.messages.length) {
|
|
1779
|
+
if (state.confirmingNewChat) { render(); return; } // armed: repeat press is a no-op
|
|
1780
|
+
state.confirmingNewChat = true;
|
|
1781
|
+
clearTimeout(_newChatArmTimer);
|
|
1782
|
+
_newChatArmTimer = setTimeout(() => { state.confirmingNewChat = false; render(); }, 4000);
|
|
1783
|
+
render();
|
|
1466
1784
|
return;
|
|
1467
1785
|
}
|
|
1786
|
+
confirmNewChat();
|
|
1787
|
+
}
|
|
1788
|
+
function confirmNewChat() {
|
|
1789
|
+
clearTimeout(_newChatArmTimer);
|
|
1468
1790
|
state.confirmingNewChat = false;
|
|
1469
1791
|
state.chat.abort?.abort();
|
|
1470
|
-
state.chat = { messages: [], busy: false, abort: null, draft: '', resumeSid: null, usage: null, confirmingEdit: null, totalCost: 0 };
|
|
1792
|
+
state.chat = { messages: [], busy: false, abort: null, draft: '', resumeSid: null, usage: null, confirmingEdit: null, totalCost: 0, shownMessages: null };
|
|
1471
1793
|
lsRemove(CHAT_KEY);
|
|
1472
1794
|
render();
|
|
1473
1795
|
}
|
|
@@ -1478,10 +1800,13 @@ function cancelChat() {
|
|
|
1478
1800
|
state.chat.abort?.abort();
|
|
1479
1801
|
// Drop a trailing empty assistant shell (aborted before any content) from the
|
|
1480
1802
|
// live array so the message count + suggestions-empty check stay correct; the
|
|
1481
|
-
// paired user message is intentionally kept.
|
|
1803
|
+
// paired user message is intentionally kept. A turn stopped mid-content is
|
|
1804
|
+
// labeled 'stopped' so truncated output never reads as a finished answer.
|
|
1482
1805
|
const msgs = state.chat.messages;
|
|
1483
1806
|
if (msgs.length && isEmptyTurn(msgs[msgs.length - 1])) msgs.pop();
|
|
1807
|
+
else if (msgs.length && msgs[msgs.length - 1].role === 'assistant') msgs[msgs.length - 1].stopped = true;
|
|
1484
1808
|
if (state.chat.busy) { state.chat.busy = false; }
|
|
1809
|
+
refreshActive();
|
|
1485
1810
|
render();
|
|
1486
1811
|
}
|
|
1487
1812
|
|
|
@@ -1492,19 +1817,65 @@ const CHAT_KEY = 'agentgui.chat';
|
|
|
1492
1817
|
function isEmptyTurn(m) {
|
|
1493
1818
|
return m.role === 'assistant' && !m.content && !(Array.isArray(m.parts) && m.parts.length);
|
|
1494
1819
|
}
|
|
1820
|
+
// Cap the persisted footprint: the last 100 messages, with any tool args/result
|
|
1821
|
+
// payload over 4KB truncated in the SAVED copy only (in-memory state keeps the
|
|
1822
|
+
// full payload). A quota failure is surfaced once instead of silently dropping
|
|
1823
|
+
// persistence forever.
|
|
1824
|
+
const PERSIST_MSG_CAP = 100;
|
|
1825
|
+
const PERSIST_PART_CAP = 4096;
|
|
1826
|
+
let _persistFailedOnce = false;
|
|
1827
|
+
let _lastPersistTs = 0;
|
|
1828
|
+
function trimPartForStorage(p) {
|
|
1829
|
+
if (!p || typeof p !== 'object') return p;
|
|
1830
|
+
let out = p;
|
|
1831
|
+
for (const k of ['result', 'args']) {
|
|
1832
|
+
const v = out[k];
|
|
1833
|
+
if (v == null) continue;
|
|
1834
|
+
let s;
|
|
1835
|
+
if (typeof v === 'string') s = v;
|
|
1836
|
+
else { try { s = JSON.stringify(v); } catch { s = String(v); } }
|
|
1837
|
+
if (s.length > PERSIST_PART_CAP) {
|
|
1838
|
+
if (out === p) out = { ...p };
|
|
1839
|
+
out[k] = s.slice(0, PERSIST_PART_CAP);
|
|
1840
|
+
out.truncatedForStorage = true;
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
return out;
|
|
1844
|
+
}
|
|
1495
1845
|
function persistChat() {
|
|
1496
1846
|
try {
|
|
1497
1847
|
const msgs = state.chat.messages
|
|
1498
1848
|
.filter(m => !isEmptyTurn(m))
|
|
1499
|
-
.
|
|
1849
|
+
.slice(-PERSIST_MSG_CAP)
|
|
1850
|
+
.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 }));
|
|
1500
1851
|
const draft = (state.chat.draft || '').trim() ? state.chat.draft : '';
|
|
1501
1852
|
// Persist when there is a transcript OR a non-empty draft (a typed-but-not-
|
|
1502
1853
|
// sent message must survive a reload too).
|
|
1503
1854
|
if (!msgs.length && !draft) { lsRemove(CHAT_KEY); return; }
|
|
1504
|
-
|
|
1505
|
-
|
|
1855
|
+
_lastPersistTs = Date.now();
|
|
1856
|
+
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 }));
|
|
1857
|
+
} catch {
|
|
1858
|
+
if (!_persistFailedOnce) {
|
|
1859
|
+
_persistFailedOnce = true;
|
|
1860
|
+
state.chat.persistError = true;
|
|
1861
|
+
announce('chat too large to save locally - export it from settings');
|
|
1862
|
+
scheduleRender();
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1506
1865
|
}
|
|
1507
1866
|
const debouncedPersistDraft = debounce(persistChat, 500);
|
|
1867
|
+
// Another GUI tab rewrote the shared chat key: never silently diverge - surface
|
|
1868
|
+
// a banner offering to reload the newer copy (last-writer-wins with a ts guard).
|
|
1869
|
+
window.addEventListener('storage', (e) => {
|
|
1870
|
+
if (e.key !== CHAT_KEY || !e.newValue) return;
|
|
1871
|
+
try {
|
|
1872
|
+
const remote = JSON.parse(e.newValue);
|
|
1873
|
+
if (remote && remote.ts && remote.ts > _lastPersistTs) {
|
|
1874
|
+
state.chat.externalUpdate = true;
|
|
1875
|
+
scheduleRender();
|
|
1876
|
+
}
|
|
1877
|
+
} catch {}
|
|
1878
|
+
});
|
|
1508
1879
|
function restoreChat() {
|
|
1509
1880
|
try {
|
|
1510
1881
|
const raw = lsGet(CHAT_KEY);
|
|
@@ -1516,6 +1887,10 @@ function restoreChat() {
|
|
|
1516
1887
|
state.chat.messages = saved.messages
|
|
1517
1888
|
.filter(m => !isEmptyTurn(m))
|
|
1518
1889
|
.map(m => ({ ...m, parts: Array.isArray(m.parts) ? m.parts : [] }));
|
|
1890
|
+
// Prefer the per-message cost sum when present (it self-corrects after
|
|
1891
|
+
// edit/retry truncation); the scalar is only the legacy fallback.
|
|
1892
|
+
const derived = computeTotalCost();
|
|
1893
|
+
if (derived) state.chat.totalCost = derived;
|
|
1519
1894
|
state.chat.resumeSid = saved.resumeSid || null;
|
|
1520
1895
|
// Restore the agent/model the transcript belongs to, so a restored chat
|
|
1521
1896
|
// isn't silently shown under whatever agent the picker defaulted to.
|
|
@@ -1605,6 +1980,8 @@ function chatFollowups() {
|
|
|
1605
1980
|
function retryLastTurn() {
|
|
1606
1981
|
if (!canSend() && !state.chat.busy) { /* allow when idle */ }
|
|
1607
1982
|
if (state.chat.busy) return;
|
|
1983
|
+
// The armed edit confirm refers to indices this retry is about to shift.
|
|
1984
|
+
state.chat.confirmingEdit = null;
|
|
1608
1985
|
const msgs = state.chat.messages;
|
|
1609
1986
|
// Find the trailing assistant turn and the user message before it.
|
|
1610
1987
|
let ai = msgs.length - 1;
|
|
@@ -1616,6 +1993,7 @@ function retryLastTurn() {
|
|
|
1616
1993
|
const userText = msgs[ui].content || '';
|
|
1617
1994
|
// Drop the user+assistant pair (and anything after) and resend the user text.
|
|
1618
1995
|
state.chat.messages = msgs.slice(0, ui);
|
|
1996
|
+
state.chat.totalCost = computeTotalCost(); // discarded turns leave the spend
|
|
1619
1997
|
state.chat.draft = userText;
|
|
1620
1998
|
persistChat();
|
|
1621
1999
|
if (canSend()) sendChat();
|
|
@@ -1635,6 +2013,7 @@ function confirmEditAndResend() {
|
|
|
1635
2013
|
if (!ce || state.chat.busy) return;
|
|
1636
2014
|
state.chat.confirmingEdit = null;
|
|
1637
2015
|
state.chat.messages = state.chat.messages.slice(0, ce.idx);
|
|
2016
|
+
state.chat.totalCost = computeTotalCost(); // discarded turns leave the spend
|
|
1638
2017
|
// Never --resume a session whose tail diverged from what the server saw.
|
|
1639
2018
|
state.chat.resumeSid = null;
|
|
1640
2019
|
state.chat.resumeNote = null;
|
|
@@ -1648,15 +2027,31 @@ function cancelEditAndResend() {
|
|
|
1648
2027
|
render();
|
|
1649
2028
|
}
|
|
1650
2029
|
|
|
1651
|
-
|
|
1652
|
-
|
|
2030
|
+
// Compute the conversation's spend from the per-message costUsd markers, so
|
|
2031
|
+
// edit/retry truncation self-corrects the total (no phantom spend from turns
|
|
2032
|
+
// that no longer exist in the transcript).
|
|
2033
|
+
function computeTotalCost() {
|
|
2034
|
+
return (state.chat.messages || []).reduce((s, m) => s + (typeof m.costUsd === 'number' ? m.costUsd : 0), 0);
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
// sendChat(text) sends an explicit text (suggestion/followup chips) WITHOUT
|
|
2038
|
+
// touching the typed draft; with no argument it sends + clears the draft.
|
|
2039
|
+
async function sendChat(textArg) {
|
|
2040
|
+
const text = ((textArg != null ? textArg : state.chat.draft) || '').trim();
|
|
1653
2041
|
if (!text || !canSend()) return;
|
|
2042
|
+
// The conversation is moving on: any armed edit-and-resend confirm refers to
|
|
2043
|
+
// an index that is about to be stale - disarm it.
|
|
2044
|
+
state.chat.confirmingEdit = null;
|
|
1654
2045
|
const t = timeNow();
|
|
1655
2046
|
const userMsg = { id: 'u' + Date.now(), role: 'user', content: text, time: t };
|
|
1656
2047
|
const curMsg = { id: 'a' + (Date.now() + 1), role: 'assistant', content: '', time: t, parts: [] };
|
|
1657
2048
|
state.chat.messages = [...state.chat.messages, userMsg, curMsg];
|
|
1658
|
-
state.chat.draft = '';
|
|
2049
|
+
if (textArg == null) state.chat.draft = '';
|
|
1659
2050
|
state.chat.busy = true;
|
|
2051
|
+
// Open the live stream + refresh the active list right away so the rail and
|
|
2052
|
+
// dashboard reflect this turn without waiting for the 3s poll.
|
|
2053
|
+
openLiveStream();
|
|
2054
|
+
refreshActive();
|
|
1660
2055
|
const ctrl = new AbortController();
|
|
1661
2056
|
state.chat.abort = ctrl;
|
|
1662
2057
|
persistChat();
|
|
@@ -1674,6 +2069,10 @@ async function sendChat() {
|
|
|
1674
2069
|
// another agent (it makes agy spuriously run --continue).
|
|
1675
2070
|
resumeSid: (state.selectedAgent === 'claude-code' && state.chat.resumeSid) || undefined,
|
|
1676
2071
|
})) {
|
|
2072
|
+
// After a stop, the iterator drains buffered events for a turn the user
|
|
2073
|
+
// aborted - applying them would set resumeSid / accrue cost / write text
|
|
2074
|
+
// into a popped shell. A stopped turn applies no further state.
|
|
2075
|
+
if (ctrl.signal.aborted) break;
|
|
1677
2076
|
if (ev.type === 'session') {
|
|
1678
2077
|
// Remember the server's session id so the NEXT turn resumes this
|
|
1679
2078
|
// conversation instead of cold-spawning. Only claude-code supports
|
|
@@ -1701,10 +2100,17 @@ async function sendChat() {
|
|
|
1701
2100
|
turns: b.num_turns != null ? b.num_turns : null,
|
|
1702
2101
|
durationMs: b.duration_ms != null ? b.duration_ms : null,
|
|
1703
2102
|
};
|
|
1704
|
-
//
|
|
1705
|
-
//
|
|
2103
|
+
// Per-message cost markers; the session total is DERIVED from the
|
|
2104
|
+
// visible messages so edit/retry truncation self-corrects the spend.
|
|
1706
2105
|
const cost = typeof b.total_cost_usd === 'number' ? b.total_cost_usd : (typeof u.cost === 'number' ? u.cost : 0);
|
|
1707
|
-
if (cost)
|
|
2106
|
+
if (cost) { cur.costUsd = (cur.costUsd || 0) + cost; state.chat.totalCost = computeTotalCost(); }
|
|
2107
|
+
scheduleStreamRender();
|
|
2108
|
+
}
|
|
2109
|
+
else if (ev.type === 'cancelled') {
|
|
2110
|
+
// Remote stop (another tab / dashboard): label the turn stopped so the
|
|
2111
|
+
// truncated output never reads as a finished answer.
|
|
2112
|
+
cur.stopped = true;
|
|
2113
|
+
announce('generation stopped');
|
|
1708
2114
|
scheduleStreamRender();
|
|
1709
2115
|
}
|
|
1710
2116
|
else if (ev.type === 'error') { cur.error = errText(ev.error); cur.errorRaw = errTextRaw(ev.error); render(); }
|
|
@@ -1715,11 +2121,34 @@ async function sendChat() {
|
|
|
1715
2121
|
state.chat.busy = false;
|
|
1716
2122
|
state.chat.abort = null;
|
|
1717
2123
|
persistChat();
|
|
2124
|
+
refreshActive(); // settle the running panel/dashboard now, not at the next poll
|
|
1718
2125
|
render();
|
|
1719
2126
|
scrollChatToBottom();
|
|
1720
2127
|
}
|
|
1721
2128
|
}
|
|
1722
2129
|
|
|
2130
|
+
// Validate the cwd draft while editing (debounced) so an invalid path reads as
|
|
2131
|
+
// invalid before the save click, via the existing confined /api/stat endpoint.
|
|
2132
|
+
const debouncedCwdProbe = debounce(async () => {
|
|
2133
|
+
const path = (state.cwdDraft ?? '').trim();
|
|
2134
|
+
if (!state.cwdEditing) return;
|
|
2135
|
+
if (!path || !/^([/\\]|[A-Za-z]:[/\\])/.test(path)) { state.cwdChecking = false; render(); return; }
|
|
2136
|
+
state.cwdChecking = true; render();
|
|
2137
|
+
const probed = path;
|
|
2138
|
+
try {
|
|
2139
|
+
const st = await B.statPath(state.backend, probed);
|
|
2140
|
+
if ((state.cwdDraft ?? '').trim() !== probed) return; // draft moved on
|
|
2141
|
+
state.cwdError = (!st || st.ok === false) ? 'folder not found on the server'
|
|
2142
|
+
: (!st.dir ? 'that path is not a directory' : null);
|
|
2143
|
+
} catch (e) {
|
|
2144
|
+
if ((state.cwdDraft ?? '').trim() !== probed) return;
|
|
2145
|
+
state.cwdError = e.status === 403 ? 'outside the allowed roots'
|
|
2146
|
+
: (e.status === 404 ? 'folder not found on the server' : null);
|
|
2147
|
+
}
|
|
2148
|
+
state.cwdChecking = false;
|
|
2149
|
+
render();
|
|
2150
|
+
}, 400);
|
|
2151
|
+
|
|
1723
2152
|
// --- history ---
|
|
1724
2153
|
function reconnectAlert() {
|
|
1725
2154
|
if (!state.live.error) return null;
|
|
@@ -1823,7 +2252,7 @@ function historyMain() {
|
|
|
1823
2252
|
h('span', { key: 'noevtxt' }, 'no events in this session - '),
|
|
1824
2253
|
Btn({ key: 'reload', onClick: () => loadSession(state.selectedSid), children: 'reload' }))
|
|
1825
2254
|
: h('div', { key: 'loading', class: 'lede empty-state', role: 'status', 'aria-live': 'polite' }, Spinner({ key: 'spin', size: 'sm' }),
|
|
1826
|
-
state.eventsSlow ? 'Indexing your Claude history
|
|
2255
|
+
state.eventsSlow ? 'Indexing your Claude history — the first load can take a minute…' : 'loading events…');
|
|
1827
2256
|
return [reconnectAlert(), head, actions, Panel({ title: 'events', children: body })].filter(Boolean);
|
|
1828
2257
|
}
|
|
1829
2258
|
|
|
@@ -1848,7 +2277,12 @@ function historyMain() {
|
|
|
1848
2277
|
sess && sess.cwd ? { label: 'cwd', value: sess.cwd, title: sess.cwd } : null,
|
|
1849
2278
|
sessionDuration() ? { label: 'duration', value: sessionDuration() } : null,
|
|
1850
2279
|
{ label: 'sid', value: state.selectedSid.slice(0, 8) + '…', title: state.selectedSid, onCopy: () => copyText(state.selectedSid, 'sid copied') },
|
|
2280
|
+
// Spelled counter vocabulary in the detail strip (events/turns/tools/
|
|
2281
|
+
// errors); the abbreviated 'ev/tools/err' triple stays compact-row-only.
|
|
1851
2282
|
{ label: 'events', value: String(state.events.length) },
|
|
2283
|
+
{ label: 'turns', value: String(sess?.userTurns ?? state.events.filter(e => e.role === 'user').length) },
|
|
2284
|
+
{ label: 'tools', value: String(state.events.filter(e => e.type === 'tool_use').length) },
|
|
2285
|
+
{ label: 'errors', value: String(state.events.filter(e => e.isError).length) },
|
|
1852
2286
|
].filter(Boolean),
|
|
1853
2287
|
});
|
|
1854
2288
|
if (filteredEvents.length === 0) {
|
|
@@ -1933,7 +2367,9 @@ function historyMain() {
|
|
|
1933
2367
|
// Guard ts: a missing/zero timestamp renders "Invalid Date" otherwise.
|
|
1934
2368
|
// Every row is click-to-expand, so always show the affordance word
|
|
1935
2369
|
// (not only when text overflows 220 chars).
|
|
1936
|
-
|
|
2370
|
+
// Relative time matches every other surface; the absolute stamp
|
|
2371
|
+
// appears when the row is expanded (forensic precision preserved).
|
|
2372
|
+
sub: (e.ts ? (expanded ? new Date(e.ts).toLocaleString() : fmtRelTime(e.ts)) : 'no time') + ' · ' + role + ' · ' + type + tool + errMark + ' · ' + (expanded ? 'collapse' : 'expand'),
|
|
1937
2373
|
onClick: () => { expanded ? state.expandedEvents.delete(key) : state.expandedEvents.add(key); render(); },
|
|
1938
2374
|
};
|
|
1939
2375
|
}),
|
|
@@ -1945,7 +2381,14 @@ function historyMain() {
|
|
|
1945
2381
|
let copyToast = null;
|
|
1946
2382
|
// Hold the toast long enough to read (2.5s); the copy button label is inside a
|
|
1947
2383
|
// role=status region (history-actions) so AT announces the change.
|
|
1948
|
-
|
|
2384
|
+
let _copyToastTimer = null;
|
|
2385
|
+
function setCopyToast(msg) {
|
|
2386
|
+
copyToast = msg; render();
|
|
2387
|
+
// One timer per invocation: a rapid second copy must not have its feedback
|
|
2388
|
+
// truncated by the first copy's expiring timeout.
|
|
2389
|
+
clearTimeout(_copyToastTimer);
|
|
2390
|
+
_copyToastTimer = setTimeout(() => { copyToast = null; render(); }, 2500);
|
|
2391
|
+
}
|
|
1949
2392
|
function copySid() {
|
|
1950
2393
|
const sid = state.selectedSid;
|
|
1951
2394
|
if (!sid) return;
|
|
@@ -1971,6 +2414,8 @@ function resumeInChat(sess, { fromHash = false } = {}) {
|
|
|
1971
2414
|
state.selectedSid = state.chat.resumeSid || state.selectedSid;
|
|
1972
2415
|
if (!fromHash) writeHash({ push: true });
|
|
1973
2416
|
state.chat.messages = [];
|
|
2417
|
+
state.chat.totalCost = 0;
|
|
2418
|
+
state.chat.shownMessages = null;
|
|
1974
2419
|
state.chat.draft = '';
|
|
1975
2420
|
// Only claude-code supports --resume by sid; warn if we have to switch the
|
|
1976
2421
|
// user's selected agent rather than silently discarding it.
|
|
@@ -2019,19 +2464,28 @@ function uniqueProjects() {
|
|
|
2019
2464
|
function runningPanel() {
|
|
2020
2465
|
const running = Array.isArray(state.active) ? state.active : [];
|
|
2021
2466
|
if (!running.length) return null;
|
|
2467
|
+
const stopping = state.live.stopping || new Set();
|
|
2022
2468
|
return Panel({
|
|
2023
2469
|
key: 'runningPanel',
|
|
2024
2470
|
title: 'running · ' + running.length,
|
|
2025
|
-
children:
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2471
|
+
children: [
|
|
2472
|
+
// A discoverable path from "this running chat" to the management surface.
|
|
2473
|
+
h('div', { key: 'runall', class: 'resume-banner', role: 'group' },
|
|
2474
|
+
h('span', { key: 'runalllbl', class: 'lede' }, 'manage all running sessions'),
|
|
2475
|
+
Btn({ key: 'runalllive', onClick: () => navTo('live'), children: 'view all in live' })),
|
|
2476
|
+
...running.map((r) => {
|
|
2477
|
+
const agentName = agentById(r.agentId)?.name || r.agentId || 'agent';
|
|
2478
|
+
const elapsedMs = r.startedAt ? Math.max(0, Date.now() - r.startedAt) : 0;
|
|
2479
|
+
const isStopping = stopping.has(r.sessionId);
|
|
2480
|
+
// All children must be keyed VElements (mixing a keyed span with an
|
|
2481
|
+
// unkeyed one crashes webjsx applyDiff "reading 'key'").
|
|
2482
|
+
return h('div', { key: 'run' + r.sessionId, class: 'resume-banner', role: 'group' },
|
|
2483
|
+
h('span', { key: 'rd-' + r.sessionId, class: 'status-dot-disc status-dot-live', 'aria-hidden': 'true' }),
|
|
2484
|
+
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] : '')),
|
|
2485
|
+
Btn({ key: 'open' + r.sessionId, onClick: () => navTo('live'), children: 'open in live' }),
|
|
2486
|
+
Btn({ key: 'stop' + r.sessionId, disabled: isStopping, onClick: () => stopActiveChat(r.sessionId), children: isStopping ? 'stopping…' : 'stop' }));
|
|
2487
|
+
}),
|
|
2488
|
+
],
|
|
2035
2489
|
});
|
|
2036
2490
|
}
|
|
2037
2491
|
|
|
@@ -2049,7 +2503,7 @@ function historySide() {
|
|
|
2049
2503
|
key: 'sr-' + (r.sid || '?') + '-' + i,
|
|
2050
2504
|
rank: String(i + 1).padStart(3, '0'),
|
|
2051
2505
|
title: r.snippet || '(no snippet)',
|
|
2052
|
-
sub: (r.project || '?') + ' · ' + (r.role || '?') + (r.tool ? ' · ' + r.tool : '') + (r.ts ? ' · ' + fmtRelTime(r.ts) : ''),
|
|
2506
|
+
sub: (projectLabel(r.project) || '?') + ' · ' + (r.role || '?') + (r.tool ? ' · ' + r.tool : '') + (r.ts ? ' · ' + fmtRelTime(r.ts) : ''),
|
|
2053
2507
|
// Rail carries the same semantics as session rows: error > subagent > normal.
|
|
2054
2508
|
rail: r.isError ? 'flame' : (r.isSubagent ? 'purple' : 'green'),
|
|
2055
2509
|
// Carry the matched event's index so loadSession scrolls to + flashes
|
|
@@ -2153,11 +2607,13 @@ function normalizeBackend(s) {
|
|
|
2153
2607
|
async function saveBackend() {
|
|
2154
2608
|
if (!isValidUrl(state.backendDraft) || state.backendDraft === state.backend) return;
|
|
2155
2609
|
// Switching backend orphans the local chat transcript (it belongs to the old
|
|
2156
|
-
// server's sessions). Confirm once if there's a transcript to lose
|
|
2157
|
-
|
|
2158
|
-
|
|
2610
|
+
// server's sessions). Confirm once if there's a transcript to lose - and the
|
|
2611
|
+
// confirmation binds to the EXACT value confirmed: editing the URL after
|
|
2612
|
+
// arming re-arms instead of executing under a stale confirmation.
|
|
2613
|
+
if (state.chat.messages.length && state.confirmingBackend !== state.backendDraft) {
|
|
2614
|
+
state.confirmingBackend = state.backendDraft; render(); return;
|
|
2159
2615
|
}
|
|
2160
|
-
state.confirmingBackend =
|
|
2616
|
+
state.confirmingBackend = undefined;
|
|
2161
2617
|
const canonical = normalizeBackend(state.backendDraft);
|
|
2162
2618
|
state.backendDraft = canonical;
|
|
2163
2619
|
B.setBackend(canonical);
|
|
@@ -2201,7 +2657,9 @@ function settingsMain() {
|
|
|
2201
2657
|
PageHeader({
|
|
2202
2658
|
compact: true,
|
|
2203
2659
|
title: 'Settings',
|
|
2204
|
-
lede
|
|
2660
|
+
// The page lede describes the PAGE; the backend explanation lives in the
|
|
2661
|
+
// backend panel where it applies.
|
|
2662
|
+
lede: 'Connection, agents, appearance, keyboard, and local data.',
|
|
2205
2663
|
}),
|
|
2206
2664
|
h('div', { key: 'settings-grid', class: 'settings-grid' }, [
|
|
2207
2665
|
Panel({
|
|
@@ -2211,6 +2669,7 @@ function settingsMain() {
|
|
|
2211
2669
|
key: 'backendForm',
|
|
2212
2670
|
onSubmit: (e) => { e.preventDefault(); saveBackend(); },
|
|
2213
2671
|
}, [
|
|
2672
|
+
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.'),
|
|
2214
2673
|
TextField({
|
|
2215
2674
|
key: 'backendField',
|
|
2216
2675
|
label: 'backend url',
|
|
@@ -2219,13 +2678,19 @@ function settingsMain() {
|
|
|
2219
2678
|
'aria-describedby': !isValid ? 'backend-url-error' : undefined,
|
|
2220
2679
|
'aria-invalid': !isValid ? 'true' : 'false',
|
|
2221
2680
|
title: isValid ? 'Enter a valid URL or leave blank for same-origin' : 'Invalid URL format',
|
|
2222
|
-
onInput: (v) => {
|
|
2681
|
+
onInput: (v) => {
|
|
2682
|
+
state.backendDraft = v;
|
|
2683
|
+
// The armed confirmation refers to a different value now - disarm.
|
|
2684
|
+
if (state.confirmingBackend !== undefined && state.confirmingBackend !== v) state.confirmingBackend = undefined;
|
|
2685
|
+
render();
|
|
2686
|
+
},
|
|
2223
2687
|
}),
|
|
2224
2688
|
!isValid ? h('p', { key: 'err', id: 'backend-url-error', class: 'lede field-error', role: 'alert' }, 'Invalid URL format') : null,
|
|
2225
2689
|
state.backendStatus === 'connecting' ? h('p', { key: 'bst-connecting', class: 'lede', role: 'status' }, 'connecting…') : null,
|
|
2226
2690
|
state.backendStatus === 'ok' ? h('p', { key: 'bst-ok', class: 'lede', role: 'status' }, 'connected') : null,
|
|
2227
2691
|
state.backendStatus === 'failed' ? h('p', { key: 'bst-failed', class: 'lede field-error', role: 'alert' }, 'connection failed - check the URL') : null,
|
|
2228
|
-
state.confirmingBackend
|
|
2692
|
+
(state.confirmingBackend !== undefined && state.confirmingBackend === state.backendDraft && isValid && state.backendDraft !== state.backend)
|
|
2693
|
+
? h('p', { key: 'bcw', class: 'lede field-error', role: 'alert' }, 'changing backend discards this browser\'s chat transcript - press save again to confirm') : null,
|
|
2229
2694
|
healthSummary(),
|
|
2230
2695
|
Btn({
|
|
2231
2696
|
key: 'savebtn',
|
|
@@ -2238,6 +2703,10 @@ function settingsMain() {
|
|
|
2238
2703
|
}),
|
|
2239
2704
|
]),
|
|
2240
2705
|
}),
|
|
2706
|
+
// Panel order: connection group (backend + server) first, then agents,
|
|
2707
|
+
// then personal preferences (appearance / keyboard / data).
|
|
2708
|
+
serverPanel(),
|
|
2709
|
+
agentsPanel(),
|
|
2241
2710
|
Panel({
|
|
2242
2711
|
id: 'appearance',
|
|
2243
2712
|
title: 'appearance',
|
|
@@ -2246,8 +2715,6 @@ function settingsMain() {
|
|
|
2246
2715
|
ThemeToggle({ key: 'tt' }),
|
|
2247
2716
|
],
|
|
2248
2717
|
}),
|
|
2249
|
-
serverPanel(),
|
|
2250
|
-
agentsPanel(),
|
|
2251
2718
|
keyboardPanel(),
|
|
2252
2719
|
preferencesPanel(),
|
|
2253
2720
|
]),
|
|
@@ -2315,9 +2782,13 @@ function preferencesPanel() {
|
|
|
2315
2782
|
id: 'data',
|
|
2316
2783
|
title: 'data',
|
|
2317
2784
|
children: [
|
|
2318
|
-
|
|
2785
|
+
// The server version lives in the server panel; carry only a build stamp
|
|
2786
|
+
// here, and only when it differs from the server version.
|
|
2787
|
+
(window.__SERVER_VERSION && window.__SERVER_VERSION !== hh.version)
|
|
2788
|
+
? h('div', { key: 'ver', class: 'lede' }, 'build ' + window.__SERVER_VERSION)
|
|
2789
|
+
: null,
|
|
2319
2790
|
h('div', { key: 'lsize', class: 'lede', style: 'margin:.5em 0' },
|
|
2320
|
-
'local data: ' + (
|
|
2791
|
+
'local data: ' + fmtBytes(lsBytes) + ' across ' + lsKeys + ' key' + (lsKeys === 1 ? '' : 's')),
|
|
2321
2792
|
h('div', { key: 'expchatrow', style: 'margin:.5em 0' },
|
|
2322
2793
|
Btn({ key: 'expchat', disabled: !savedChat,
|
|
2323
2794
|
title: savedChat ? 'Download the saved chat transcript as JSON' : 'no saved chat',
|
|
@@ -2332,7 +2803,7 @@ function preferencesPanel() {
|
|
|
2332
2803
|
Btn({ key: 'cldyes', danger: true, onClick: clearLocalData, children: 'clear' }),
|
|
2333
2804
|
Btn({ key: 'cldno', onClick: () => { state.confirmingClearData = false; render(); }, children: 'cancel' })] })
|
|
2334
2805
|
: Btn({ key: 'cldbtn', onClick: clearLocalData, children: 'clear local data' }),
|
|
2335
|
-
],
|
|
2806
|
+
].filter(Boolean),
|
|
2336
2807
|
});
|
|
2337
2808
|
}
|
|
2338
2809
|
|
|
@@ -2399,6 +2870,20 @@ async function refreshHistory() {
|
|
|
2399
2870
|
// Index by sid so each live SSE event is an O(1) lookup, not an O(sessions)
|
|
2400
2871
|
// linear scan per event during a burst load.
|
|
2401
2872
|
state.sessionsBySid = new Map((state.sessions || []).map(s => [s.sid, s]));
|
|
2873
|
+
// Bound the live tally: drop entries with no activity in 24h and cap the
|
|
2874
|
+
// Map at ~200 most-recent sids (a long-lived tab otherwise accumulates
|
|
2875
|
+
// every sid ever seen, and dead entries could resurrect wrong externals).
|
|
2876
|
+
if (state.live.tally) {
|
|
2877
|
+
const cutoff = Date.now() - 24 * 3600 * 1000;
|
|
2878
|
+
for (const [sid, t] of [...state.live.tally]) {
|
|
2879
|
+
if (!t.last || t.last < cutoff) state.live.tally.delete(sid);
|
|
2880
|
+
}
|
|
2881
|
+
if (state.live.tally.size > 200) {
|
|
2882
|
+
state.live.tally = new Map([...state.live.tally.entries()]
|
|
2883
|
+
.sort((a, b) => (b[1].last || 0) - (a[1].last || 0))
|
|
2884
|
+
.slice(0, 200));
|
|
2885
|
+
}
|
|
2886
|
+
}
|
|
2402
2887
|
// If the selected session vanished from the list (deleted/aged out server-side),
|
|
2403
2888
|
// drop the selection so the main pane doesn't sit on stale events that can no
|
|
2404
2889
|
// longer be reloaded; fall back to the no-selection empty state.
|
|
@@ -2436,6 +2921,10 @@ async function runSearch() {
|
|
|
2436
2921
|
render();
|
|
2437
2922
|
try {
|
|
2438
2923
|
state.searchHits = await B.searchHistory(state.backend, q, 60);
|
|
2924
|
+
// Announce the settled count for AT - the sessions-column count is only
|
|
2925
|
+
// rendered visually (the history actions row is the only aria-live region).
|
|
2926
|
+
const n = (state.searchHits.results || []).length;
|
|
2927
|
+
announce((n || 'no') + ' matches for ' + q);
|
|
2439
2928
|
} catch (e) {
|
|
2440
2929
|
state.searchHits = { query: q, results: [], error: errText(e) };
|
|
2441
2930
|
} finally {
|
|
@@ -2477,6 +2966,10 @@ async function loadSession(sid, { focusEventI = null, focusEventTs = null, fromH
|
|
|
2477
2966
|
});
|
|
2478
2967
|
try {
|
|
2479
2968
|
state.events = await B.getSessionEvents(state.backend, sid);
|
|
2969
|
+
// ccsniff's events route has no ?limit= (checked: router.js returns the
|
|
2970
|
+
// whole session) - cap in-memory state at the most-recent 5000 so a
|
|
2971
|
+
// monster session can't pin the tab; the render window stays 300+load-older.
|
|
2972
|
+
if (state.events.length > 5000) state.events = state.events.slice(-5000);
|
|
2480
2973
|
clearTimeout(slowTimer);
|
|
2481
2974
|
state.eventsSlow = false;
|
|
2482
2975
|
state.eventsLoaded = true;
|
|
@@ -2717,14 +3210,49 @@ function focusSearch() {
|
|
|
2717
3210
|
const el = document.querySelector('#app input[type="search"]');
|
|
2718
3211
|
el?.focus();
|
|
2719
3212
|
}
|
|
3213
|
+
// Focus the active surface's filter input (Files grid filter / Live dashboard
|
|
3214
|
+
// filter) - both are search-type inputs inside the main region.
|
|
3215
|
+
function focusFilter() {
|
|
3216
|
+
const el = document.querySelector('#agentgui-main input[type="search"]')
|
|
3217
|
+
|| document.querySelector('#agentgui-main .ds-file-filter-input')
|
|
3218
|
+
|| document.querySelector('#agentgui-main input[type="text"]');
|
|
3219
|
+
el?.focus();
|
|
3220
|
+
return !!el;
|
|
3221
|
+
}
|
|
2720
3222
|
window.addEventListener('keydown', (e) => {
|
|
2721
3223
|
const t = e.target;
|
|
2722
3224
|
const typing = t && (t.tagName === 'TEXTAREA' || t.tagName === 'INPUT' || t.isContentEditable);
|
|
3225
|
+
// One explicit chord BEFORE the modifier early-return: Mod+Shift+L focuses
|
|
3226
|
+
// the composer from anywhere, even while typing in another field.
|
|
3227
|
+
if ((e.metaKey || e.ctrlKey) && e.shiftKey && (e.key === 'L' || e.key === 'l')) {
|
|
3228
|
+
e.preventDefault();
|
|
3229
|
+
navTo('chat');
|
|
3230
|
+
requestAnimationFrame(() => focusComposer());
|
|
3231
|
+
announce('composer focused');
|
|
3232
|
+
return;
|
|
3233
|
+
}
|
|
2723
3234
|
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
|
2724
3235
|
if (typing) {
|
|
2725
3236
|
if (e.key === 'Escape') t.blur();
|
|
2726
3237
|
return;
|
|
2727
3238
|
}
|
|
3239
|
+
if (e.key === 'Escape') {
|
|
3240
|
+
// Priority ladder for transient state (modals/drawers are kit-handled):
|
|
3241
|
+
// shortcuts overlay > armed confirms > stop a streaming generation.
|
|
3242
|
+
if (state.showShortcuts) { state.showShortcuts = false; render(); announce('shortcuts closed'); return; }
|
|
3243
|
+
// File-mutation dialog: close on Escape wherever focus sits (the kit's
|
|
3244
|
+
// backdrop listener covers in-dialog focus; this covers everything else).
|
|
3245
|
+
if (state.files.dialog) { if (!state.files.dialog.busy) closeFileDialog(); return; }
|
|
3246
|
+
if (state.chat.confirmingEdit) { state.chat.confirmingEdit = null; render(); announce('edit cancelled'); return; }
|
|
3247
|
+
if (state.confirmingNewChat) { clearTimeout(_newChatArmTimer); state.confirmingNewChat = false; render(); announce('new chat cancelled'); return; }
|
|
3248
|
+
if (state.live.confirmingStopAll || state.live.confirmingStopSelected) {
|
|
3249
|
+
state.live.confirmingStopAll = false; state.live.confirmingStopSelected = false;
|
|
3250
|
+
clearTimeout(_stopAllArmTimer); clearTimeout(_stopSelArmTimer);
|
|
3251
|
+
render(); announce('stop cancelled'); return;
|
|
3252
|
+
}
|
|
3253
|
+
if (state.chat.busy && state.tab === 'chat') { cancelChat(); announce('generation stopped'); return; }
|
|
3254
|
+
return;
|
|
3255
|
+
}
|
|
2728
3256
|
if (gPending) {
|
|
2729
3257
|
gPending = false;
|
|
2730
3258
|
if (e.key === 'c') { navTo('chat'); return; }
|
|
@@ -2737,13 +3265,24 @@ window.addEventListener('keydown', (e) => {
|
|
|
2737
3265
|
if (e.key === 'g') { gPending = true; setTimeout(() => { gPending = false; }, 1000); return; }
|
|
2738
3266
|
if (e.key === 'n' && state.tab === 'chat') { e.preventDefault(); newChat(); return; }
|
|
2739
3267
|
if (e.key === '/') {
|
|
2740
|
-
// /
|
|
2741
|
-
//
|
|
3268
|
+
// / targets the active surface's find affordance: search on history,
|
|
3269
|
+
// composer on chat, the filter inputs on files/live. Settings has no
|
|
3270
|
+
// field - the only documented no-op.
|
|
2742
3271
|
if (state.tab === 'history') { e.preventDefault(); focusSearch(); announce('search focused'); }
|
|
2743
3272
|
else if (state.tab === 'chat') { e.preventDefault(); focusComposer(); announce('composer focused'); }
|
|
3273
|
+
else if (state.tab === 'files' || state.tab === 'live') { e.preventDefault(); if (focusFilter()) announce('filter focused'); }
|
|
2744
3274
|
return;
|
|
2745
3275
|
}
|
|
2746
3276
|
if (e.key === '?') { state.showShortcuts = !state.showShortcuts; render(); return; }
|
|
2747
3277
|
});
|
|
2748
3278
|
|
|
3279
|
+
// A file dropped anywhere outside a DropZone must never navigate the browser
|
|
3280
|
+
// away (destroying the live session view). DropZones handle their own events.
|
|
3281
|
+
window.addEventListener('dragover', (e) => {
|
|
3282
|
+
if (!(e.target instanceof Element) || !e.target.closest('.ds-dropzone')) e.preventDefault();
|
|
3283
|
+
});
|
|
3284
|
+
window.addEventListener('drop', (e) => {
|
|
3285
|
+
if (!(e.target instanceof Element) || !e.target.closest('.ds-dropzone')) e.preventDefault();
|
|
3286
|
+
});
|
|
3287
|
+
|
|
2749
3288
|
init();
|