agentgui 1.0.959 → 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-logic-predictability.js +88 -0
- package/AGENTS.md +11 -9
- package/PUNCHLIST-LOGIC.md +405 -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 +660 -136
- package/site/app/js/backend.js +25 -1
- package/site/app/vendor/anentrypoint-design/247420.css +116 -2
- package/site/app/vendor/anentrypoint-design/247420.js +13 -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,7 +583,10 @@ 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();
|
|
@@ -556,7 +633,9 @@ const DATE_GROUP_ORDER = ['Running', 'Today', 'Yesterday', 'This week', 'Earlier
|
|
|
556
633
|
// chats are pinned to a "Running" section at the top (a live workspace surfaces
|
|
557
634
|
// in-flight work first), the rest bucket by recency. Returns { items, groups }.
|
|
558
635
|
function sessionGroups(sessionsView) {
|
|
559
|
-
|
|
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));
|
|
560
639
|
const buckets = new Map();
|
|
561
640
|
for (const s of sessionsView) {
|
|
562
641
|
const label = runningSids.has(s.sid) ? 'Running' : dateGroupLabel(s.last);
|
|
@@ -608,7 +687,7 @@ function sessionsColumn() {
|
|
|
608
687
|
}
|
|
609
688
|
const sessionsView = visibleSessions();
|
|
610
689
|
const sliced = sessionsView.slice(0, state.sessionsLimit);
|
|
611
|
-
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));
|
|
612
691
|
const items = sliced.map((s) => ({
|
|
613
692
|
sid: s.sid,
|
|
614
693
|
title: projectLabel(s.title) || projectLabel(s.project) || s.sid,
|
|
@@ -636,7 +715,7 @@ function sessionsColumn() {
|
|
|
636
715
|
onNew: () => { navTo('chat'); newChat(); },
|
|
637
716
|
onSelect: (s) => { if (state.tab === 'chat') resumeInChat({ sid: s.sid }); else loadSession(s.sid); },
|
|
638
717
|
loading: state.tab === 'history' && !state.sessions.length && !state.historyError,
|
|
639
|
-
loadingText: state.historySlow ? 'Indexing your Claude history
|
|
718
|
+
loadingText: state.historySlow ? 'Indexing your Claude history — the first load can take a minute…' : undefined,
|
|
640
719
|
error: state.historyError,
|
|
641
720
|
emptyText: 'No conversations yet',
|
|
642
721
|
});
|
|
@@ -656,6 +735,10 @@ async function loadDir(dirPath, { fromHash = false } = {}) {
|
|
|
656
735
|
state.files.loading = true; state.files.error = null; render();
|
|
657
736
|
try {
|
|
658
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; }
|
|
659
742
|
state.files.path = j.path;
|
|
660
743
|
state.files.segments = j.segments || [];
|
|
661
744
|
state.files.entries = j.entries || [];
|
|
@@ -740,7 +823,12 @@ function openFileDialog(kind, file) {
|
|
|
740
823
|
state.files.dialog = { kind, file: file || null, error: null, busy: false };
|
|
741
824
|
render();
|
|
742
825
|
}
|
|
743
|
-
function closeFileDialog() {
|
|
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
|
+
}
|
|
744
832
|
async function runFileMutation(fn, doneMsg) {
|
|
745
833
|
const d = state.files.dialog;
|
|
746
834
|
if (!d || d.busy) return;
|
|
@@ -751,6 +839,9 @@ async function runFileMutation(fn, doneMsg) {
|
|
|
751
839
|
announce(doneMsg);
|
|
752
840
|
await loadDir(state.files.path, { fromHash: true }); // refresh in place, no history entry
|
|
753
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; }
|
|
754
845
|
d.busy = false; d.error = fileMutationCopy(e); render();
|
|
755
846
|
}
|
|
756
847
|
}
|
|
@@ -759,55 +850,101 @@ async function runFileMutation(fn, doneMsg) {
|
|
|
759
850
|
async function uploadFiles(fileList) {
|
|
760
851
|
const dir = state.files.path;
|
|
761
852
|
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
|
-
|
|
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();
|
|
770
872
|
}
|
|
771
|
-
|
|
873
|
+
} finally {
|
|
874
|
+
state.files.uploading = false;
|
|
772
875
|
}
|
|
773
876
|
announce('upload finished');
|
|
774
|
-
await loadDir(
|
|
877
|
+
await loadDir(state.files.path, { fromHash: true });
|
|
775
878
|
// Keep error rows visible; clear the list entirely when everything landed.
|
|
776
|
-
if (!
|
|
879
|
+
if (!(state.files.uploads || []).some(i => i.error)) state.files.uploads = null;
|
|
880
|
+
render();
|
|
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;
|
|
777
903
|
render();
|
|
778
904
|
}
|
|
779
905
|
// The active file dialog (rename/delete/mkdir) as a kit modal, or null.
|
|
780
906
|
function fileDialog() {
|
|
781
907
|
const d = state.files && state.files.dialog;
|
|
782
908
|
if (!d) return null;
|
|
783
|
-
|
|
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).
|
|
784
911
|
if (d.kind === 'rename') {
|
|
785
|
-
return
|
|
912
|
+
return PromptDialog({
|
|
786
913
|
title: 'Rename ' + d.file.name, value: d.file.name, placeholder: 'new name',
|
|
914
|
+
error: d.error || null, busy: !!d.busy,
|
|
787
915
|
confirmLabel: d.busy ? 'renaming...' : 'rename', cancelLabel: 'cancel',
|
|
788
916
|
onCancel: closeFileDialog,
|
|
789
|
-
onConfirm: (v) => {
|
|
790
|
-
|
|
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
|
+
});
|
|
791
923
|
}
|
|
792
924
|
if (d.kind === 'delete') {
|
|
793
925
|
const isDir = d.file.type === 'dir';
|
|
794
|
-
return
|
|
926
|
+
return ConfirmDialog({
|
|
795
927
|
title: 'Delete ' + d.file.name,
|
|
796
928
|
message: isDir
|
|
797
929
|
? 'Delete this folder and everything inside it? This cannot be undone.'
|
|
798
930
|
: 'Delete this file? This cannot be undone.',
|
|
931
|
+
error: d.error || null, busy: !!d.busy,
|
|
799
932
|
confirmLabel: d.busy ? 'deleting...' : 'delete', cancelLabel: 'cancel', destructive: true,
|
|
800
933
|
onCancel: closeFileDialog,
|
|
801
934
|
onConfirm: () => runFileMutation(() => B.deleteEntry(state.backend, d.file.path, isDir), 'deleted ' + d.file.name),
|
|
802
|
-
})
|
|
935
|
+
});
|
|
803
936
|
}
|
|
804
937
|
if (d.kind === 'mkdir') {
|
|
805
|
-
return
|
|
938
|
+
return PromptDialog({
|
|
806
939
|
title: 'New folder', value: '', placeholder: 'folder name',
|
|
940
|
+
error: d.error || null, busy: !!d.busy,
|
|
807
941
|
confirmLabel: d.busy ? 'creating...' : 'create', cancelLabel: 'cancel',
|
|
808
942
|
onCancel: closeFileDialog,
|
|
809
|
-
onConfirm: (v) => {
|
|
810
|
-
|
|
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
|
+
});
|
|
811
948
|
}
|
|
812
949
|
return null;
|
|
813
950
|
}
|
|
@@ -1005,7 +1142,17 @@ function filesMain() {
|
|
|
1005
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.' }),
|
|
1006
1143
|
rootsRow ? h('div', { key: 'froots' }, rootsRow) : null,
|
|
1007
1144
|
h('div', { key: 'ftb' }, toolbar),
|
|
1008
|
-
(f.uploads && f.uploads.length) ? h('div', { key: 'fup' }, UploadProgress({
|
|
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,
|
|
1009
1156
|
h('div', { key: 'fbody' }, droppableBody),
|
|
1010
1157
|
fileDialog(),
|
|
1011
1158
|
// Inline pane handles wide-screen preview; the modal is only the <900px
|
|
@@ -1054,10 +1201,24 @@ function armStopSelected() {
|
|
|
1054
1201
|
}
|
|
1055
1202
|
|
|
1056
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.
|
|
1057
1206
|
async function stopAllActive(sessions) {
|
|
1058
1207
|
const sids = (Array.isArray(sessions) ? sessions : (state.active || [])).map(s => s.sid || s.sessionId).filter(Boolean);
|
|
1059
|
-
|
|
1060
|
-
|
|
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;
|
|
1061
1222
|
}
|
|
1062
1223
|
|
|
1063
1224
|
// How long a session can go without activity (and without a running tool)
|
|
@@ -1074,71 +1235,150 @@ function liveMain() {
|
|
|
1074
1235
|
const tally = state.live.tally || new Map();
|
|
1075
1236
|
const now = Date.now();
|
|
1076
1237
|
// Live-stream health: connected (recent event), connecting (opened, no event
|
|
1077
|
-
// yet), or
|
|
1078
|
-
|
|
1079
|
-
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);
|
|
1080
1244
|
let sessions = (Array.isArray(state.active) ? state.active : []).map((r) => {
|
|
1081
|
-
//
|
|
1082
|
-
//
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
const
|
|
1086
|
-
const
|
|
1087
|
-
const
|
|
1088
|
-
|
|
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);
|
|
1089
1259
|
const counterBits = [];
|
|
1090
|
-
if (events
|
|
1260
|
+
if (events >= 0) counterBits.push(events + ' ev');
|
|
1091
1261
|
if (tools) counterBits.push(tools + ' tools');
|
|
1092
1262
|
if (errors) counterBits.push(errors + ' err');
|
|
1093
|
-
// Current tool:
|
|
1094
|
-
//
|
|
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).
|
|
1095
1266
|
let currentTool = '';
|
|
1096
|
-
if (state.chat.resumeSid ===
|
|
1267
|
+
if (state.chat.resumeSid === realSid && state.chat.busy) {
|
|
1097
1268
|
const msgs = state.chat.messages || [];
|
|
1098
1269
|
const last = msgs[msgs.length - 1];
|
|
1099
1270
|
const running = last && Array.isArray(last.parts) && last.parts.filter(p => p && p.kind === 'tool' && p.status === 'running').slice(-1)[0];
|
|
1100
1271
|
if (running) currentTool = running.name || '';
|
|
1101
1272
|
}
|
|
1102
|
-
|
|
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.
|
|
1103
1277
|
let status = 'running';
|
|
1104
|
-
if (
|
|
1105
|
-
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 : '');
|
|
1106
1286
|
return {
|
|
1107
1287
|
sid: r.sessionId,
|
|
1288
|
+
realSid,
|
|
1289
|
+
title: title || undefined,
|
|
1108
1290
|
agent: agentById(r.agentId)?.name || r.agentId || 'agent',
|
|
1109
1291
|
model: r.model || '',
|
|
1110
1292
|
cwd: r.cwd ? r.cwd.split(/[/\\]/).filter(Boolean).slice(-1)[0] : '',
|
|
1111
|
-
elapsed:
|
|
1293
|
+
elapsed: elapsedMs ? fmtDuration(elapsedMs) : '',
|
|
1294
|
+
elapsedMs,
|
|
1295
|
+
startedTs,
|
|
1112
1296
|
counter: counterBits.length ? counterBits.join(' · ') : null,
|
|
1113
|
-
lastActivity: lastTs ? fmtRelTime(lastTs) : '',
|
|
1297
|
+
lastActivity: lastTs ? fmtRelTime(lastTs + (state.live.clockSkew || 0)) : '',
|
|
1298
|
+
lastTs,
|
|
1299
|
+
errors,
|
|
1114
1300
|
currentTool,
|
|
1115
1301
|
status,
|
|
1302
|
+
stopping: stoppingSet.has(r.sessionId),
|
|
1116
1303
|
};
|
|
1117
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
|
+
}
|
|
1118
1342
|
// W12: in-dir filter + errors-only toggle.
|
|
1119
1343
|
const lv = state.live;
|
|
1120
1344
|
if (lv.errorsOnly) sessions = sessions.filter(s => s.status === 'error');
|
|
1121
1345
|
if (lv.filter) {
|
|
1122
1346
|
const q = lv.filter.toLowerCase();
|
|
1123
|
-
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));
|
|
1124
1348
|
}
|
|
1125
|
-
//
|
|
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.
|
|
1126
1352
|
const sortKey = lv.sort || 'status';
|
|
1127
1353
|
sessions.sort((a, b) => {
|
|
1128
|
-
|
|
1129
|
-
if (
|
|
1130
|
-
if (sortKey === '
|
|
1131
|
-
|
|
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));
|
|
1132
1361
|
});
|
|
1133
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;
|
|
1134
1368
|
return [
|
|
1135
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,
|
|
1136
1376
|
PageHeader({ compact: true, title: 'Live sessions', lede: 'Every in-flight agent session, managed at once. Stop, open, or jump to events per session.' }),
|
|
1137
1377
|
SessionDashboard({
|
|
1138
1378
|
sessions,
|
|
1139
1379
|
offline,
|
|
1140
1380
|
streamState,
|
|
1141
|
-
activeSid: state.chat.resumeSid,
|
|
1381
|
+
activeSid: activeCard ? activeCard.sid : state.chat.resumeSid, // W13
|
|
1142
1382
|
sort: { value: sortKey, onChange: (v) => { state.live.sort = v; persistLivePrefs(); render(); } },
|
|
1143
1383
|
filter: { value: lv.filter || '', placeholder: 'Filter sessions', onInput: (v) => { state.live.filter = v; render(); } },
|
|
1144
1384
|
errorsOnly: !!lv.errorsOnly,
|
|
@@ -1155,21 +1395,27 @@ function liveMain() {
|
|
|
1155
1395
|
confirmingStopSelected: !!lv.confirmingStopSelected,
|
|
1156
1396
|
onArmStopAll: armStopAll,
|
|
1157
1397
|
onArmStopSelected: armStopSelected,
|
|
1158
|
-
onStopSelected: (sids) => {
|
|
1398
|
+
onStopSelected: async (sids) => {
|
|
1159
1399
|
state.live.confirmingStopSelected = false;
|
|
1160
1400
|
clearTimeout(_stopSelArmTimer);
|
|
1161
|
-
|
|
1162
|
-
|
|
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();
|
|
1163
1408
|
},
|
|
1164
|
-
emptyText: 'No live sessions
|
|
1165
|
-
onStop: (s) => stopActiveChat(s.sid),
|
|
1166
|
-
onStopAll: (all) => {
|
|
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) => {
|
|
1167
1412
|
state.live.confirmingStopAll = false;
|
|
1168
1413
|
clearTimeout(_stopAllArmTimer);
|
|
1169
|
-
stopAllActive(all);
|
|
1414
|
+
await stopAllActive((all || []).filter(s => !s.external));
|
|
1415
|
+
render();
|
|
1170
1416
|
},
|
|
1171
|
-
onOpen: (s) => { resumeInChat({ sid: s.sid }); },
|
|
1172
|
-
onView: (s) => { navTo('history'); loadSession(s.sid); },
|
|
1417
|
+
onOpen: (s) => { resumeInChat({ sid: s.realSid || s.sid }); },
|
|
1418
|
+
onView: (s) => { navTo('history'); loadSession(s.realSid || s.sid); },
|
|
1173
1419
|
}),
|
|
1174
1420
|
].filter(Boolean);
|
|
1175
1421
|
}
|
|
@@ -1291,7 +1537,7 @@ function chatMain() {
|
|
|
1291
1537
|
// than as a separate stacked Alert, so resume context reads as one block.
|
|
1292
1538
|
banners.push(h('div', { key: 'rb', class: 'resume-banner', role: 'status' },
|
|
1293
1539
|
h('span', { key: 'rbtxt', class: 'lede' },
|
|
1294
|
-
'
|
|
1540
|
+
'next message continues session ' + state.chat.resumeSid.slice(0, 8) + '…'
|
|
1295
1541
|
+ (state.chat.resumeNote ? ' - ' + state.chat.resumeNote : '')),
|
|
1296
1542
|
Btn({ key: 'rclr', onClick: () => { state.chat.resumeSid = null; state.chat.resumeNote = null; render(); }, children: 'clear' })));
|
|
1297
1543
|
}
|
|
@@ -1303,12 +1549,30 @@ function chatMain() {
|
|
|
1303
1549
|
banners.push(Alert({ key: 'confnew', kind: 'warn', title: 'Clear chat history?',
|
|
1304
1550
|
children: [
|
|
1305
1551
|
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' })] }));
|
|
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' })] }));
|
|
1308
1554
|
}
|
|
1309
1555
|
if (state.cwdError) {
|
|
1310
1556
|
banners.push(Alert({ key: 'cwderr', kind: 'warn', title: 'Invalid working directory', children: state.cwdError }));
|
|
1311
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
|
+
}
|
|
1312
1576
|
if (state.agentsError) {
|
|
1313
1577
|
banners.push(Alert({ key: 'agerr', kind: 'error', title: 'Could not load agents from the server',
|
|
1314
1578
|
children: [
|
|
@@ -1351,9 +1615,12 @@ function chatMain() {
|
|
|
1351
1615
|
messages: state.chat.messages,
|
|
1352
1616
|
busy: state.chat.busy,
|
|
1353
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.
|
|
1354
1621
|
status: state.chat.busy
|
|
1355
|
-
? (state.health.ws === 'reconnecting' ? '
|
|
1356
|
-
: (state.modelsLoading ? 'loading models…' : ((
|
|
1622
|
+
? (state.health.ws === 'reconnecting' ? 'connecting…' : 'streaming…')
|
|
1623
|
+
: (state.modelsLoading ? 'loading models…' : ((lastMsg && lastMsg.role === 'assistant' && lastMsg.stopped) ? 'stopped' : 'ready')),
|
|
1357
1624
|
agentName,
|
|
1358
1625
|
placeholder,
|
|
1359
1626
|
canSend: canSend(),
|
|
@@ -1363,18 +1630,35 @@ function chatMain() {
|
|
|
1363
1630
|
'What are the recent changes on this branch?',
|
|
1364
1631
|
'Find and explain the main entry point',
|
|
1365
1632
|
],
|
|
1366
|
-
|
|
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); },
|
|
1367
1636
|
// W14: a stable per-agent product mark (a line-SVG, not a per-agent letter)
|
|
1368
1637
|
// and the active target shown inline above the composer.
|
|
1369
1638
|
avatar: state.selectedAgent ? h('span', { class: 'agentchat-avatar-mark', 'aria-hidden': 'true' }, Icon('forum', { size: 16 })) : undefined,
|
|
1370
1639
|
composerContext: state.selectedAgent ? {
|
|
1371
|
-
|
|
1372
|
-
|
|
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),
|
|
1373
1657
|
} : undefined,
|
|
1374
1658
|
// W15: contextual follow-up chips derived from the settled last turn
|
|
1375
1659
|
// (tool error / code fence / file path), seeded statically otherwise.
|
|
1376
1660
|
followups: chatFollowups(),
|
|
1377
|
-
onFollowupClick: (t) => { if (!canSend()) return;
|
|
1661
|
+
onFollowupClick: (t) => { if (!canSend()) return; sendChat(t); },
|
|
1378
1662
|
// Transcript export: copy all / markdown / json.
|
|
1379
1663
|
exportActions: state.chat.messages.length ? [
|
|
1380
1664
|
{ label: 'copy all', title: 'Copy the whole transcript as markdown', onClick: () => copyText(transcriptToMarkdown(state.chat.messages), 'transcript copied') },
|
|
@@ -1399,6 +1683,15 @@ function chatMain() {
|
|
|
1399
1683
|
cwd: state.chatCwd,
|
|
1400
1684
|
cwdEditing: !!state.cwdEditing,
|
|
1401
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(); },
|
|
1402
1695
|
onSelectAgent: (v) => selectAgent(v),
|
|
1403
1696
|
onSelectModel: (v) => selectModel(v),
|
|
1404
1697
|
onNewChat: newChat,
|
|
@@ -1444,9 +1737,9 @@ function chatMain() {
|
|
|
1444
1737
|
if (state.chatCwd) lsSet('agentgui.cwd', state.chatCwd); else lsRemove('agentgui.cwd');
|
|
1445
1738
|
state.cwdEditing = false; state.cwdDraft = undefined; render();
|
|
1446
1739
|
},
|
|
1447
|
-
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(); },
|
|
1448
1741
|
onCwdClear: () => { state.chatCwd = ''; lsRemove('agentgui.cwd'); render(); },
|
|
1449
|
-
onCwdDraft: (v) => { state.cwdDraft = v; },
|
|
1742
|
+
onCwdDraft: (v) => { state.cwdDraft = v; state.cwdError = null; debouncedCwdProbe(); },
|
|
1450
1743
|
}),
|
|
1451
1744
|
].filter(Boolean);
|
|
1452
1745
|
}
|
|
@@ -1460,14 +1753,28 @@ function offlineBanner() {
|
|
|
1460
1753
|
// (The working-directory bar now lives in the AgentChat kit; agentgui wires its
|
|
1461
1754
|
// cwd state + handlers as kit callbacks in chatMain.)
|
|
1462
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;
|
|
1463
1762
|
function newChat() {
|
|
1464
|
-
if (state.chat.messages.length
|
|
1465
|
-
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();
|
|
1466
1769
|
return;
|
|
1467
1770
|
}
|
|
1771
|
+
confirmNewChat();
|
|
1772
|
+
}
|
|
1773
|
+
function confirmNewChat() {
|
|
1774
|
+
clearTimeout(_newChatArmTimer);
|
|
1468
1775
|
state.confirmingNewChat = false;
|
|
1469
1776
|
state.chat.abort?.abort();
|
|
1470
|
-
state.chat = { messages: [], busy: false, abort: null, draft: '', resumeSid: null, usage: null, confirmingEdit: null, totalCost: 0 };
|
|
1777
|
+
state.chat = { messages: [], busy: false, abort: null, draft: '', resumeSid: null, usage: null, confirmingEdit: null, totalCost: 0, shownMessages: null };
|
|
1471
1778
|
lsRemove(CHAT_KEY);
|
|
1472
1779
|
render();
|
|
1473
1780
|
}
|
|
@@ -1478,10 +1785,13 @@ function cancelChat() {
|
|
|
1478
1785
|
state.chat.abort?.abort();
|
|
1479
1786
|
// Drop a trailing empty assistant shell (aborted before any content) from the
|
|
1480
1787
|
// live array so the message count + suggestions-empty check stay correct; the
|
|
1481
|
-
// 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.
|
|
1482
1790
|
const msgs = state.chat.messages;
|
|
1483
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;
|
|
1484
1793
|
if (state.chat.busy) { state.chat.busy = false; }
|
|
1794
|
+
refreshActive();
|
|
1485
1795
|
render();
|
|
1486
1796
|
}
|
|
1487
1797
|
|
|
@@ -1492,19 +1802,65 @@ const CHAT_KEY = 'agentgui.chat';
|
|
|
1492
1802
|
function isEmptyTurn(m) {
|
|
1493
1803
|
return m.role === 'assistant' && !m.content && !(Array.isArray(m.parts) && m.parts.length);
|
|
1494
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
|
+
}
|
|
1495
1830
|
function persistChat() {
|
|
1496
1831
|
try {
|
|
1497
1832
|
const msgs = state.chat.messages
|
|
1498
1833
|
.filter(m => !isEmptyTurn(m))
|
|
1499
|
-
.
|
|
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 }));
|
|
1500
1836
|
const draft = (state.chat.draft || '').trim() ? state.chat.draft : '';
|
|
1501
1837
|
// Persist when there is a transcript OR a non-empty draft (a typed-but-not-
|
|
1502
1838
|
// sent message must survive a reload too).
|
|
1503
1839
|
if (!msgs.length && !draft) { lsRemove(CHAT_KEY); return; }
|
|
1504
|
-
|
|
1505
|
-
|
|
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
|
+
}
|
|
1506
1850
|
}
|
|
1507
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
|
+
});
|
|
1508
1864
|
function restoreChat() {
|
|
1509
1865
|
try {
|
|
1510
1866
|
const raw = lsGet(CHAT_KEY);
|
|
@@ -1516,6 +1872,10 @@ function restoreChat() {
|
|
|
1516
1872
|
state.chat.messages = saved.messages
|
|
1517
1873
|
.filter(m => !isEmptyTurn(m))
|
|
1518
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;
|
|
1519
1879
|
state.chat.resumeSid = saved.resumeSid || null;
|
|
1520
1880
|
// Restore the agent/model the transcript belongs to, so a restored chat
|
|
1521
1881
|
// isn't silently shown under whatever agent the picker defaulted to.
|
|
@@ -1605,6 +1965,8 @@ function chatFollowups() {
|
|
|
1605
1965
|
function retryLastTurn() {
|
|
1606
1966
|
if (!canSend() && !state.chat.busy) { /* allow when idle */ }
|
|
1607
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;
|
|
1608
1970
|
const msgs = state.chat.messages;
|
|
1609
1971
|
// Find the trailing assistant turn and the user message before it.
|
|
1610
1972
|
let ai = msgs.length - 1;
|
|
@@ -1616,6 +1978,7 @@ function retryLastTurn() {
|
|
|
1616
1978
|
const userText = msgs[ui].content || '';
|
|
1617
1979
|
// Drop the user+assistant pair (and anything after) and resend the user text.
|
|
1618
1980
|
state.chat.messages = msgs.slice(0, ui);
|
|
1981
|
+
state.chat.totalCost = computeTotalCost(); // discarded turns leave the spend
|
|
1619
1982
|
state.chat.draft = userText;
|
|
1620
1983
|
persistChat();
|
|
1621
1984
|
if (canSend()) sendChat();
|
|
@@ -1635,6 +1998,7 @@ function confirmEditAndResend() {
|
|
|
1635
1998
|
if (!ce || state.chat.busy) return;
|
|
1636
1999
|
state.chat.confirmingEdit = null;
|
|
1637
2000
|
state.chat.messages = state.chat.messages.slice(0, ce.idx);
|
|
2001
|
+
state.chat.totalCost = computeTotalCost(); // discarded turns leave the spend
|
|
1638
2002
|
// Never --resume a session whose tail diverged from what the server saw.
|
|
1639
2003
|
state.chat.resumeSid = null;
|
|
1640
2004
|
state.chat.resumeNote = null;
|
|
@@ -1648,15 +2012,31 @@ function cancelEditAndResend() {
|
|
|
1648
2012
|
render();
|
|
1649
2013
|
}
|
|
1650
2014
|
|
|
1651
|
-
|
|
1652
|
-
|
|
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
|
+
}
|
|
2021
|
+
|
|
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();
|
|
1653
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;
|
|
1654
2030
|
const t = timeNow();
|
|
1655
2031
|
const userMsg = { id: 'u' + Date.now(), role: 'user', content: text, time: t };
|
|
1656
2032
|
const curMsg = { id: 'a' + (Date.now() + 1), role: 'assistant', content: '', time: t, parts: [] };
|
|
1657
2033
|
state.chat.messages = [...state.chat.messages, userMsg, curMsg];
|
|
1658
|
-
state.chat.draft = '';
|
|
2034
|
+
if (textArg == null) state.chat.draft = '';
|
|
1659
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();
|
|
1660
2040
|
const ctrl = new AbortController();
|
|
1661
2041
|
state.chat.abort = ctrl;
|
|
1662
2042
|
persistChat();
|
|
@@ -1674,6 +2054,10 @@ async function sendChat() {
|
|
|
1674
2054
|
// another agent (it makes agy spuriously run --continue).
|
|
1675
2055
|
resumeSid: (state.selectedAgent === 'claude-code' && state.chat.resumeSid) || undefined,
|
|
1676
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;
|
|
1677
2061
|
if (ev.type === 'session') {
|
|
1678
2062
|
// Remember the server's session id so the NEXT turn resumes this
|
|
1679
2063
|
// conversation instead of cold-spawning. Only claude-code supports
|
|
@@ -1701,10 +2085,17 @@ async function sendChat() {
|
|
|
1701
2085
|
turns: b.num_turns != null ? b.num_turns : null,
|
|
1702
2086
|
durationMs: b.duration_ms != null ? b.duration_ms : null,
|
|
1703
2087
|
};
|
|
1704
|
-
//
|
|
1705
|
-
//
|
|
2088
|
+
// Per-message cost markers; the session total is DERIVED from the
|
|
2089
|
+
// visible messages so edit/retry truncation self-corrects the spend.
|
|
1706
2090
|
const cost = typeof b.total_cost_usd === 'number' ? b.total_cost_usd : (typeof u.cost === 'number' ? u.cost : 0);
|
|
1707
|
-
if (cost)
|
|
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');
|
|
1708
2099
|
scheduleStreamRender();
|
|
1709
2100
|
}
|
|
1710
2101
|
else if (ev.type === 'error') { cur.error = errText(ev.error); cur.errorRaw = errTextRaw(ev.error); render(); }
|
|
@@ -1715,11 +2106,34 @@ async function sendChat() {
|
|
|
1715
2106
|
state.chat.busy = false;
|
|
1716
2107
|
state.chat.abort = null;
|
|
1717
2108
|
persistChat();
|
|
2109
|
+
refreshActive(); // settle the running panel/dashboard now, not at the next poll
|
|
1718
2110
|
render();
|
|
1719
2111
|
scrollChatToBottom();
|
|
1720
2112
|
}
|
|
1721
2113
|
}
|
|
1722
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
|
+
|
|
1723
2137
|
// --- history ---
|
|
1724
2138
|
function reconnectAlert() {
|
|
1725
2139
|
if (!state.live.error) return null;
|
|
@@ -1823,7 +2237,7 @@ function historyMain() {
|
|
|
1823
2237
|
h('span', { key: 'noevtxt' }, 'no events in this session - '),
|
|
1824
2238
|
Btn({ key: 'reload', onClick: () => loadSession(state.selectedSid), children: 'reload' }))
|
|
1825
2239
|
: 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
|
|
2240
|
+
state.eventsSlow ? 'Indexing your Claude history — the first load can take a minute…' : 'loading events…');
|
|
1827
2241
|
return [reconnectAlert(), head, actions, Panel({ title: 'events', children: body })].filter(Boolean);
|
|
1828
2242
|
}
|
|
1829
2243
|
|
|
@@ -1848,7 +2262,12 @@ function historyMain() {
|
|
|
1848
2262
|
sess && sess.cwd ? { label: 'cwd', value: sess.cwd, title: sess.cwd } : null,
|
|
1849
2263
|
sessionDuration() ? { label: 'duration', value: sessionDuration() } : null,
|
|
1850
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.
|
|
1851
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) },
|
|
1852
2271
|
].filter(Boolean),
|
|
1853
2272
|
});
|
|
1854
2273
|
if (filteredEvents.length === 0) {
|
|
@@ -1933,7 +2352,9 @@ function historyMain() {
|
|
|
1933
2352
|
// Guard ts: a missing/zero timestamp renders "Invalid Date" otherwise.
|
|
1934
2353
|
// Every row is click-to-expand, so always show the affordance word
|
|
1935
2354
|
// (not only when text overflows 220 chars).
|
|
1936
|
-
|
|
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'),
|
|
1937
2358
|
onClick: () => { expanded ? state.expandedEvents.delete(key) : state.expandedEvents.add(key); render(); },
|
|
1938
2359
|
};
|
|
1939
2360
|
}),
|
|
@@ -1945,7 +2366,14 @@ function historyMain() {
|
|
|
1945
2366
|
let copyToast = null;
|
|
1946
2367
|
// Hold the toast long enough to read (2.5s); the copy button label is inside a
|
|
1947
2368
|
// role=status region (history-actions) so AT announces the change.
|
|
1948
|
-
|
|
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
|
+
}
|
|
1949
2377
|
function copySid() {
|
|
1950
2378
|
const sid = state.selectedSid;
|
|
1951
2379
|
if (!sid) return;
|
|
@@ -1971,6 +2399,8 @@ function resumeInChat(sess, { fromHash = false } = {}) {
|
|
|
1971
2399
|
state.selectedSid = state.chat.resumeSid || state.selectedSid;
|
|
1972
2400
|
if (!fromHash) writeHash({ push: true });
|
|
1973
2401
|
state.chat.messages = [];
|
|
2402
|
+
state.chat.totalCost = 0;
|
|
2403
|
+
state.chat.shownMessages = null;
|
|
1974
2404
|
state.chat.draft = '';
|
|
1975
2405
|
// Only claude-code supports --resume by sid; warn if we have to switch the
|
|
1976
2406
|
// user's selected agent rather than silently discarding it.
|
|
@@ -2019,19 +2449,28 @@ function uniqueProjects() {
|
|
|
2019
2449
|
function runningPanel() {
|
|
2020
2450
|
const running = Array.isArray(state.active) ? state.active : [];
|
|
2021
2451
|
if (!running.length) return null;
|
|
2452
|
+
const stopping = state.live.stopping || new Set();
|
|
2022
2453
|
return Panel({
|
|
2023
2454
|
key: 'runningPanel',
|
|
2024
2455
|
title: 'running · ' + running.length,
|
|
2025
|
-
children:
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
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
|
+
],
|
|
2035
2474
|
});
|
|
2036
2475
|
}
|
|
2037
2476
|
|
|
@@ -2049,7 +2488,7 @@ function historySide() {
|
|
|
2049
2488
|
key: 'sr-' + (r.sid || '?') + '-' + i,
|
|
2050
2489
|
rank: String(i + 1).padStart(3, '0'),
|
|
2051
2490
|
title: r.snippet || '(no snippet)',
|
|
2052
|
-
sub: (r.project || '?') + ' · ' + (r.role || '?') + (r.tool ? ' · ' + r.tool : '') + (r.ts ? ' · ' + fmtRelTime(r.ts) : ''),
|
|
2491
|
+
sub: (projectLabel(r.project) || '?') + ' · ' + (r.role || '?') + (r.tool ? ' · ' + r.tool : '') + (r.ts ? ' · ' + fmtRelTime(r.ts) : ''),
|
|
2053
2492
|
// Rail carries the same semantics as session rows: error > subagent > normal.
|
|
2054
2493
|
rail: r.isError ? 'flame' : (r.isSubagent ? 'purple' : 'green'),
|
|
2055
2494
|
// Carry the matched event's index so loadSession scrolls to + flashes
|
|
@@ -2153,11 +2592,13 @@ function normalizeBackend(s) {
|
|
|
2153
2592
|
async function saveBackend() {
|
|
2154
2593
|
if (!isValidUrl(state.backendDraft) || state.backendDraft === state.backend) return;
|
|
2155
2594
|
// 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
|
-
|
|
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;
|
|
2159
2600
|
}
|
|
2160
|
-
state.confirmingBackend =
|
|
2601
|
+
state.confirmingBackend = undefined;
|
|
2161
2602
|
const canonical = normalizeBackend(state.backendDraft);
|
|
2162
2603
|
state.backendDraft = canonical;
|
|
2163
2604
|
B.setBackend(canonical);
|
|
@@ -2201,7 +2642,9 @@ function settingsMain() {
|
|
|
2201
2642
|
PageHeader({
|
|
2202
2643
|
compact: true,
|
|
2203
2644
|
title: 'Settings',
|
|
2204
|
-
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.',
|
|
2205
2648
|
}),
|
|
2206
2649
|
h('div', { key: 'settings-grid', class: 'settings-grid' }, [
|
|
2207
2650
|
Panel({
|
|
@@ -2211,6 +2654,7 @@ function settingsMain() {
|
|
|
2211
2654
|
key: 'backendForm',
|
|
2212
2655
|
onSubmit: (e) => { e.preventDefault(); saveBackend(); },
|
|
2213
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.'),
|
|
2214
2658
|
TextField({
|
|
2215
2659
|
key: 'backendField',
|
|
2216
2660
|
label: 'backend url',
|
|
@@ -2219,13 +2663,19 @@ function settingsMain() {
|
|
|
2219
2663
|
'aria-describedby': !isValid ? 'backend-url-error' : undefined,
|
|
2220
2664
|
'aria-invalid': !isValid ? 'true' : 'false',
|
|
2221
2665
|
title: isValid ? 'Enter a valid URL or leave blank for same-origin' : 'Invalid URL format',
|
|
2222
|
-
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
|
+
},
|
|
2223
2672
|
}),
|
|
2224
2673
|
!isValid ? h('p', { key: 'err', id: 'backend-url-error', class: 'lede field-error', role: 'alert' }, 'Invalid URL format') : null,
|
|
2225
2674
|
state.backendStatus === 'connecting' ? h('p', { key: 'bst-connecting', class: 'lede', role: 'status' }, 'connecting…') : null,
|
|
2226
2675
|
state.backendStatus === 'ok' ? h('p', { key: 'bst-ok', class: 'lede', role: 'status' }, 'connected') : null,
|
|
2227
2676
|
state.backendStatus === 'failed' ? h('p', { key: 'bst-failed', class: 'lede field-error', role: 'alert' }, 'connection failed - check the URL') : null,
|
|
2228
|
-
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,
|
|
2229
2679
|
healthSummary(),
|
|
2230
2680
|
Btn({
|
|
2231
2681
|
key: 'savebtn',
|
|
@@ -2238,6 +2688,10 @@ function settingsMain() {
|
|
|
2238
2688
|
}),
|
|
2239
2689
|
]),
|
|
2240
2690
|
}),
|
|
2691
|
+
// Panel order: connection group (backend + server) first, then agents,
|
|
2692
|
+
// then personal preferences (appearance / keyboard / data).
|
|
2693
|
+
serverPanel(),
|
|
2694
|
+
agentsPanel(),
|
|
2241
2695
|
Panel({
|
|
2242
2696
|
id: 'appearance',
|
|
2243
2697
|
title: 'appearance',
|
|
@@ -2246,8 +2700,6 @@ function settingsMain() {
|
|
|
2246
2700
|
ThemeToggle({ key: 'tt' }),
|
|
2247
2701
|
],
|
|
2248
2702
|
}),
|
|
2249
|
-
serverPanel(),
|
|
2250
|
-
agentsPanel(),
|
|
2251
2703
|
keyboardPanel(),
|
|
2252
2704
|
preferencesPanel(),
|
|
2253
2705
|
]),
|
|
@@ -2315,9 +2767,13 @@ function preferencesPanel() {
|
|
|
2315
2767
|
id: 'data',
|
|
2316
2768
|
title: 'data',
|
|
2317
2769
|
children: [
|
|
2318
|
-
|
|
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,
|
|
2319
2775
|
h('div', { key: 'lsize', class: 'lede', style: 'margin:.5em 0' },
|
|
2320
|
-
'local data: ' + (
|
|
2776
|
+
'local data: ' + fmtBytes(lsBytes) + ' across ' + lsKeys + ' key' + (lsKeys === 1 ? '' : 's')),
|
|
2321
2777
|
h('div', { key: 'expchatrow', style: 'margin:.5em 0' },
|
|
2322
2778
|
Btn({ key: 'expchat', disabled: !savedChat,
|
|
2323
2779
|
title: savedChat ? 'Download the saved chat transcript as JSON' : 'no saved chat',
|
|
@@ -2332,7 +2788,7 @@ function preferencesPanel() {
|
|
|
2332
2788
|
Btn({ key: 'cldyes', danger: true, onClick: clearLocalData, children: 'clear' }),
|
|
2333
2789
|
Btn({ key: 'cldno', onClick: () => { state.confirmingClearData = false; render(); }, children: 'cancel' })] })
|
|
2334
2790
|
: Btn({ key: 'cldbtn', onClick: clearLocalData, children: 'clear local data' }),
|
|
2335
|
-
],
|
|
2791
|
+
].filter(Boolean),
|
|
2336
2792
|
});
|
|
2337
2793
|
}
|
|
2338
2794
|
|
|
@@ -2399,6 +2855,20 @@ async function refreshHistory() {
|
|
|
2399
2855
|
// Index by sid so each live SSE event is an O(1) lookup, not an O(sessions)
|
|
2400
2856
|
// linear scan per event during a burst load.
|
|
2401
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
|
+
}
|
|
2402
2872
|
// If the selected session vanished from the list (deleted/aged out server-side),
|
|
2403
2873
|
// drop the selection so the main pane doesn't sit on stale events that can no
|
|
2404
2874
|
// longer be reloaded; fall back to the no-selection empty state.
|
|
@@ -2436,6 +2906,10 @@ async function runSearch() {
|
|
|
2436
2906
|
render();
|
|
2437
2907
|
try {
|
|
2438
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);
|
|
2439
2913
|
} catch (e) {
|
|
2440
2914
|
state.searchHits = { query: q, results: [], error: errText(e) };
|
|
2441
2915
|
} finally {
|
|
@@ -2477,6 +2951,10 @@ async function loadSession(sid, { focusEventI = null, focusEventTs = null, fromH
|
|
|
2477
2951
|
});
|
|
2478
2952
|
try {
|
|
2479
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);
|
|
2480
2958
|
clearTimeout(slowTimer);
|
|
2481
2959
|
state.eventsSlow = false;
|
|
2482
2960
|
state.eventsLoaded = true;
|
|
@@ -2717,14 +3195,49 @@ function focusSearch() {
|
|
|
2717
3195
|
const el = document.querySelector('#app input[type="search"]');
|
|
2718
3196
|
el?.focus();
|
|
2719
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
|
+
}
|
|
2720
3207
|
window.addEventListener('keydown', (e) => {
|
|
2721
3208
|
const t = e.target;
|
|
2722
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
|
+
}
|
|
2723
3219
|
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
|
2724
3220
|
if (typing) {
|
|
2725
3221
|
if (e.key === 'Escape') t.blur();
|
|
2726
3222
|
return;
|
|
2727
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
|
+
}
|
|
2728
3241
|
if (gPending) {
|
|
2729
3242
|
gPending = false;
|
|
2730
3243
|
if (e.key === 'c') { navTo('chat'); return; }
|
|
@@ -2737,13 +3250,24 @@ window.addEventListener('keydown', (e) => {
|
|
|
2737
3250
|
if (e.key === 'g') { gPending = true; setTimeout(() => { gPending = false; }, 1000); return; }
|
|
2738
3251
|
if (e.key === 'n' && state.tab === 'chat') { e.preventDefault(); newChat(); return; }
|
|
2739
3252
|
if (e.key === '/') {
|
|
2740
|
-
// /
|
|
2741
|
-
//
|
|
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.
|
|
2742
3256
|
if (state.tab === 'history') { e.preventDefault(); focusSearch(); announce('search focused'); }
|
|
2743
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'); }
|
|
2744
3259
|
return;
|
|
2745
3260
|
}
|
|
2746
3261
|
if (e.key === '?') { state.showShortcuts = !state.showShortcuts; render(); return; }
|
|
2747
3262
|
});
|
|
2748
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
|
+
|
|
2749
3273
|
init();
|