agentgui 1.0.985 → 1.0.986
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +4 -0
- package/lib/asset-server.js +1 -1
- package/lib/claude-runner-run.js +0 -1
- package/lib/http-handler.js +112 -27
- package/lib/plugins/acp-plugin.js +27 -6
- package/lib/plugins/files-plugin.js +43 -12
- package/lib/plugins/workflow-plugin.js +20 -2
- package/lib/ws-handlers-util.js +7 -0
- package/package.json +1 -1
- package/site/app/index.html +0 -1
- package/site/app/js/app.js +174 -147
- package/site/app/js/backend.js +52 -6
- package/site/app/vendor/anentrypoint-design/247420.css +19 -0
- package/site/app/vendor/anentrypoint-design/247420.js +14 -14
package/site/app/js/app.js
CHANGED
|
@@ -45,9 +45,13 @@ const state = {
|
|
|
45
45
|
files: { path: '', segments: [], entries: [], roots: [], loading: false, error: null, preview: null, sort: 'name', sortDir: 'asc', filter: '' },
|
|
46
46
|
};
|
|
47
47
|
|
|
48
|
+
// Two-step arm controls auto-reset after this delay so an accidental first click
|
|
49
|
+
// doesn't leave a "armed" button forever.
|
|
50
|
+
const ARM_RESET_MS = 4000;
|
|
51
|
+
|
|
48
52
|
// Full routable param set. Every view-defining piece of state round-trips
|
|
49
53
|
// through the hash so reload and Back/forward restore the exact view.
|
|
50
|
-
const HASH_KEYS = ['tab', 'sid', 'dir', 'file', 'q', 'project', 'section'];
|
|
54
|
+
const HASH_KEYS = ['tab', 'sid', 'dir', 'file', 'q', 'project', 'section', 'filter'];
|
|
51
55
|
function readHash() {
|
|
52
56
|
const hash = location.hash || '';
|
|
53
57
|
const out = {};
|
|
@@ -70,6 +74,7 @@ function buildHash() {
|
|
|
70
74
|
if (tab === 'files' && state.files) {
|
|
71
75
|
if (state.files.path) parts.push('dir=' + encodeURIComponent(state.files.path));
|
|
72
76
|
if (state.files.preview && state.files.preview.path) parts.push('file=' + encodeURIComponent(state.files.preview.path));
|
|
77
|
+
if (state.files.filter) parts.push('filter=' + encodeURIComponent(state.files.filter));
|
|
73
78
|
}
|
|
74
79
|
if (tab === 'history') {
|
|
75
80
|
const q = (state.searchQ || '').trim();
|
|
@@ -171,6 +176,9 @@ function announce(msg) {
|
|
|
171
176
|
requestAnimationFrame(() => { if (_announcer) _announcer.textContent = msg; });
|
|
172
177
|
}
|
|
173
178
|
|
|
179
|
+
// Extract the last path segment from a file-system path (cross-platform / or \).
|
|
180
|
+
function pathBasename(p) { return p ? p.split(/[/\\]/).filter(Boolean).slice(-1)[0] || '' : ''; }
|
|
181
|
+
|
|
174
182
|
function pillButton(key, label, active, title, onClick) {
|
|
175
183
|
return h('button', {
|
|
176
184
|
key,
|
|
@@ -244,7 +252,9 @@ function agentAvailable(id) { const a = agentById(id); return !a || a.available
|
|
|
244
252
|
// Protocol ids are plumbing vocabulary; rows speak product words.
|
|
245
253
|
const PROTOCOL_WORDS = { acp: 'managed server', cli: 'local CLI', direct: 'local CLI' };
|
|
246
254
|
const PRIMARY_AGENTS = ['claude-code', 'opencode', 'kilo', 'agy'];
|
|
247
|
-
|
|
255
|
+
// Memoized: recomputed only when state.agents changes (in loadAgents), not on
|
|
256
|
+
// every chatMain() render. Stored in state.sortedAgentsCache.
|
|
257
|
+
function computeSortedAgents() {
|
|
248
258
|
const rank = (a) => {
|
|
249
259
|
const primary = PRIMARY_AGENTS.indexOf(a.id);
|
|
250
260
|
const avail = a.available !== false;
|
|
@@ -258,6 +268,7 @@ function sortedAgents() {
|
|
|
258
268
|
.sort((x, y) => x.rank - y.rank || x.a.name.localeCompare(y.a.name))
|
|
259
269
|
.map(({ a }) => a);
|
|
260
270
|
}
|
|
271
|
+
function sortedAgents() { return state.sortedAgentsCache || (state.sortedAgentsCache = computeSortedAgents()); }
|
|
261
272
|
|
|
262
273
|
function navTo(tab, { writeHash: doWriteHash = true, push = true } = {}) {
|
|
263
274
|
const prev = state.tab;
|
|
@@ -338,6 +349,7 @@ async function refreshActive() {
|
|
|
338
349
|
try { next = await B.listActiveChats(state.backend); } catch { return; }
|
|
339
350
|
const changed = activeSig(next) !== activeSig(state.active);
|
|
340
351
|
state.active = next;
|
|
352
|
+
if (changed) state._sessionGroupsCache = null;
|
|
341
353
|
// A stopping sid that left the active set has genuinely stopped - clear it
|
|
342
354
|
// so the per-card 'stopping' state resolves.
|
|
343
355
|
const st = state.live.stopping;
|
|
@@ -424,7 +436,11 @@ function openLiveStream() {
|
|
|
424
436
|
// reconnect or overlap would otherwise double-append the same event.
|
|
425
437
|
if (!state.events._seen) state.events._seen = new Set();
|
|
426
438
|
if (ev.i == null || !state.events._seen.has(ev.i)) {
|
|
427
|
-
if (ev.i != null)
|
|
439
|
+
if (ev.i != null) {
|
|
440
|
+
state.events._seen.add(ev.i);
|
|
441
|
+
// Cap the seen-set so a very long session doesn't grow unbounded.
|
|
442
|
+
if (state.events._seen.size > 5000) state.events._seen = new Set([...state.events._seen].slice(-2500));
|
|
443
|
+
}
|
|
428
444
|
ev._idx = state.events.length;
|
|
429
445
|
state.events.push(ev);
|
|
430
446
|
// Cap retained events so a long live session can't grow unbounded.
|
|
@@ -449,6 +465,7 @@ function openLiveStream() {
|
|
|
449
465
|
// only the one resumed in the in-page chat.
|
|
450
466
|
if (ev.type === 'tool_use') { t.tools++; t.toolRunning = true; t.toolName = ev.tool || ev.name || ''; }
|
|
451
467
|
if (ev.type === 'tool_result') { t.toolRunning = false; t.toolName = ''; }
|
|
468
|
+
if (ev.type === 'result' && ev.usage) { t.tokens = (t.tokens || 0) + (ev.usage.input_tokens || 0) + (ev.usage.output_tokens || 0); }
|
|
452
469
|
if (ev.isError) { t.errors++; t.lastErrorTs = t.last; }
|
|
453
470
|
}
|
|
454
471
|
state.live.tally.set(data.sid, t);
|
|
@@ -576,8 +593,11 @@ function view() {
|
|
|
576
593
|
});
|
|
577
594
|
|
|
578
595
|
const shortcutsHint = state.showShortcuts
|
|
579
|
-
?
|
|
580
|
-
|
|
596
|
+
? h('div', { key: 'sc', role: 'dialog', 'aria-modal': 'true', 'aria-label': 'Keyboard shortcuts', class: 'ds-alert ds-alert--info shortcuts-dialog' },
|
|
597
|
+
h('div', { key: 'sc-head', class: 'ds-alert-head' },
|
|
598
|
+
h('span', { key: 'sc-title', class: 'ds-alert-title' }, 'Keyboard shortcuts'),
|
|
599
|
+
h('button', { key: 'sc-close', type: 'button', class: 'ds-btn', 'aria-label': 'Close keyboard shortcuts', ref: (el) => { if (el) requestAnimationFrame(() => el.focus()); }, onClick: () => { state.showShortcuts = false; render(); announce('shortcuts closed'); } }, 'close')),
|
|
600
|
+
h('div', { key: 'sc-body', class: 'ds-alert-body' }, ShortcutList({ shortcuts: SHORTCUTS })))
|
|
581
601
|
: null;
|
|
582
602
|
const main = h('div', { id: 'agentgui-main', role: 'region', 'aria-label': 'main content', 'data-chat-scroll': '', class: 'agentgui-main agentgui-main-' + state.tab }, [shortcutsHint, ...mainContent()].filter(Boolean));
|
|
583
603
|
|
|
@@ -606,7 +626,7 @@ function view() {
|
|
|
606
626
|
// One affordance per action: the cwd row opens the SAME inline editor as
|
|
607
627
|
// the composer context line (it validates via /api/stat) instead of
|
|
608
628
|
// navigating away to the Files tab.
|
|
609
|
-
onSetCwd: () => { state.cwdEditing = true; state.cwdDraft = state.chatCwd || ''; state.cwdError = null; render(); },
|
|
629
|
+
onSetCwd: () => { state.cwdEditing = true; state.cwdDraft = state.chatCwd || ''; state.cwdError = null; render(); requestAnimationFrame(() => { const inp = document.querySelector('.agentchat-cwd-input'); if (inp) inp.focus(); }); },
|
|
610
630
|
});
|
|
611
631
|
} else if (state.tab === 'files' && state.files && state.files.preview && !isNarrow()) {
|
|
612
632
|
pane = filePreviewPane();
|
|
@@ -619,8 +639,11 @@ function view() {
|
|
|
619
639
|
// The left workspace rail: brand, New chat action, and the primary view nav.
|
|
620
640
|
function workspaceRail() {
|
|
621
641
|
const liveCount = (Array.isArray(state.active) ? state.active.length : 0);
|
|
642
|
+
// Show a live pulse on the chat rail item when a stream is in progress but the
|
|
643
|
+
// user has navigated to a different tab - so the background stream stays visible.
|
|
644
|
+
const chatStreaming = state.chat.busy && state.tab !== 'chat';
|
|
622
645
|
const items = [
|
|
623
|
-
{ key: 'chat', label: 'Chat', icon: 'forum', active: state.tab === 'chat', onClick: () => navTo('chat') },
|
|
646
|
+
{ key: 'chat', label: 'Chat', icon: 'forum', active: state.tab === 'chat', count: chatStreaming ? 1 : null, onClick: () => navTo('chat') },
|
|
624
647
|
{ key: 'history', label: 'History', icon: 'thread', active: state.tab === 'history', onClick: () => navTo('history') },
|
|
625
648
|
{ key: 'files', label: 'Files', icon: 'folder', active: state.tab === 'files', onClick: () => navTo('files') },
|
|
626
649
|
{ key: 'live', label: 'Live', icon: 'activity', active: state.tab === 'live', count: liveCount || null, onClick: () => navTo('live') },
|
|
@@ -655,6 +678,9 @@ const DATE_GROUP_ORDER = ['Running', 'Today', 'Yesterday', 'This week', 'Earlier
|
|
|
655
678
|
// chats are pinned to a "Running" section at the top (a live workspace surfaces
|
|
656
679
|
// in-flight work first), the rest bucket by recency. Returns { items, groups }.
|
|
657
680
|
function sessionGroups(sessionsView) {
|
|
681
|
+
// Cache key: sessions + active combo (both affect group membership).
|
|
682
|
+
const key = sessionsView.map(s => s.sid).join(',') + '|' + (Array.isArray(state.active) ? state.active : []).map(a => a.claudeSessionId || a.sessionId).join(',');
|
|
683
|
+
if (state._sessionGroupsCache && state._sessionGroupsCacheKey === key) return state._sessionGroupsCache;
|
|
658
684
|
// Join on the REAL claude/ccsniff sid when known (chat.active rows carry the
|
|
659
685
|
// ephemeral chat- id; claudeSessionId lands once streaming_session arrives).
|
|
660
686
|
const runningSids = new Set((Array.isArray(state.active) ? state.active : []).map(a => a.claudeSessionId || a.sessionId));
|
|
@@ -667,6 +693,8 @@ function sessionGroups(sessionsView) {
|
|
|
667
693
|
const groups = DATE_GROUP_ORDER
|
|
668
694
|
.filter(l => buckets.has(l))
|
|
669
695
|
.map(l => ({ label: l, sids: buckets.get(l) }));
|
|
696
|
+
state._sessionGroupsCacheKey = key;
|
|
697
|
+
state._sessionGroupsCache = groups;
|
|
670
698
|
return groups;
|
|
671
699
|
}
|
|
672
700
|
|
|
@@ -704,7 +732,7 @@ function sessionsColumn() {
|
|
|
704
732
|
selected: state.selectedSid,
|
|
705
733
|
search: {
|
|
706
734
|
value: state.searchQ,
|
|
707
|
-
placeholder: 'Search conversations',
|
|
735
|
+
placeholder: 'Search conversations (2+ chars)',
|
|
708
736
|
onInput: (v) => { state.searchQ = v; if (v.trim().length >= 2) debouncedSearch(); else { state.searchHits = null; } render(); },
|
|
709
737
|
},
|
|
710
738
|
onNew: () => { navTo('chat'); newChat(); },
|
|
@@ -1100,12 +1128,30 @@ function fileDialog() {
|
|
|
1100
1128
|
}
|
|
1101
1129
|
if (d.kind === 'bulk-move') {
|
|
1102
1130
|
const n = filesMarked().size;
|
|
1131
|
+
// Debounced /api/stat validation on the destination input (mirrors cwd field).
|
|
1132
|
+
if (!d._validateDest) {
|
|
1133
|
+
d._validateDest = debounce(async (v) => {
|
|
1134
|
+
if (!v || v === state.files.path) return;
|
|
1135
|
+
try {
|
|
1136
|
+
const st = await B.statPath(state.backend, v);
|
|
1137
|
+
if (state.files.dialog !== d) return;
|
|
1138
|
+
d.error = (!st || st.ok === false) ? 'folder not found on the server'
|
|
1139
|
+
: (!st.dir ? 'that path is not a directory' : null);
|
|
1140
|
+
} catch (e) {
|
|
1141
|
+
if (state.files.dialog !== d) return;
|
|
1142
|
+
d.error = e.status === 403 ? 'outside the allowed roots'
|
|
1143
|
+
: (e.status === 404 ? 'folder not found on the server' : null);
|
|
1144
|
+
}
|
|
1145
|
+
render();
|
|
1146
|
+
}, 400);
|
|
1147
|
+
}
|
|
1103
1148
|
return PromptDialog({
|
|
1104
1149
|
title: 'Move ' + n + ' selected ' + (n === 1 ? 'entry' : 'entries'),
|
|
1105
1150
|
value: state.files.path || '', placeholder: 'destination folder path',
|
|
1106
1151
|
error: d.error || null, busy: !!d.busy,
|
|
1107
1152
|
confirmLabel: d.busy ? 'moving…' : 'move ' + n, cancelLabel: 'cancel',
|
|
1108
1153
|
onCancel: closeFileDialog,
|
|
1154
|
+
onInput: (v) => { d.error = null; d._validateDest(v); },
|
|
1109
1155
|
onConfirm: (v) => {
|
|
1110
1156
|
if (!v) { d.error = 'enter a destination folder'; render(); return; }
|
|
1111
1157
|
if (v === state.files.path) { d.error = 'already in that folder - enter a different destination'; render(); return; }
|
|
@@ -1272,9 +1318,10 @@ function filesMain() {
|
|
|
1272
1318
|
persistFilesPrefs();
|
|
1273
1319
|
render();
|
|
1274
1320
|
} },
|
|
1275
|
-
filter: { value: f.filter || '', placeholder: 'Filter files in this directory', onInput:
|
|
1321
|
+
filter: { value: f.filter || '', placeholder: 'Filter files in this directory', onInput: debouncedFilesFilter },
|
|
1276
1322
|
onUp: fileUp,
|
|
1277
1323
|
onOpen: (file) => {
|
|
1324
|
+
if (file.permissions === 'EACCES') { announce('no access to ' + file.name); return; }
|
|
1278
1325
|
if (file.type === 'dir') loadDir(file.path);
|
|
1279
1326
|
else openPreview(file);
|
|
1280
1327
|
},
|
|
@@ -1315,7 +1362,9 @@ function filesMain() {
|
|
|
1315
1362
|
f.path ? Btn({ key: 'upload', onClick: () => {
|
|
1316
1363
|
const inp = document.createElement('input');
|
|
1317
1364
|
inp.type = 'file'; inp.multiple = true;
|
|
1318
|
-
inp.
|
|
1365
|
+
inp.style.position = 'fixed'; inp.style.opacity = '0';
|
|
1366
|
+
document.body.appendChild(inp);
|
|
1367
|
+
inp.onchange = () => { uploadFiles(inp.files); inp.remove(); };
|
|
1319
1368
|
inp.click();
|
|
1320
1369
|
}, children: 'upload' }) : null,
|
|
1321
1370
|
targetCwd
|
|
@@ -1331,7 +1380,19 @@ function filesMain() {
|
|
|
1331
1380
|
label: 'drop files to upload to this folder',
|
|
1332
1381
|
onDragOver: () => { if (!state.files.dragover) { state.files.dragover = true; render(); } },
|
|
1333
1382
|
onDragLeave: () => { if (state.files.dragover) { state.files.dragover = false; render(); } },
|
|
1334
|
-
onDrop: (files) => {
|
|
1383
|
+
onDrop: (files, ev) => {
|
|
1384
|
+
state.files.dragover = false;
|
|
1385
|
+
// Detect directory drops: browsers report an empty FileList for dirs.
|
|
1386
|
+
// Check DataTransferItems when available; fall back to empty files list.
|
|
1387
|
+
const items = ev && ev.dataTransfer && ev.dataTransfer.items;
|
|
1388
|
+
if (items) {
|
|
1389
|
+
const hasDir = Array.from(items).some(it => { try { return it.webkitGetAsEntry && it.webkitGetAsEntry()?.isDirectory; } catch { return false; } });
|
|
1390
|
+
if (hasDir) { announce('Folders cannot be dropped — use the New Folder button to create directories'); return; }
|
|
1391
|
+
} else if (!files || !files.length) {
|
|
1392
|
+
announce('Folders cannot be dropped — use the New Folder button to create directories'); return;
|
|
1393
|
+
}
|
|
1394
|
+
uploadFiles(files);
|
|
1395
|
+
},
|
|
1335
1396
|
children: body,
|
|
1336
1397
|
})
|
|
1337
1398
|
: body;
|
|
@@ -1405,13 +1466,13 @@ let _stopSelArmTimer = null;
|
|
|
1405
1466
|
function armStopAll() {
|
|
1406
1467
|
state.live.confirmingStopAll = true;
|
|
1407
1468
|
clearTimeout(_stopAllArmTimer);
|
|
1408
|
-
_stopAllArmTimer = setTimeout(() => { state.live.confirmingStopAll = false; render(); },
|
|
1469
|
+
_stopAllArmTimer = setTimeout(() => { state.live.confirmingStopAll = false; render(); }, ARM_RESET_MS);
|
|
1409
1470
|
render();
|
|
1410
1471
|
}
|
|
1411
1472
|
function armStopSelected() {
|
|
1412
1473
|
state.live.confirmingStopSelected = true;
|
|
1413
1474
|
clearTimeout(_stopSelArmTimer);
|
|
1414
|
-
_stopSelArmTimer = setTimeout(() => { state.live.confirmingStopSelected = false; render(); },
|
|
1475
|
+
_stopSelArmTimer = setTimeout(() => { state.live.confirmingStopSelected = false; render(); }, ARM_RESET_MS);
|
|
1415
1476
|
render();
|
|
1416
1477
|
}
|
|
1417
1478
|
|
|
@@ -1506,7 +1567,7 @@ function liveMain() {
|
|
|
1506
1567
|
title: title || undefined,
|
|
1507
1568
|
agent: agentById(r.agentId)?.name || r.agentId || 'agent',
|
|
1508
1569
|
model: r.model || '',
|
|
1509
|
-
cwd: r.cwd
|
|
1570
|
+
cwd: pathBasename(r.cwd),
|
|
1510
1571
|
elapsed: elapsedMs ? fmtDuration(elapsedMs) : '',
|
|
1511
1572
|
elapsedMs,
|
|
1512
1573
|
startedTs,
|
|
@@ -1519,10 +1580,12 @@ function liveMain() {
|
|
|
1519
1580
|
stopping: stoppingSet.has(r.sessionId),
|
|
1520
1581
|
// Arrival cue for a freshly-started session (a brief enter animation).
|
|
1521
1582
|
isNew: startedTs ? (now - startedTs < 3000) : false,
|
|
1522
|
-
// Surface the in-page chat's own running cost on its card
|
|
1523
|
-
// session we hold
|
|
1583
|
+
// Surface the in-page chat's own running cost and token count on its card
|
|
1584
|
+
// (the only session we hold reliable per-session data for; others omit it).
|
|
1524
1585
|
cost: (state.chat.resumeSid && (r.claudeSessionId === state.chat.resumeSid || r.sessionId === state.chat.resumeSid))
|
|
1525
1586
|
? (state.chat.totalCost || null) : null,
|
|
1587
|
+
tokens: (state.chat.resumeSid && (r.claudeSessionId === state.chat.resumeSid || r.sessionId === state.chat.resumeSid) && state.chat.usage)
|
|
1588
|
+
? ((state.chat.usage.inputTokens || 0) + (state.chat.usage.outputTokens || 0)) : (t && t.tokens ? t.tokens : undefined),
|
|
1526
1589
|
};
|
|
1527
1590
|
});
|
|
1528
1591
|
// External sessions (a claude CLI in a terminal, etc.): live SSE motion that
|
|
@@ -1549,7 +1612,7 @@ function liveMain() {
|
|
|
1549
1612
|
title: sess ? (projectLabel(sess.title) || projectLabel(sess.project) || sid) : sid,
|
|
1550
1613
|
agent: 'external session',
|
|
1551
1614
|
model: '',
|
|
1552
|
-
cwd: sess && sess.cwd
|
|
1615
|
+
cwd: pathBasename(sess && sess.cwd),
|
|
1553
1616
|
elapsed: '',
|
|
1554
1617
|
elapsedMs: 0,
|
|
1555
1618
|
startedTs: 0,
|
|
@@ -1558,7 +1621,9 @@ function liveMain() {
|
|
|
1558
1621
|
lastTs: t.last,
|
|
1559
1622
|
errors,
|
|
1560
1623
|
currentTool: t.toolRunning ? (t.toolName || 'tool') : '',
|
|
1561
|
-
|
|
1624
|
+
// Apply stale detection to external sessions too: no recent activity + no running tool = stale.
|
|
1625
|
+
status: (lastErrorTs && (nowS - lastErrorTs) <= STALE_AFTER_MS) ? 'error'
|
|
1626
|
+
: (!t.toolRunning && t.last && (nowS - t.last) > STALE_AFTER_MS * 0.6 ? 'stale' : 'running'),
|
|
1562
1627
|
stopping: false,
|
|
1563
1628
|
});
|
|
1564
1629
|
}
|
|
@@ -1636,7 +1701,7 @@ function liveMain() {
|
|
|
1636
1701
|
state.live.selected = sel;
|
|
1637
1702
|
render();
|
|
1638
1703
|
},
|
|
1639
|
-
emptyText: 'No live sessions — agents you start (or run locally) appear here.',
|
|
1704
|
+
emptyText: (!sessions.length && (lv.filter || lv.errorsOnly)) ? 'No sessions match the current filter' : 'No live sessions — agents you start (or run locally) appear here.',
|
|
1640
1705
|
emptyAction: { label: 'start a chat', onClick: () => { navTo('chat'); } },
|
|
1641
1706
|
onStop: (s) => { if (!s.external) stopActiveChat(s.sid); },
|
|
1642
1707
|
onStopAll: async (all) => {
|
|
@@ -1759,8 +1824,7 @@ function chatMain() {
|
|
|
1759
1824
|
// silently loses prior context. Warn once the conversation is past its first
|
|
1760
1825
|
// turn so the user knows this agent is not carrying history forward.
|
|
1761
1826
|
{
|
|
1762
|
-
|
|
1763
|
-
if (state.selectedAgent && state.selectedAgent !== 'claude-code' && userTurns > 1 && !state.chat.resumeSid) {
|
|
1827
|
+
if (state.selectedAgent && state.selectedAgent !== 'claude-code' && userTurnCount > 1 && !state.chat.resumeSid) {
|
|
1764
1828
|
banners.push(Alert({ key: 'nocontinuity', kind: 'info', title: (agentById(state.selectedAgent)?.name || state.selectedAgent) + ' does not resume across turns',
|
|
1765
1829
|
children: 'This agent starts fresh each message - it will not remember earlier turns in this chat. Use Claude Code for a continuous conversation.' }));
|
|
1766
1830
|
}
|
|
@@ -1812,6 +1876,10 @@ function chatMain() {
|
|
|
1812
1876
|
h('span', { key: 'agtxt', title: state.agentsError }, 'The agent list failed to load. '),
|
|
1813
1877
|
Btn({ key: 'agretry', onClick: () => loadAgents(), children: 'retry' })] }));
|
|
1814
1878
|
}
|
|
1879
|
+
if (state.chat.loadingTranscript) {
|
|
1880
|
+
banners.push(Alert({ key: 'transcriptload', kind: 'info', title: 'Loading prior conversation...',
|
|
1881
|
+
children: [Spinner({ key: 'trspin', size: 'sm' })] }));
|
|
1882
|
+
}
|
|
1815
1883
|
if (state.chat.confirmingEdit) {
|
|
1816
1884
|
banners.push(Alert({ key: 'confedit', kind: 'warn', title: 'Edit this message?',
|
|
1817
1885
|
children: [
|
|
@@ -1878,9 +1946,9 @@ function chatMain() {
|
|
|
1878
1946
|
agentName,
|
|
1879
1947
|
state.selectedModel || null,
|
|
1880
1948
|
{
|
|
1881
|
-
label: state.chatCwd ? state.chatCwd
|
|
1949
|
+
label: state.chatCwd ? pathBasename(state.chatCwd) : 'server default',
|
|
1882
1950
|
title: 'change working directory',
|
|
1883
|
-
onClick: () => { state.cwdEditing = true; state.cwdDraft = state.chatCwd || ''; state.cwdError = null; render(); },
|
|
1951
|
+
onClick: () => { state.cwdEditing = true; state.cwdDraft = state.chatCwd || ''; state.cwdError = null; render(); requestAnimationFrame(() => { const inp = document.querySelector('.chat-cwd-input, .agentchat-cwd-input'); if (inp) inp.focus(); }); },
|
|
1884
1952
|
},
|
|
1885
1953
|
userTurnCount > 0 ? plural(userTurnCount, 'turn') : null,
|
|
1886
1954
|
(state.chat.resumeSid && state.selectedAgent === 'claude-code')
|
|
@@ -1940,7 +2008,7 @@ function chatMain() {
|
|
|
1940
2008
|
if (was !== now) render();
|
|
1941
2009
|
},
|
|
1942
2010
|
onSend: (v) => { state.chat.draft = v; sendChat(); },
|
|
1943
|
-
onCwdEdit: () => { state.cwdEditing = true; state.cwdDraft = state.chatCwd || ''; state.cwdError = null; render(); },
|
|
2011
|
+
onCwdEdit: () => { state.cwdEditing = true; state.cwdDraft = state.chatCwd || ''; state.cwdError = null; render(); requestAnimationFrame(() => { const inp = document.querySelector('.agentchat-cwd-input'); if (inp) inp.focus(); }); },
|
|
1944
2012
|
onCwdSave: async () => {
|
|
1945
2013
|
const path = (state.cwdDraft ?? '').trim();
|
|
1946
2014
|
// A relative cwd would resolve against the server process dir, not what
|
|
@@ -1970,7 +2038,7 @@ function chatMain() {
|
|
|
1970
2038
|
if (state.chatCwd) lsSet('agentgui.cwd', state.chatCwd); else lsRemove('agentgui.cwd');
|
|
1971
2039
|
state.cwdEditing = false; state.cwdDraft = undefined; render();
|
|
1972
2040
|
},
|
|
1973
|
-
onCwdCancel: () => { state.cwdEditing = false; state.cwdDraft = undefined; state.cwdError = null; state.cwdChecking = false; render(); },
|
|
2041
|
+
onCwdCancel: () => { state.cwdEditing = false; state.cwdDraft = undefined; state.cwdError = null; state.cwdChecking = false; render(); requestAnimationFrame(() => { const btn = document.querySelector('.agentchat-cwd-btn'); if (btn) btn.focus(); }); },
|
|
1974
2042
|
onCwdClear: () => { state.chatCwd = ''; lsRemove('agentgui.cwd'); render(); },
|
|
1975
2043
|
onCwdDraft: (v) => { state.cwdDraft = v; state.cwdError = null; debouncedCwdProbe(); },
|
|
1976
2044
|
}),
|
|
@@ -1997,7 +2065,7 @@ function newChat() {
|
|
|
1997
2065
|
if (state.confirmingNewChat) { render(); return; } // armed: repeat press is a no-op
|
|
1998
2066
|
state.confirmingNewChat = true;
|
|
1999
2067
|
clearTimeout(_newChatArmTimer);
|
|
2000
|
-
_newChatArmTimer = setTimeout(() => { state.confirmingNewChat = false; render(); },
|
|
2068
|
+
_newChatArmTimer = setTimeout(() => { state.confirmingNewChat = false; render(); }, ARM_RESET_MS);
|
|
2001
2069
|
render();
|
|
2002
2070
|
return;
|
|
2003
2071
|
}
|
|
@@ -2126,6 +2194,7 @@ function messageToText(m) {
|
|
|
2126
2194
|
return m.parts.map((p) => {
|
|
2127
2195
|
if (typeof p === 'string') return p;
|
|
2128
2196
|
if (p.kind === 'md' || p.kind === 'text') return p.text || '';
|
|
2197
|
+
if (p.kind === 'thinking') return '[thinking: ' + (p.text || '') + ']';
|
|
2129
2198
|
if (p.kind === 'tool') return '[tool: ' + (p.name || '') + (p.label ? ' ' + p.label : '') + ']';
|
|
2130
2199
|
return '';
|
|
2131
2200
|
}).filter(Boolean).join('\n');
|
|
@@ -2161,6 +2230,7 @@ function transcriptToMarkdown(messages) {
|
|
|
2161
2230
|
? parts.map((p) => {
|
|
2162
2231
|
if (typeof p === 'string') return p;
|
|
2163
2232
|
if (p.kind === 'md' || p.kind === 'text') return p.text || '';
|
|
2233
|
+
if (p.kind === 'thinking') return '> thinking: ' + (p.text || '');
|
|
2164
2234
|
if (p.kind === 'tool' || p.kind === 'tool_result') {
|
|
2165
2235
|
const bits = ['## tool: ' + (p.name || 'tool')];
|
|
2166
2236
|
if (p.args && Object.keys(p.args).length) bits.push('```json\n' + JSON.stringify(p.args, null, 2) + '\n```');
|
|
@@ -2180,6 +2250,8 @@ const FOLLOWUP_SEEDS = ['Explain that in more detail', 'Show me the diff', 'Run
|
|
|
2180
2250
|
function chatFollowups() {
|
|
2181
2251
|
const msgs = state.chat.messages || [];
|
|
2182
2252
|
if (!msgs.length || state.chat.busy) return [];
|
|
2253
|
+
// Memoize: recompute only when the messages array reference changes.
|
|
2254
|
+
if (state._followupsCache && state._followupsMsgs === msgs) return state._followupsCache;
|
|
2183
2255
|
const last = [...msgs].reverse().find(m => m.role === 'assistant');
|
|
2184
2256
|
if (!last) return [];
|
|
2185
2257
|
const out = [];
|
|
@@ -2192,7 +2264,10 @@ function chatFollowups() {
|
|
|
2192
2264
|
const base = fm[1].split(/[/\\]/).filter(Boolean).pop();
|
|
2193
2265
|
if (base) out.push('Open ' + base.replace(/[^\w.\-]/g, '') + ' in files');
|
|
2194
2266
|
}
|
|
2195
|
-
|
|
2267
|
+
const result = out.length ? out.slice(0, 3) : FOLLOWUP_SEEDS;
|
|
2268
|
+
state._followupsMsgs = msgs;
|
|
2269
|
+
state._followupsCache = result;
|
|
2270
|
+
return result;
|
|
2196
2271
|
}
|
|
2197
2272
|
// Retry the last assistant turn: drop it and re-send the preceding user message.
|
|
2198
2273
|
function retryLastTurn() {
|
|
@@ -2281,6 +2356,11 @@ async function sendChat(textArg) {
|
|
|
2281
2356
|
agentId: state.selectedAgent,
|
|
2282
2357
|
model: state.selectedModel || undefined,
|
|
2283
2358
|
cwd: state.chatCwd || undefined,
|
|
2359
|
+
// Non-resume agents receive a flattened transcript (tool parts become text summaries).
|
|
2360
|
+
// Claude-code uses --resume and ignores this array; it is only used by direct/ACP agents.
|
|
2361
|
+
// NOTE: only m.content (or its text equivalent) is sent here - tool_use/tool_result
|
|
2362
|
+
// parts are intentionally flattened to text so non-claude-code agents receive a
|
|
2363
|
+
// readable transcript rather than raw API objects they cannot process.
|
|
2284
2364
|
messages: state.chat.messages.slice(0, -1).map(m => ({ role: m.role, content: m.content || messageToText(m) })),
|
|
2285
2365
|
signal: ctrl.signal,
|
|
2286
2366
|
// Only claude-code consumes a resume sid; never forward a stale one to
|
|
@@ -2382,7 +2462,8 @@ function reconnectAlert() {
|
|
|
2382
2462
|
});
|
|
2383
2463
|
}
|
|
2384
2464
|
|
|
2385
|
-
//
|
|
2465
|
+
// Inline fallback for the fmtDuration kit export (line 11 references humanizeMs
|
|
2466
|
+
// as the local fallback when the kit does not export fmtDuration yet).
|
|
2386
2467
|
function humanizeMs(ms) {
|
|
2387
2468
|
if (ms == null || !isFinite(ms) || ms < 0) return '';
|
|
2388
2469
|
const s = Math.round(ms / 1000);
|
|
@@ -2395,14 +2476,16 @@ function humanizeMs(ms) {
|
|
|
2395
2476
|
function sessionDuration() {
|
|
2396
2477
|
const ts = (state.events || []).map(e => e.ts).filter(Boolean);
|
|
2397
2478
|
if (ts.length < 2) return '';
|
|
2398
|
-
return
|
|
2479
|
+
return fmtDuration(ts.reduce((a, b) => b > a ? b : a, ts[0]) - ts.reduce((a, b) => b < a ? b : a, ts[0]));
|
|
2399
2480
|
}
|
|
2400
2481
|
|
|
2401
|
-
// Event-type filter predicate (all | text | tool | errors).
|
|
2482
|
+
// Event-type filter predicate (all | text | tool | errors | thinking).
|
|
2402
2483
|
function eventMatchesFilter(e, f) {
|
|
2403
2484
|
if (f === 'tool') return e.type === 'tool_use' || e.type === 'tool_result';
|
|
2404
2485
|
if (f === 'errors') return !!e.isError;
|
|
2405
|
-
if (f === '
|
|
2486
|
+
if (f === 'thinking') return e.type === 'thinking';
|
|
2487
|
+
// text excludes thinking events so they don't appear under a non-dedicated filter.
|
|
2488
|
+
if (f === 'text') return !e.isError && e.type !== 'tool_use' && e.type !== 'tool_result' && e.type !== 'thinking';
|
|
2406
2489
|
return true;
|
|
2407
2490
|
}
|
|
2408
2491
|
|
|
@@ -2493,6 +2576,7 @@ function historyMain() {
|
|
|
2493
2576
|
{ id: 'text', label: 'text' },
|
|
2494
2577
|
{ id: 'tool', label: 'tools' },
|
|
2495
2578
|
{ id: 'errors', label: 'errors' },
|
|
2579
|
+
{ id: 'thinking', label: 'thinking' },
|
|
2496
2580
|
],
|
|
2497
2581
|
selected: ef,
|
|
2498
2582
|
onSelect: (id) => { state.eventFilter = id && id.id ? id.id : id; render(); },
|
|
@@ -2516,6 +2600,7 @@ function historyMain() {
|
|
|
2516
2600
|
{ label: 'turns', value: String(sess?.userTurns ?? evCounters.turns) },
|
|
2517
2601
|
{ label: 'tools', value: String(evCounters.tools) },
|
|
2518
2602
|
{ label: 'errors', value: String(evCounters.errors) },
|
|
2603
|
+
sess && sess.cost != null ? { label: 'cost', value: '$' + Number(sess.cost).toFixed(4) } : null,
|
|
2519
2604
|
].filter(Boolean),
|
|
2520
2605
|
});
|
|
2521
2606
|
if (filteredEvents.length === 0) {
|
|
@@ -2541,7 +2626,7 @@ function historyMain() {
|
|
|
2541
2626
|
render();
|
|
2542
2627
|
}, children: allExpanded ? 'collapse shown' : 'expand shown' }),
|
|
2543
2628
|
hiddenCount > 0
|
|
2544
|
-
? Btn({ key: 'older', onClick: () => { state.eventsLimit += 300; render(); }, children: 'load ' + Math.min(300, hiddenCount) + ' older (' + hiddenCount + ' hidden)' })
|
|
2629
|
+
? Btn({ key: 'older', onClick: () => { const added = Math.min(300, hiddenCount); state.eventsLimit += 300; announce('loaded ' + added + ' more events'); render(); }, children: 'load ' + Math.min(300, hiddenCount) + ' older (' + hiddenCount + ' hidden)' })
|
|
2545
2630
|
: null,
|
|
2546
2631
|
);
|
|
2547
2632
|
return [
|
|
@@ -2564,16 +2649,17 @@ function historyMain() {
|
|
|
2564
2649
|
const tool = e.tool ? ' · tool: ' + e.tool : '';
|
|
2565
2650
|
const errMark = e.isError ? ' · error' : '';
|
|
2566
2651
|
const raw = e.text || '';
|
|
2567
|
-
const text = raw.replace(/\s+/g, ' ').trim();
|
|
2568
|
-
const
|
|
2652
|
+
const text = raw.replace(/\s+/g, ' ').trim() || (e.type === 'tool_use' && e.toolInput ? toolLabel(e.toolInput) : '');
|
|
2653
|
+
const toolNamePrefix = (e.type === 'tool_use' && e.tool) ? e.tool + ': ' : '';
|
|
2654
|
+
const typePrefix = e.type === 'tool_result' ? '(result) ' : (e.type === 'tool_use' ? ('(tool call) ' + toolNamePrefix) : '');
|
|
2569
2655
|
const expanded = state.expandedEvents.has(key);
|
|
2570
2656
|
// Only build the expanded body (JSON.stringify tool input) when the row is
|
|
2571
2657
|
// expanded - doing it for all ~300 rows every frame wastes work mid-stream.
|
|
2572
2658
|
const full = expanded ? (e.toolInput ? (text + '\n\n' + JSON.stringify(e.toolInput, null, 2)) : raw) : '';
|
|
2573
2659
|
// Rail tone matches the session/agents rail semantics so an event's
|
|
2574
2660
|
// kind is visible at a glance, consistent across the GUI:
|
|
2575
|
-
// flame = error, purple =
|
|
2576
|
-
const rail = e.isError ? 'flame' : (e.type === 'tool_use' ? '
|
|
2661
|
+
// flame = error, purple = tool activity, green = normal turn.
|
|
2662
|
+
const rail = e.isError ? 'flame' : (e.type === 'tool_use' || e.type === 'tool_result' ? 'purple' : 'green');
|
|
2577
2663
|
// When the session was opened from a search hit, window the collapsed
|
|
2578
2664
|
// title AROUND the first query match (a match at char 5000 would
|
|
2579
2665
|
// otherwise be invisible behind the 0-220 slice).
|
|
@@ -2660,6 +2746,8 @@ function resumeInChat(sess, { fromHash = false } = {}) {
|
|
|
2660
2746
|
// Load prior turns from history so the user can re-read context inline.
|
|
2661
2747
|
const sidToLoad = state.chat.resumeSid;
|
|
2662
2748
|
if (sidToLoad) {
|
|
2749
|
+
state.chat.loadingTranscript = true;
|
|
2750
|
+
render();
|
|
2663
2751
|
B.getSessionEvents(state.backend, sidToLoad).then(evs => {
|
|
2664
2752
|
// Only populate if still on the same resume (user may have switched).
|
|
2665
2753
|
if (state.chat.resumeSid !== sidToLoad || state.chat.messages.length) return;
|
|
@@ -2667,17 +2755,20 @@ function resumeInChat(sess, { fromHash = false } = {}) {
|
|
|
2667
2755
|
for (const e of (evs || []).slice(-50)) {
|
|
2668
2756
|
if (e.type === 'human' || e.role === 'user') {
|
|
2669
2757
|
const text = e.text || e.content || '';
|
|
2670
|
-
if (text) msgs.push({ id: 'rh' + e.ts, role: 'user', content: text, time: e.ts });
|
|
2758
|
+
if (text) msgs.push({ id: 'rh' + e.ts, role: 'user', content: text, time: e.ts, historical: true });
|
|
2671
2759
|
} else if (e.type === 'assistant' || e.role === 'assistant') {
|
|
2672
2760
|
const text = e.text || '';
|
|
2673
|
-
if (text) msgs.push({ id: 'ra' + e.ts, role: 'assistant', content: '', time: e.ts, parts: [{ kind: 'md', text }] });
|
|
2761
|
+
if (text) msgs.push({ id: 'ra' + e.ts, role: 'assistant', content: '', time: e.ts, parts: [{ kind: 'md', text }], historical: true });
|
|
2674
2762
|
}
|
|
2675
2763
|
}
|
|
2676
|
-
if (
|
|
2764
|
+
if (state.chat.resumeSid === sidToLoad && !state.chat.messages.length) {
|
|
2677
2765
|
state.chat.messages = msgs;
|
|
2678
|
-
render();
|
|
2679
2766
|
}
|
|
2680
|
-
}).catch(() => {})
|
|
2767
|
+
}).catch(() => {}) // history may not be available; silent fail
|
|
2768
|
+
.finally(() => {
|
|
2769
|
+
if (state.chat.resumeSid === sidToLoad) state.chat.loadingTranscript = false;
|
|
2770
|
+
render();
|
|
2771
|
+
});
|
|
2681
2772
|
}
|
|
2682
2773
|
}
|
|
2683
2774
|
|
|
@@ -2734,7 +2825,7 @@ function runningPanel() {
|
|
|
2734
2825
|
// unkeyed one crashes webjsx applyDiff "reading 'key'").
|
|
2735
2826
|
return h('div', { key: 'run' + r.sessionId, class: 'resume-banner', role: 'group' },
|
|
2736
2827
|
h('span', { key: 'rd-' + r.sessionId, class: 'status-dot-disc ' + (isStopping ? 'status-dot-connecting' : 'status-dot-live'), 'aria-hidden': 'true' }),
|
|
2737
|
-
h('span', { key: 'rl-' + r.sessionId, class: 'lede' }, agentName + (r.model ? ' · ' + r.model : '') + (elapsedMs ? ' · ' + fmtDuration(elapsedMs) : '') + (r.cwd ? ' · ' + r.cwd
|
|
2828
|
+
h('span', { key: 'rl-' + r.sessionId, class: 'lede' }, agentName + (r.model ? ' · ' + r.model : '') + (elapsedMs ? ' · ' + fmtDuration(elapsedMs) : '') + (r.cwd ? ' · ' + pathBasename(r.cwd) : '')),
|
|
2738
2829
|
Btn({ key: 'open' + r.sessionId, onClick: () => navTo('live'), children: 'open in live' }),
|
|
2739
2830
|
Btn({ key: 'stop' + r.sessionId, disabled: isStopping, onClick: () => stopActiveChat(r.sessionId), children: isStopping ? 'stopping…' : 'stop' }));
|
|
2740
2831
|
}),
|
|
@@ -2742,107 +2833,6 @@ function runningPanel() {
|
|
|
2742
2833
|
});
|
|
2743
2834
|
}
|
|
2744
2835
|
|
|
2745
|
-
function historySide() {
|
|
2746
|
-
const searching = !!state.searchHits;
|
|
2747
|
-
const sessionsView = visibleSessions();
|
|
2748
|
-
const limit = state.sessionsLimit;
|
|
2749
|
-
const visible = searching ? state.searchHits.results.slice(0, 60) : sessionsView.slice(0, limit);
|
|
2750
|
-
const truncatedBy = searching ? Math.max(0, state.searchHits.results.length - 60) : Math.max(0, sessionsView.length - limit);
|
|
2751
|
-
const rows = searching
|
|
2752
|
-
? visible.map((r, i) =>
|
|
2753
|
-
Row({
|
|
2754
|
-
// sid can repeat across hits, so key on sid+position; rank is the
|
|
2755
|
-
// absolute result position (stable across the 60-row slice).
|
|
2756
|
-
key: 'sr-' + (r.sid || '?') + '-' + i,
|
|
2757
|
-
rank: String(i + 1).padStart(3, '0'),
|
|
2758
|
-
title: r.snippet || '(no snippet)',
|
|
2759
|
-
highlight: state.searchQ || undefined,
|
|
2760
|
-
sub: (projectLabel(r.project) || '?') + ' · ' + (r.role || '?') + (r.tool ? ' · ' + r.tool : '') + (r.ts ? ' · ' + fmtRelTime(r.ts) : ''),
|
|
2761
|
-
// Rail carries the same semantics as session rows: error > subagent > normal.
|
|
2762
|
-
rail: r.isError ? 'flame' : (r.isSubagent ? 'purple' : 'green'),
|
|
2763
|
-
// Carry the matched event's index so loadSession scrolls to + flashes
|
|
2764
|
-
// the match, instead of dropping the user at the top of the session.
|
|
2765
|
-
onClick: () => loadSession(r.sid, { focusEventI: r.i, focusEventTs: r.ts }),
|
|
2766
|
-
})
|
|
2767
|
-
)
|
|
2768
|
-
: visible.map((s, i) =>
|
|
2769
|
-
Row({
|
|
2770
|
-
key: 'sess' + s.sid,
|
|
2771
|
-
rank: String(i + 1).padStart(3, '0'),
|
|
2772
|
-
// Subagent is conveyed by the purple rail, not a "- " text prefix.
|
|
2773
|
-
title: projectLabel(s.title) || projectLabel(s.project) || s.sid,
|
|
2774
|
-
// Always show the error count so 0 reads as "no errors", not "untracked".
|
|
2775
|
-
sub: fmtRelTime(s.last) + ' · ' + (s.events || 0) + ' ev · ' + (s.tools || 0) + ' tools · ' + (s.errors || 0) + ' err',
|
|
2776
|
-
rail: sessionErrorDense(s) ? 'flame' : (s.isSubagent ? 'purple' : 'green'),
|
|
2777
|
-
active: s.sid === state.selectedSid,
|
|
2778
|
-
onClick: () => loadSession(s.sid),
|
|
2779
|
-
})
|
|
2780
|
-
);
|
|
2781
|
-
const subagentCount = (Array.isArray(state.sessions) ? state.sessions : []).filter(s => s.isSubagent).length;
|
|
2782
|
-
const projects = uniqueProjects();
|
|
2783
|
-
|
|
2784
|
-
return [
|
|
2785
|
-
runningPanel(),
|
|
2786
|
-
Panel({
|
|
2787
|
-
title: searching
|
|
2788
|
-
? 'matches · ' + (state.searchHits.results?.length || 0)
|
|
2789
|
-
: ('sessions · ' + sessionsView.length + (subagentCount && !state.showSubagents ? ' (+'+subagentCount+' sub)' : '')),
|
|
2790
|
-
children: [
|
|
2791
|
-
SearchInput({
|
|
2792
|
-
key: 'searchInput',
|
|
2793
|
-
// The DS SearchInput reads `label` (not aria-label) for the accessible
|
|
2794
|
-
// name, falling back to placeholder; pass label so AT announces it.
|
|
2795
|
-
placeholder: 'Search event text across sessions',
|
|
2796
|
-
label: 'Search event text across sessions',
|
|
2797
|
-
'aria-label': 'Search event text across sessions',
|
|
2798
|
-
value: state.searchQ,
|
|
2799
|
-
onInput: (v) => { state.searchQ = v; debouncedSearch(); },
|
|
2800
|
-
}),
|
|
2801
|
-
state.searchBusy
|
|
2802
|
-
? h('div', { key: 'searchbusy', class: 'lede empty-state empty-state--inline', role: 'status' }, Spinner({ key: 'ss', size: 'sm' }), 'searching…')
|
|
2803
|
-
: null,
|
|
2804
|
-
searching && state.searchHits.error
|
|
2805
|
-
? Alert({ key: 'searcherr', kind: 'error', title: 'Search failed', children: state.searchHits.error })
|
|
2806
|
-
: null,
|
|
2807
|
-
searching && !state.searchBusy && !state.searchHits.error && (state.searchHits.results || []).length === 0
|
|
2808
|
-
? h('p', { key: 'nomatch', class: 'lede empty-state empty-state--inline' }, 'no matches for "' + state.searchQ + '"')
|
|
2809
|
-
: null,
|
|
2810
|
-
state.searchQ.trim().length === 1
|
|
2811
|
-
? h('p', { key: 'min2', class: 'lede empty-state empty-state--inline' }, 'type at least 2 characters to search')
|
|
2812
|
-
: null,
|
|
2813
|
-
state.searchQ
|
|
2814
|
-
? Btn({ key: 'clearq', onClick: () => { state.searchQ = ''; state.searchHits = null; state.searchBusy = false; writeHash(); render(); }, children: 'clear search' })
|
|
2815
|
-
: null,
|
|
2816
|
-
!searching && projects.length > 1
|
|
2817
|
-
? h('div', { key: 'projfilter', class: 'pill-row', role: 'group', 'aria-label': 'Filter sessions by project' },
|
|
2818
|
-
pillButton('allp', 'all', !state.projectFilter, 'Show all projects', () => { state.projectFilter = ''; writeHash(); render(); }),
|
|
2819
|
-
...projects.slice(0, 8).map(([name, count]) =>
|
|
2820
|
-
pillButton('p'+name, truncate(projectLabel(name), 14, 20) + ' (' + count + ')', state.projectFilter === name, name, () => { state.projectFilter = state.projectFilter === name ? '' : name; writeHash(); render(); })))
|
|
2821
|
-
: null,
|
|
2822
|
-
!searching && subagentCount
|
|
2823
|
-
? h('div', { key: 'subtog', class: 'lede subagent-toggle' },
|
|
2824
|
-
Checkbox({
|
|
2825
|
-
checked: state.showSubagents,
|
|
2826
|
-
label: 'show subagents (' + (state.showSubagents ? subagentCount + ' shown' : subagentCount + ' hidden') + ')',
|
|
2827
|
-
onChange: (v) => { state.showSubagents = v; render(); },
|
|
2828
|
-
}))
|
|
2829
|
-
: null,
|
|
2830
|
-
state.historyError
|
|
2831
|
-
? h('p', { key: 'err', class: 'lede field-error', role: 'alert' }, state.historyError)
|
|
2832
|
-
: (rows.length ? h('div', { key: 'rows' }, ...rows) : h('p', { key: 'empty', class: 'lede empty-state' }, 'no sessions yet')),
|
|
2833
|
-
!searching && truncatedBy > 0
|
|
2834
|
-
? Btn({ key: 'more', onClick: () => { state.sessionsLimit += 60; render(); }, children: 'show '+Math.min(60, truncatedBy)+' more ('+truncatedBy+' hidden)' })
|
|
2835
|
-
: null,
|
|
2836
|
-
// Search is server-capped at 60 hits; there is no deeper page, so tell
|
|
2837
|
-
// the user the result set is truncated rather than silently hiding it.
|
|
2838
|
-
searching && truncatedBy > 0
|
|
2839
|
-
? h('p', { key: 'searchmore', class: 'lede empty-state' }, 'showing first 60 matches (' + truncatedBy + ' more - refine your search)')
|
|
2840
|
-
: null,
|
|
2841
|
-
],
|
|
2842
|
-
}),
|
|
2843
|
-
].filter(Boolean);
|
|
2844
|
-
}
|
|
2845
|
-
|
|
2846
2836
|
// --- settings ---
|
|
2847
2837
|
function isValidUrl(s) {
|
|
2848
2838
|
if (!s) return true; // blank = same-origin is valid
|
|
@@ -2865,6 +2855,13 @@ function normalizeBackend(s) {
|
|
|
2865
2855
|
|
|
2866
2856
|
async function saveBackend() {
|
|
2867
2857
|
if (!isValidUrl(state.backendDraft)) return;
|
|
2858
|
+
// Block a mid-stream backend switch: the in-flight fetch is bound to the old
|
|
2859
|
+
// origin and will error or produce split state if the backend changes under it.
|
|
2860
|
+
if (state.chat.busy) {
|
|
2861
|
+
state.backendError = 'A chat is in progress - stop it before switching backends.';
|
|
2862
|
+
render();
|
|
2863
|
+
return;
|
|
2864
|
+
}
|
|
2868
2865
|
// Re-submitting the current URL (e.g. after a failed health check) re-runs
|
|
2869
2866
|
// the health probe and shows connecting… so the user gets visible feedback.
|
|
2870
2867
|
if (state.backendDraft === state.backend) { state.backendStatus = 'connecting'; render(); await recheckHealth(); return; }
|
|
@@ -2971,6 +2968,9 @@ function settingsMain() {
|
|
|
2971
2968
|
state.backendDraft = v;
|
|
2972
2969
|
// The armed confirmation refers to a different value now - disarm.
|
|
2973
2970
|
if (state.confirmingBackend !== undefined && state.confirmingBackend !== v) state.confirmingBackend = undefined;
|
|
2971
|
+
// Clear stale probe results so the old error doesn't persist while typing.
|
|
2972
|
+
state.backendError = undefined;
|
|
2973
|
+
if (state.backendStatus === 'failed') state.backendStatus = undefined;
|
|
2974
2974
|
render();
|
|
2975
2975
|
},
|
|
2976
2976
|
}),
|
|
@@ -3021,7 +3021,7 @@ function serverPanel() {
|
|
|
3021
3021
|
title: 'server',
|
|
3022
3022
|
children: [
|
|
3023
3023
|
h('div', { key: 'sv', class: 'lede' }, 'version: ' + (hh.version ? 'v' + hh.version : 'unknown')),
|
|
3024
|
-
h('div', { key: 'sup', class: 'lede' }, 'uptime: ' + (upMs != null ?
|
|
3024
|
+
h('div', { key: 'sup', class: 'lede' }, 'uptime: ' + (upMs != null ? fmtDuration(upMs) : 'unknown')),
|
|
3025
3025
|
h('div', { key: 'swc', class: 'lede' }, 'connected clients: ' + (wsClients != null ? wsClients : 'unknown')),
|
|
3026
3026
|
h('div', { key: 'spd', class: 'lede' }, 'projects folder: ' + (hh.projectsDir || 'unknown')),
|
|
3027
3027
|
roots.length
|
|
@@ -3113,6 +3113,7 @@ function agentsPanel() {
|
|
|
3113
3113
|
// manual start/stop controls by design. This note makes that explicit so
|
|
3114
3114
|
// a 'stopped' row doesn't read as a missing action.
|
|
3115
3115
|
hasAcp ? h('p', { key: 'acpnote', class: 'lede agentgui-field-mb' }, 'ACP agents start on demand and restart automatically; selecting one launches it.') : null,
|
|
3116
|
+
h('div', { key: 'agrefreshrow', class: 'agentgui-field-mb' }, Btn({ key: 'agrefresh', onClick: () => loadAgents(), children: 'refresh' })),
|
|
3116
3117
|
...(state.agents.length
|
|
3117
3118
|
? state.agents.map((a, i) => {
|
|
3118
3119
|
const acp = acpStatusFor(a.id);
|
|
@@ -3140,6 +3141,9 @@ function agentsPanel() {
|
|
|
3140
3141
|
// click, no button role) instead of looking clickable but doing nothing.
|
|
3141
3142
|
state: usable ? 'default' : 'disabled',
|
|
3142
3143
|
onClick: usable ? () => { navTo('chat'); selectAgent(a.id); } : undefined,
|
|
3144
|
+
right: (acp && !acp.healthy)
|
|
3145
|
+
? [Btn({ key: 'acprestart', onClick: (e) => { e.stopPropagation(); B.restartAcpAgent(state.backend, a.id).then(() => loadAgents()); }, children: 'restart' })]
|
|
3146
|
+
: undefined,
|
|
3143
3147
|
});
|
|
3144
3148
|
})
|
|
3145
3149
|
// The empty array means one of three things; never let an in-flight load
|
|
@@ -3158,6 +3162,11 @@ function agentsPanel() {
|
|
|
3158
3162
|
|
|
3159
3163
|
// --- data ---
|
|
3160
3164
|
async function refreshHistory() {
|
|
3165
|
+
// Guard against concurrent calls: a slow first fetch followed by a polling
|
|
3166
|
+
// trigger would otherwise stack two in-flight requests; the second would
|
|
3167
|
+
// overwrite state mid-render with a stale response.
|
|
3168
|
+
if (state._historyFetching) return;
|
|
3169
|
+
state._historyFetching = true;
|
|
3161
3170
|
// Warmup copy: the FIRST sessions fetch can sit behind ccsniff's 30-90s
|
|
3162
3171
|
// JSONL walk; after 5s swap the loading copy to indexing language.
|
|
3163
3172
|
const firstLoad = !state._historyLoadedOnce;
|
|
@@ -3171,6 +3180,7 @@ async function refreshHistory() {
|
|
|
3171
3180
|
// Index by sid so each live SSE event is an O(1) lookup, not an O(sessions)
|
|
3172
3181
|
// linear scan per event during a burst load.
|
|
3173
3182
|
state.sessionsBySid = new Map((state.sessions || []).map(s => [s.sid, s]));
|
|
3183
|
+
state._sessionGroupsCache = null;
|
|
3174
3184
|
// Bound the live tally: drop entries with no activity in 24h and cap the
|
|
3175
3185
|
// Map at ~200 most-recent sids (a long-lived tab otherwise accumulates
|
|
3176
3186
|
// every sid ever seen, and dead entries could resurrect wrong externals).
|
|
@@ -3201,11 +3211,16 @@ async function refreshHistory() {
|
|
|
3201
3211
|
// render-stack string and never clear), so render() lives outside this try.
|
|
3202
3212
|
state.historyError = errText(e);
|
|
3203
3213
|
console.warn('history fetch failed:', e.message);
|
|
3214
|
+
} finally {
|
|
3215
|
+
state._historyFetching = false;
|
|
3216
|
+
if (slowTimer) clearTimeout(slowTimer);
|
|
3217
|
+
render();
|
|
3204
3218
|
}
|
|
3205
|
-
if (slowTimer) clearTimeout(slowTimer);
|
|
3206
|
-
render();
|
|
3207
3219
|
}
|
|
3208
3220
|
const debouncedRefreshHistory = debounce(refreshHistory, 500);
|
|
3221
|
+
// Debounced files filter: toLowerCase() on every entry runs on every keystroke;
|
|
3222
|
+
// 150ms coalesces rapid typing into one filter pass (perf-003).
|
|
3223
|
+
const debouncedFilesFilter = debounce((v) => { state.files.filter = v; state.files.shown = null; if (state.tab === 'files') writeHash(); render(); }, 150);
|
|
3209
3224
|
|
|
3210
3225
|
async function runSearch() {
|
|
3211
3226
|
const q = state.searchQ.trim();
|
|
@@ -3322,6 +3337,7 @@ async function loadAgents() {
|
|
|
3322
3337
|
state.agentsLoading = true;
|
|
3323
3338
|
try {
|
|
3324
3339
|
state.agents = await B.listAgents(state.backend);
|
|
3340
|
+
state.sortedAgentsCache = null; // invalidate memoized sort when agents list changes
|
|
3325
3341
|
state.agentsLoading = false;
|
|
3326
3342
|
// Agent selection priority: the agent a restored transcript belongs to (so
|
|
3327
3343
|
// the chat isn't shown under the wrong agent), else the saved picker agent,
|
|
@@ -3405,7 +3421,10 @@ async function init() {
|
|
|
3405
3421
|
// navTo - loadDir sets loading=true synchronously, so navTo's default
|
|
3406
3422
|
// loadDir('') guard skips and the two never race. Once the listing
|
|
3407
3423
|
// resolves, restore the file= preview on top of it.
|
|
3408
|
-
if (bootTab === 'files' && hp.dir)
|
|
3424
|
+
if (bootTab === 'files' && hp.dir) {
|
|
3425
|
+
if (hp.filter) state.files.filter = hp.filter;
|
|
3426
|
+
loadDir(hp.dir, { fromHash: true }).then(() => restoreFileFromHash(hp.file));
|
|
3427
|
+
}
|
|
3409
3428
|
if (bootTab === 'history' && hp.q) state.searchQ = hp.q;
|
|
3410
3429
|
if (bootTab === 'history' && hp.project) state.projectFilter = hp.project;
|
|
3411
3430
|
navTo(bootTab, { push: false });
|
|
@@ -3485,6 +3504,7 @@ window.addEventListener('popstate', () => {
|
|
|
3485
3504
|
}
|
|
3486
3505
|
if (tab === 'files') {
|
|
3487
3506
|
const cur = state.files && state.files.path;
|
|
3507
|
+
if (hp.filter !== undefined && hp.filter !== null) state.files.filter = hp.filter || '';
|
|
3488
3508
|
if (hp.dir && hp.dir !== cur) {
|
|
3489
3509
|
// Restore the file= preview only once the directory listing resolves
|
|
3490
3510
|
// (the entry object lives in state.files.entries).
|
|
@@ -3586,6 +3606,13 @@ window.addEventListener('keydown', (e) => {
|
|
|
3586
3606
|
return;
|
|
3587
3607
|
}
|
|
3588
3608
|
if (e.key === '?') { state.showShortcuts = !state.showShortcuts; render(); return; }
|
|
3609
|
+
// Left/Right: step through file previews (documented in SHORTCUTS).
|
|
3610
|
+
if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && state.tab === 'files' && state.files.preview) {
|
|
3611
|
+
const { prev, next } = previewNeighbours();
|
|
3612
|
+
const target = e.key === 'ArrowLeft' ? prev : next;
|
|
3613
|
+
if (target) { e.preventDefault(); openPreview(target); }
|
|
3614
|
+
return;
|
|
3615
|
+
}
|
|
3589
3616
|
});
|
|
3590
3617
|
|
|
3591
3618
|
// A file dropped anywhere outside a DropZone must never navigate the browser
|