agentgui 1.0.1060 → 1.0.1062
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/.gm/prd.yml +51 -0
- package/lib/http-handler.js +27 -13
- package/package.json +1 -1
- package/site/app/js/app.js +265 -32
- package/site/app/js/backend.js +21 -7
- package/site/app/vendor/anentrypoint-design/247420.css +74 -10
- package/site/app/vendor/anentrypoint-design/247420.js +15 -15
package/.gm/prd.yml
CHANGED
|
@@ -3076,3 +3076,54 @@
|
|
|
3076
3076
|
- gm-plugkit/playwriter browser verb dom=/capture eval context defect - requires an upstream tool fix outside agentgui/design
|
|
3077
3077
|
status: completed
|
|
3078
3078
|
witness: 'External tool defect, not resolvable from this repo/session: the gm-plugkit/playwriter browser verb''s dom= and capture prefixes evaluate in a Node vm context with no page/document binding (ReferenceError on every attempt this session), unlike the screenshot= and url=<target> paths which do reach the real page. This is a defect in the upstream gm-plugkit/playwriter tool package itself, outside agentgui and design repo scope. Recorded per the false-completion rule as blockedBy:external rather than marked done - screenshots remain the working witness fallback and were used for all browser verification in this sweep.'
|
|
3079
|
+
- id: cwd-validation-realtime-existing-probe-review
|
|
3080
|
+
subject: Audit the existing debouncedCwdProbe/onCwdDraft validation - confirm it still races correctly with the new browse/recent/autocomplete affordances (no double state writes, no stale error surviving a valid pick)
|
|
3081
|
+
status: pending
|
|
3082
|
+
- id: practicality-sweep-composer-ergonomics
|
|
3083
|
+
subject: 'Beyond cwd: sweep the chat composer and immediate surrounding controls for other practicality gaps (agent/model switching friction, draft persistence edge cases, file attach friction) via a workflow dispatch'
|
|
3084
|
+
status: pending
|
|
3085
|
+
- id: practicality-sweep-files-surface
|
|
3086
|
+
subject: Sweep the Files tab for practicality gaps beyond the cwd-picker integration (navigation friction, bulk-op discoverability, missing shortcuts)
|
|
3087
|
+
status: pending
|
|
3088
|
+
- id: practicality-sweep-history-settings
|
|
3089
|
+
subject: Sweep History and Settings surfaces for practicality gaps (search ergonomics, settings organization, discoverability of power-user features)
|
|
3090
|
+
status: pending
|
|
3091
|
+
- id: practicality-sweep-live-dashboard
|
|
3092
|
+
subject: Sweep the Live dashboard for remaining practicality gaps beyond the 40th/41st run fixes
|
|
3093
|
+
status: pending
|
|
3094
|
+
- id: practicality-verify-buildesk-live
|
|
3095
|
+
subject: Live-verify all cwd-practicality and general-practicality findings on https://buildesk.acc.l-inc.co.za/gm via browser witness
|
|
3096
|
+
status: pending
|
|
3097
|
+
- id: practicality-kit-build-vendor
|
|
3098
|
+
subject: Build+test the design kit, re-vendor dist into site/app/vendor/anentrypoint-design
|
|
3099
|
+
status: pending
|
|
3100
|
+
- id: practicality-push-ci-green
|
|
3101
|
+
subject: Push kit and agentgui changes, verify CI green on both repos
|
|
3102
|
+
status: pending
|
|
3103
|
+
- id: practicality-agents-md-record
|
|
3104
|
+
subject: Record this run's findings in AGENTS.md following the existing run-log convention
|
|
3105
|
+
status: pending
|
|
3106
|
+
- id: docstudio-cue-session-group-eyebrow
|
|
3107
|
+
subject: Verify ConversationList's session-group headers (Today/Yesterday/etc) use the --tr-label token + --fg-3 tone consistently, matching docstudio's uppercase letter-spaced low-opacity eyebrow label idiom
|
|
3108
|
+
status: pending
|
|
3109
|
+
- id: gc-delete-undo-trash
|
|
3110
|
+
subject: No undo-after-delete (soft-delete/trash) anywhere in the confined delete surface
|
|
3111
|
+
status: pending
|
|
3112
|
+
- id: gc-search-hit-hash-anchor
|
|
3113
|
+
subject: Search-hit event anchor (focusEventI/focusEventTs) not carried in the hash - reload/Back loses highlighted line
|
|
3114
|
+
status: pending
|
|
3115
|
+
- id: gc-settings-section-scrollspy
|
|
3116
|
+
subject: Settings section anchor is deep-link-in only - scrolling never updates state.settingsSection or the URL
|
|
3117
|
+
status: pending
|
|
3118
|
+
- id: gc-context-budget-affordance
|
|
3119
|
+
subject: No context-size/token-budget affordance - only turn count and dollar cost shown, never remaining context headroom
|
|
3120
|
+
status: pending
|
|
3121
|
+
- id: gc-expanded-body-highlight
|
|
3122
|
+
subject: Search-result highlighting inside expanded event bodies never highlights the matched query term
|
|
3123
|
+
status: pending
|
|
3124
|
+
- id: gc-search-results-export
|
|
3125
|
+
subject: No per-session or global export of History search results themselves
|
|
3126
|
+
status: pending
|
|
3127
|
+
- id: gc-settings-storage-estimate
|
|
3128
|
+
subject: Settings has no cache/data breakdown beyond agentgui's own localStorage keys - no navigator.storage.estimate()
|
|
3129
|
+
status: pending
|
package/lib/http-handler.js
CHANGED
|
@@ -84,6 +84,25 @@ function sanitizeEntryName(name) {
|
|
|
84
84
|
return n;
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
// Every confined filesystem route accepts its target path via a `?path=`/
|
|
88
|
+
// `?dir=` query param, preferred over a path SEGMENT. A reverse proxy's
|
|
89
|
+
// proxy_pass URI normalization can decode-then-reencode the request path,
|
|
90
|
+
// collapsing an encoded %2F segment slash back into a literal '/' before
|
|
91
|
+
// forwarding - the app then sees extra path segments instead of one opaque
|
|
92
|
+
// one, strips what it thinks is a leading '/', and silently turns an absolute
|
|
93
|
+
// path into a relative one that fails confinement even for genuinely
|
|
94
|
+
// accessible directories/files. A query param is untouched by that
|
|
95
|
+
// normalization on any proxy in front of this app. `legacyPrefix` is the old
|
|
96
|
+
// `/api/xxx/<path>` route prefix, still accepted for any caller not yet
|
|
97
|
+
// updated to the query-param form.
|
|
98
|
+
function resolveConfinedPath(req, routePath, queryKey, legacyPrefix) {
|
|
99
|
+
const url = new URL(req.url, 'http://x');
|
|
100
|
+
const qVal = url.searchParams.get(queryKey);
|
|
101
|
+
if (qVal != null) return qVal;
|
|
102
|
+
const raw = routePath.split('?')[0].slice(legacyPrefix.length).replace(/^\//, '');
|
|
103
|
+
return raw ? decodeURIComponent(raw) : '';
|
|
104
|
+
}
|
|
105
|
+
|
|
87
106
|
// Map a Node.js filesystem error code to a safe human-readable string that
|
|
88
107
|
// does not disclose host paths or internal stack context. Used everywhere an
|
|
89
108
|
// err.message would otherwise be returned to the client.
|
|
@@ -382,8 +401,7 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
|
|
|
382
401
|
// to the same allowlist as the Files surface (an unconfined stat would be
|
|
383
402
|
// a filesystem oracle). Returns {ok, dir} or a 403/404 with plain copy.
|
|
384
403
|
if (routePath.startsWith('/api/stat') && req.method === 'GET') {
|
|
385
|
-
const
|
|
386
|
-
const decodedPath = rawS ? decodeURIComponent(rawS) : '';
|
|
404
|
+
const decodedPath = resolveConfinedPath(req, routePath, 'path', '/api/stat');
|
|
387
405
|
const conf = confineToRoots(decodedPath, fsAllowRoots());
|
|
388
406
|
if (!conf.ok) { sendJSON(req, res, conf.reason === 'not found' ? 404 : 403, { error: conf.reason }); return; }
|
|
389
407
|
try {
|
|
@@ -441,8 +459,7 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
|
|
|
441
459
|
// FileGrid + BreadcrumbPath render directly. Allowed roots default to the
|
|
442
460
|
// server cwd + Claude projects dir; widen via FS_ROOTS (path-separated).
|
|
443
461
|
if (routePath.startsWith('/api/list')) {
|
|
444
|
-
const
|
|
445
|
-
const decodedPath = rawQ ? decodeURIComponent(rawQ) : '';
|
|
462
|
+
const decodedPath = resolveConfinedPath(req, routePath, 'dir', '/api/list');
|
|
446
463
|
const allowRoots = fsAllowRoots();
|
|
447
464
|
// Empty path = the first allow-root (a sane default landing dir).
|
|
448
465
|
const reqPath = !decodedPath ? allowRoots[0] : decodedPath;
|
|
@@ -509,9 +526,8 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
|
|
|
509
526
|
// and limited to reasonable text/code/image types so this is never a
|
|
510
527
|
// generic arbitrary-file reader. Images are served via /api/image; this
|
|
511
528
|
// returns text/* with utf-8.
|
|
512
|
-
if (routePath.startsWith('/api/file/')) {
|
|
513
|
-
const
|
|
514
|
-
const decodedPath = decodeURIComponent(rawF);
|
|
529
|
+
if (routePath.startsWith('/api/file/') || routePath.startsWith('/api/file?')) {
|
|
530
|
+
const decodedPath = resolveConfinedPath(req, routePath, 'path', '/api/file/');
|
|
515
531
|
const allowRoots = fsAllowRoots();
|
|
516
532
|
const conf = confineToRoots(decodedPath, allowRoots);
|
|
517
533
|
if (!conf.ok) { res.writeHead(conf.reason === 'not found' ? 404 : 403); res.end('Forbidden'); return; }
|
|
@@ -556,9 +572,8 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
|
|
|
556
572
|
// Confined raw-bytes download (any type) with an attachment disposition,
|
|
557
573
|
// so the Files view can offer download on a row. Same allowlist + realpath
|
|
558
574
|
// confinement as /api/file and /api/image - never a generic file reader.
|
|
559
|
-
if (routePath.startsWith('/api/download/')) {
|
|
560
|
-
const
|
|
561
|
-
const decodedPath = decodeURIComponent(rawD);
|
|
575
|
+
if (routePath.startsWith('/api/download/') || routePath.startsWith('/api/download?')) {
|
|
576
|
+
const decodedPath = resolveConfinedPath(req, routePath, 'path', '/api/download/');
|
|
562
577
|
const allowRoots = fsAllowRoots();
|
|
563
578
|
const conf = confineToRoots(decodedPath, allowRoots);
|
|
564
579
|
if (!conf.ok) { res.writeHead(conf.reason === 'not found' ? 404 : 403); res.end('Forbidden'); return; }
|
|
@@ -766,9 +781,8 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
|
|
|
766
781
|
return;
|
|
767
782
|
}
|
|
768
783
|
|
|
769
|
-
if (routePath.startsWith('/api/image/')) {
|
|
770
|
-
const
|
|
771
|
-
const decodedPath = decodeURIComponent(imagePath);
|
|
784
|
+
if (routePath.startsWith('/api/image/') || routePath.startsWith('/api/image?')) {
|
|
785
|
+
const decodedPath = resolveConfinedPath(req, routePath, 'path', '/api/image/');
|
|
772
786
|
// Confine reads to an allowlist root. Without this the route is an
|
|
773
787
|
// arbitrary-file-read of any image-extensioned path on the host (the
|
|
774
788
|
// prior `includes('..')` guard is a no-op after path.normalize resolves
|
package/package.json
CHANGED
package/site/app/js/app.js
CHANGED
|
@@ -1012,6 +1012,15 @@ function fileMutationCopy(e) {
|
|
|
1012
1012
|
if (e.status === 413) return 'Too large (50MB upload cap).';
|
|
1013
1013
|
return e.message || 'The operation failed.';
|
|
1014
1014
|
}
|
|
1015
|
+
// On a 409 name-collision, suggest 'name (2)' / 'name (3)' etc instead of
|
|
1016
|
+
// leaving the user to retype the identical blocked string from scratch -
|
|
1017
|
+
// mirrors the numbering convention most desktop file managers use.
|
|
1018
|
+
function suggestAlternateName(name) {
|
|
1019
|
+
const m = /^(.*) \((\d+)\)(\.[^.]*)?$/.exec(name);
|
|
1020
|
+
if (m) return m[1] + ' (' + (parseInt(m[2], 10) + 1) + ')' + (m[3] || '');
|
|
1021
|
+
const dot = name.lastIndexOf('.');
|
|
1022
|
+
return dot > 0 ? name.slice(0, dot) + ' (2)' + name.slice(dot) : name + ' (2)';
|
|
1023
|
+
}
|
|
1015
1024
|
function openFileDialog(kind, file) {
|
|
1016
1025
|
const trigger = typeof document !== 'undefined' ? document.activeElement : null;
|
|
1017
1026
|
state.files.dialog = { kind, file: file || null, error: null, busy: false, _trigger: trigger || null };
|
|
@@ -1285,7 +1294,7 @@ function fileDialog() {
|
|
|
1285
1294
|
// flow, so a sibling alert was invisible and outside the focus trap).
|
|
1286
1295
|
if (d.kind === 'rename') {
|
|
1287
1296
|
return PromptDialog({
|
|
1288
|
-
title: 'Rename ' + d.file.name, value: d.file.name, placeholder: 'new name',
|
|
1297
|
+
title: 'Rename ' + d.file.name, value: d.suggestedValue ?? d.file.name, placeholder: 'new name',
|
|
1289
1298
|
error: d.error || null, busy: !!d.busy,
|
|
1290
1299
|
confirmLabel: d.busy ? 'renaming…' : 'rename', cancelLabel: 'cancel',
|
|
1291
1300
|
onCancel: closeFileDialog,
|
|
@@ -1295,7 +1304,17 @@ function fileDialog() {
|
|
|
1295
1304
|
runFileMutation(() => B.renameEntry(state.backend, d.file.path, v), 'renamed to ' + v,
|
|
1296
1305
|
(entries) => entries.map((e) => (e.path || e.name) === (d.file.path || d.file.name)
|
|
1297
1306
|
? { ...e, name: v, path: e.path ? e.path.slice(0, e.path.length - d.file.name.length) + v : v }
|
|
1298
|
-
: e))
|
|
1307
|
+
: e))
|
|
1308
|
+
.then(() => {
|
|
1309
|
+
// On a 409 name-collision, prefill the retry input with a
|
|
1310
|
+
// suggested alternate instead of leaving the identical blocked
|
|
1311
|
+
// string - matching the pattern uploadFiles' 409-retry rows
|
|
1312
|
+
// already establish for the same class of conflict.
|
|
1313
|
+
if (state.files.dialog === d && d.error && /already exists/i.test(d.error)) {
|
|
1314
|
+
d.suggestedValue = suggestAlternateName(v);
|
|
1315
|
+
render();
|
|
1316
|
+
}
|
|
1317
|
+
});
|
|
1299
1318
|
},
|
|
1300
1319
|
});
|
|
1301
1320
|
}
|
|
@@ -1348,11 +1367,18 @@ function fileDialog() {
|
|
|
1348
1367
|
}
|
|
1349
1368
|
return PromptDialog({
|
|
1350
1369
|
title: 'Move ' + n + ' selected ' + (n === 1 ? 'entry' : 'entries'),
|
|
1351
|
-
value: state.files.path || '', placeholder: 'destination folder path',
|
|
1370
|
+
value: d._draft ?? (state.files.path || ''), placeholder: 'destination folder path',
|
|
1352
1371
|
error: d.error || null, busy: !!d.busy,
|
|
1353
1372
|
confirmLabel: d.busy ? 'moving…' : 'move ' + n, cancelLabel: 'cancel',
|
|
1373
|
+
// A second/third allowed root has no discoverable path other than
|
|
1374
|
+
// typing it from memory - one-click chips for every accessible root,
|
|
1375
|
+
// matching the same practicality upgrade the cwd editor got.
|
|
1376
|
+
roots: (Array.isArray(state.files.roots) && state.files.roots.length > 1)
|
|
1377
|
+
? state.files.roots.map((r) => ({ path: r, label: truncate(projectLabel(r) || r, 14, 24) }))
|
|
1378
|
+
: undefined,
|
|
1354
1379
|
onCancel: closeFileDialog,
|
|
1355
|
-
onInput: (v) => { d.error = null; d._validateDest(v); },
|
|
1380
|
+
onInput: (v) => { d._draft = v; d.error = null; d._validateDest(v); },
|
|
1381
|
+
onPickRoot: (v) => { d._draft = v; d.error = null; d._validateDest(v); render(); },
|
|
1356
1382
|
onConfirm: (v) => {
|
|
1357
1383
|
if (!v) { d.error = 'enter a destination folder'; render(); return; }
|
|
1358
1384
|
if (v === state.files.path) { d.error = 'already in that folder - enter a different destination'; render(); return; }
|
|
@@ -1362,7 +1388,7 @@ function fileDialog() {
|
|
|
1362
1388
|
}
|
|
1363
1389
|
if (d.kind === 'mkdir') {
|
|
1364
1390
|
return PromptDialog({
|
|
1365
|
-
title: 'New folder', value: '', placeholder: 'folder name',
|
|
1391
|
+
title: 'New folder', value: d.suggestedValue ?? '', placeholder: 'folder name',
|
|
1366
1392
|
error: d.error || null, busy: !!d.busy,
|
|
1367
1393
|
confirmLabel: d.busy ? 'creating…' : 'create', cancelLabel: 'cancel',
|
|
1368
1394
|
onCancel: closeFileDialog,
|
|
@@ -1371,7 +1397,13 @@ function fileDialog() {
|
|
|
1371
1397
|
runFileMutation(() => B.makeDir(state.backend, state.files.path, v), 'created ' + v,
|
|
1372
1398
|
(entries) => entries.some((e) => e.name === v)
|
|
1373
1399
|
? entries
|
|
1374
|
-
: [...entries, { name: v, path: (state.files.path ? state.files.path.replace(/\/$/, '') + '/' : '') + v, type: 'dir' }])
|
|
1400
|
+
: [...entries, { name: v, path: (state.files.path ? state.files.path.replace(/\/$/, '') + '/' : '') + v, type: 'dir' }])
|
|
1401
|
+
.then(() => {
|
|
1402
|
+
if (state.files.dialog === d && d.error && /already exists/i.test(d.error)) {
|
|
1403
|
+
d.suggestedValue = suggestAlternateName(v);
|
|
1404
|
+
render();
|
|
1405
|
+
}
|
|
1406
|
+
});
|
|
1375
1407
|
},
|
|
1376
1408
|
});
|
|
1377
1409
|
}
|
|
@@ -1665,6 +1697,21 @@ function filesMain() {
|
|
|
1665
1697
|
// stop-selected against sessions that no longer exist.
|
|
1666
1698
|
const LIVE_PREFS_KEY = 'agentgui.live';
|
|
1667
1699
|
const FILES_PREFS_KEY = 'agentgui.files';
|
|
1700
|
+
// Recent-cwd MRU: a small, most-recently-used list of working directories
|
|
1701
|
+
// actually saved, so switching between a regular handful of projects needs no
|
|
1702
|
+
// re-typing/re-browsing. Capped small - this is a quick-pick convenience, not
|
|
1703
|
+
// a full history (that's what the Files tab + browse popover are for).
|
|
1704
|
+
const CWD_RECENT_KEY = 'agentgui.cwd.recent';
|
|
1705
|
+
const CWD_RECENT_CAP = 6;
|
|
1706
|
+
function loadRecentCwds() {
|
|
1707
|
+
try { const v = JSON.parse(lsGet(CWD_RECENT_KEY) || '[]'); return Array.isArray(v) ? v : []; } catch { return []; }
|
|
1708
|
+
}
|
|
1709
|
+
function pushRecentCwd(path) {
|
|
1710
|
+
if (!path) return;
|
|
1711
|
+
const list = loadRecentCwds().filter((p) => p !== path);
|
|
1712
|
+
list.unshift(path);
|
|
1713
|
+
lsSet(CWD_RECENT_KEY, JSON.stringify(list.slice(0, CWD_RECENT_CAP)));
|
|
1714
|
+
}
|
|
1668
1715
|
function persistLivePrefs() {
|
|
1669
1716
|
lsSet(LIVE_PREFS_KEY, JSON.stringify({ sort: state.live.sort || 'status', errorsOnly: !!state.live.errorsOnly, filter: state.live.filter || '' }));
|
|
1670
1717
|
if (state.tab === 'live') writeHash();
|
|
@@ -2089,6 +2136,7 @@ function applyToolResult(parts, block) {
|
|
|
2089
2136
|
const ERR_COPY = [
|
|
2090
2137
|
[/^ws closed$/, 'Lost connection to the server.'],
|
|
2091
2138
|
[/connection lost during stream/, 'Lost connection while the agent was responding - use retry.'],
|
|
2139
|
+
[/connection dropped mid-turn/, 'Connection dropped mid-turn - the response above may be incomplete (events weren\'t replayed). Retry to try again.'],
|
|
2092
2140
|
[/no sessionId from server/, 'The server could not start the agent - check it is installed.'],
|
|
2093
2141
|
[/^sessions: \d+/, 'History is still indexing - try again in a moment.'],
|
|
2094
2142
|
];
|
|
@@ -2151,11 +2199,17 @@ function chatMain() {
|
|
|
2151
2199
|
banners.push(Alert({ key: 'cwderr', kind: 'warn', title: 'Invalid working directory', children: state.cwdError }));
|
|
2152
2200
|
}
|
|
2153
2201
|
if (state.chat.externalUpdate) {
|
|
2202
|
+
const hasDraft = !!(state.chat.draft && state.chat.draft.trim());
|
|
2154
2203
|
banners.push(Alert({ key: 'xupd', kind: 'info', title: 'This chat was updated in another tab',
|
|
2155
2204
|
children: [
|
|
2156
|
-
h('span', { key: 'xutxt' }, 'Reload it to see the latest turns, or dismiss to keep this tab\'s view. '
|
|
2205
|
+
h('span', { key: 'xutxt' }, 'Reload it to see the latest turns, or dismiss to keep this tab\'s view. '
|
|
2206
|
+
// "reload it" re-pulls the transcript from localStorage, which
|
|
2207
|
+
// silently discarded whatever was typed but not yet sent in THIS
|
|
2208
|
+
// tab - warn before that data-loss, not just after.
|
|
2209
|
+
+ (hasDraft ? 'This tab has an unsent draft that reloading will discard. ' : '')),
|
|
2157
2210
|
Btn({ key: 'xureload', disabled: state.chat.busy, onClick: () => {
|
|
2158
2211
|
if (state.chat.busy) return;
|
|
2212
|
+
if (hasDraft && !window.confirm('Reloading will discard your unsent draft in this tab. Continue?')) return;
|
|
2159
2213
|
state.chat.externalUpdate = false;
|
|
2160
2214
|
state.chat.messages = []; state.chat.resumeSid = null; state.chat.totalCost = 0;
|
|
2161
2215
|
restoreChat(); render();
|
|
@@ -2174,9 +2228,15 @@ function chatMain() {
|
|
|
2174
2228
|
Btn({ key: 'ptruncdismiss', onClick: () => { state.chat.persistTruncated = false; render(); }, children: 'dismiss' })] }));
|
|
2175
2229
|
}
|
|
2176
2230
|
if (state.agentsError) {
|
|
2231
|
+
// A failed agents.list call (server/network issue) is a distinct case
|
|
2232
|
+
// from "the list loaded but nothing is installed" (installHint below,
|
|
2233
|
+
// which only fires on a genuinely non-empty, all-unavailable list) -
|
|
2234
|
+
// recommending npx-install commands here would be actively wrong, since
|
|
2235
|
+
// we don't actually know whether anything is installed. Just clarify the
|
|
2236
|
+
// failure is transient/server-side, not a "nothing is installed" state.
|
|
2177
2237
|
banners.push(Alert({ key: 'agerr', kind: 'error', title: 'Could not load agents from the server',
|
|
2178
2238
|
children: [
|
|
2179
|
-
h('span', { key: 'agtxt', title: state.agentsError }, 'The agent list failed to load. '),
|
|
2239
|
+
h('span', { key: 'agtxt', title: state.agentsError }, 'The agent list failed to load - this is a connection issue, not necessarily a sign nothing is installed. '),
|
|
2180
2240
|
Btn({ key: 'agretry', onClick: () => loadAgents(), children: 'retry' })] }));
|
|
2181
2241
|
}
|
|
2182
2242
|
if (state.chat.loadingTranscript) {
|
|
@@ -2185,9 +2245,19 @@ function chatMain() {
|
|
|
2185
2245
|
}
|
|
2186
2246
|
if (state.chat.confirmingEdit) {
|
|
2187
2247
|
const isRetry = state.chat.confirmingEdit.kind === 'retry';
|
|
2248
|
+
// Name the actual count/cost about to be discarded instead of the vague
|
|
2249
|
+
// "the later turns" - a user retrying/editing message 2 of 40 needs to
|
|
2250
|
+
// know whether that's 1 turn or 38 before confirming a destructive undo.
|
|
2251
|
+
const truncIdx = state.chat.confirmingEdit.idx;
|
|
2252
|
+
const discarded = state.chat.messages.slice(truncIdx + 1);
|
|
2253
|
+
const discardedTurns = discarded.filter((m) => m.role === 'user').length;
|
|
2254
|
+
const discardedCost = discarded.reduce((s, m) => s + (typeof m.costUsd === 'number' ? m.costUsd : 0), 0);
|
|
2255
|
+
const discardSummary = discardedTurns
|
|
2256
|
+
? plural(discardedTurns, 'turn') + (discardedCost > 0 ? ' ($' + discardedCost.toFixed(4) + ')' : '')
|
|
2257
|
+
: null;
|
|
2188
2258
|
banners.push(Alert({ key: 'confedit', kind: 'warn', title: isRetry ? 'Retry this turn?' : 'Edit this message?',
|
|
2189
2259
|
children: [
|
|
2190
|
-
h('span', { key: 'cetext' }, (isRetry ? 'Retrying' : 'Editing') + ' will remove the later turns - continue? '),
|
|
2260
|
+
h('span', { key: 'cetext' }, (isRetry ? 'Retrying' : 'Editing') + ' will remove ' + (discardSummary ? discardSummary + ' after this point' : 'the later turns') + ' - continue? '),
|
|
2191
2261
|
Btn({ key: 'ceno', onClick: cancelEditAndResend, children: 'cancel' }),
|
|
2192
2262
|
Btn({ key: 'ceyes', danger: true, onClick: confirmEditAndResend, children: isRetry ? 'retry' : 'continue' })] }));
|
|
2193
2263
|
}
|
|
@@ -2296,6 +2366,16 @@ function chatMain() {
|
|
|
2296
2366
|
cwdDraft: state.cwdDraft,
|
|
2297
2367
|
cwdError: state.cwdError || null,
|
|
2298
2368
|
cwdChecking: !!state.cwdChecking,
|
|
2369
|
+
// Practicality upgrade: allowed roots (one-click starting points, always
|
|
2370
|
+
// visible instead of buried in the Files tab's own picker), a small
|
|
2371
|
+
// recent-cwd MRU (one-click switch between the handful of directories a
|
|
2372
|
+
// user actually works in), and an inline browse popover (click through
|
|
2373
|
+
// subdirectories right here instead of round-tripping through Files).
|
|
2374
|
+
cwdRoots: (Array.isArray(state.files.roots) && state.files.roots.length)
|
|
2375
|
+
? state.files.roots.map((r) => ({ path: r, label: truncate(projectLabel(r) || r, 14, 24) }))
|
|
2376
|
+
: undefined,
|
|
2377
|
+
cwdRecent: loadRecentCwds().filter((p) => p !== state.chatCwd),
|
|
2378
|
+
cwdBrowse: state.cwdBrowse || undefined,
|
|
2299
2379
|
// Pasted images upload through the same confined endpoint the Files tab
|
|
2300
2380
|
// uses, into the chat's current cwd, then insert the resulting relative
|
|
2301
2381
|
// path into the draft - matching the desktop-composer expectation
|
|
@@ -2332,14 +2412,35 @@ function chatMain() {
|
|
|
2332
2412
|
if (was !== now) render();
|
|
2333
2413
|
},
|
|
2334
2414
|
onSend: (v) => { state.chat.draft = v; sendChat(); },
|
|
2335
|
-
onCwdEdit: () => {
|
|
2415
|
+
onCwdEdit: () => {
|
|
2416
|
+
state.cwdEditing = true; state.cwdDraft = state.chatCwd || ''; state.cwdError = null; render();
|
|
2417
|
+
requestAnimationFrame(() => { const inp = document.querySelector('.agentchat-cwd-input'); if (inp) inp.focus(); });
|
|
2418
|
+
// A fresh user has no way to discover what's even browsable if roots
|
|
2419
|
+
// are only fetched on the first "browse" click - prefetch them the
|
|
2420
|
+
// moment the editor opens (root-only listDir call, cheap) so the
|
|
2421
|
+
// always-visible roots row is populated on the very first open.
|
|
2422
|
+
if (!(state.files.roots && state.files.roots.length)) {
|
|
2423
|
+
B.listDir(state.backend, '').then((j) => { state.files.roots = j.roots || []; render(); }).catch(() => {});
|
|
2424
|
+
}
|
|
2425
|
+
},
|
|
2426
|
+
onCwdBrowseToggle: () => toggleCwdBrowse(),
|
|
2427
|
+
onCwdBrowseCrumb: (i) => cwdBrowseCrumb(i),
|
|
2428
|
+
onCwdBrowseEnter: (path) => loadCwdBrowseDir(path),
|
|
2429
|
+
onCwdBrowsePick: (path) => cwdBrowsePick(path),
|
|
2336
2430
|
onCwdSave: async () => {
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
//
|
|
2340
|
-
//
|
|
2341
|
-
|
|
2342
|
-
|
|
2431
|
+
let path = (state.cwdDraft ?? '').trim();
|
|
2432
|
+
const isAbsolute = /^([/\\]|[A-Za-z]:[/\\])/.test(path);
|
|
2433
|
+
// A bare relative subpath (no leading / or drive letter) resolves
|
|
2434
|
+
// against the CURRENT chat cwd, not the server process dir - lets a
|
|
2435
|
+
// power user type a known subfolder name instead of the full path or
|
|
2436
|
+
// opening the browse popover. Only meaningful when a cwd is already
|
|
2437
|
+
// set; with no current cwd there's no sensible base to resolve against.
|
|
2438
|
+
if (path && !isAbsolute && state.chatCwd) {
|
|
2439
|
+
const base = state.chatCwd.replace(/[/\\]+$/, '');
|
|
2440
|
+
const sep = state.chatCwd.includes('\\') && !state.chatCwd.includes('/') ? '\\' : '/';
|
|
2441
|
+
path = base + sep + path.replace(/^\.[/\\]/, '');
|
|
2442
|
+
} else if (path && !isAbsolute) {
|
|
2443
|
+
state.cwdError = 'enter an absolute path (e.g. /home/you/proj or C:\\proj), a subfolder name (resolves against the current cwd once one is set), or leave blank';
|
|
2343
2444
|
render();
|
|
2344
2445
|
return;
|
|
2345
2446
|
}
|
|
@@ -2350,7 +2451,7 @@ function chatMain() {
|
|
|
2350
2451
|
if (!st || st.ok === false) { state.cwdError = 'directory not found on the server: ' + path; render(); return; }
|
|
2351
2452
|
if (!st.dir) { state.cwdError = 'that path is not a directory'; render(); return; }
|
|
2352
2453
|
} catch (e) {
|
|
2353
|
-
state.cwdError = e.status === 403 ? 'outside the accessible folders'
|
|
2454
|
+
state.cwdError = e.status === 403 ? 'outside the accessible folders - use "browse…" or a "recent"/root chip above to pick a reachable one'
|
|
2354
2455
|
: (e.status === 404 ? 'directory not found on the server: ' + path
|
|
2355
2456
|
: (e.status ? 'directory not found on the server: ' + path : 'could not validate the path - server unreachable'));
|
|
2356
2457
|
render();
|
|
@@ -2359,20 +2460,86 @@ function chatMain() {
|
|
|
2359
2460
|
}
|
|
2360
2461
|
state.cwdError = null;
|
|
2361
2462
|
state.chatCwd = path;
|
|
2362
|
-
if (state.chatCwd) lsSet('agentgui.cwd', state.chatCwd); else lsRemove('agentgui.cwd');
|
|
2363
|
-
state.cwdEditing = false; state.cwdDraft = undefined; render();
|
|
2463
|
+
if (state.chatCwd) { lsSet('agentgui.cwd', state.chatCwd); pushRecentCwd(state.chatCwd); } else lsRemove('agentgui.cwd');
|
|
2464
|
+
state.cwdEditing = false; state.cwdDraft = undefined; state.cwdBrowse = null; render();
|
|
2364
2465
|
},
|
|
2365
|
-
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(); }); },
|
|
2466
|
+
onCwdCancel: () => { state.cwdEditing = false; state.cwdDraft = undefined; state.cwdError = null; state.cwdChecking = false; state.cwdBrowse = null; render(); requestAnimationFrame(() => { const btn = document.querySelector('.agentchat-cwd-btn'); if (btn) btn.focus(); }); },
|
|
2366
2467
|
onCwdClear: () => { state.chatCwd = ''; lsRemove('agentgui.cwd'); render(); },
|
|
2367
2468
|
onCwdDraft: (v) => { state.cwdDraft = v; state.cwdError = null; debouncedCwdProbe(); },
|
|
2368
2469
|
}),
|
|
2369
2470
|
].filter(Boolean);
|
|
2370
2471
|
}
|
|
2371
2472
|
|
|
2473
|
+
// Inline cwd-browse popover: a lightweight directory-only listing reusing the
|
|
2474
|
+
// same confined B.listDir the Files tab uses, but writing to its own
|
|
2475
|
+
// state.cwdBrowse rather than state.files - opening/closing the cwd browser
|
|
2476
|
+
// must never disturb whatever the Files tab currently has loaded, and vice
|
|
2477
|
+
// versa (independent request-id guards against cross-talk if both are open).
|
|
2478
|
+
let _cwdBrowseReqId = 0;
|
|
2479
|
+
async function loadCwdBrowseDir(dirPath) {
|
|
2480
|
+
const myReq = (_cwdBrowseReqId += 1);
|
|
2481
|
+
state.cwdBrowse = { ...(state.cwdBrowse || {}), loading: true };
|
|
2482
|
+
render();
|
|
2483
|
+
try {
|
|
2484
|
+
const j = await B.listDir(state.backend, dirPath || '');
|
|
2485
|
+
if (_cwdBrowseReqId !== myReq) return;
|
|
2486
|
+
state.cwdBrowse = {
|
|
2487
|
+
current: j.path,
|
|
2488
|
+
segments: j.segments || [],
|
|
2489
|
+
rootLabel: (state.files.roots && state.files.roots.length > 1) ? 'roots' : 'root',
|
|
2490
|
+
// Directory-only listing: the cwd picker is for choosing a folder to run
|
|
2491
|
+
// in, not for browsing/opening files - filter out non-directory entries.
|
|
2492
|
+
entries: (j.entries || []).filter((e) => e.type === 'dir' || e.isDir || e.dir),
|
|
2493
|
+
loading: false,
|
|
2494
|
+
};
|
|
2495
|
+
render();
|
|
2496
|
+
} catch (e) {
|
|
2497
|
+
if (_cwdBrowseReqId !== myReq) return;
|
|
2498
|
+
state.cwdBrowse = { ...(state.cwdBrowse || {}), loading: false, entries: [] };
|
|
2499
|
+
render();
|
|
2500
|
+
}
|
|
2501
|
+
}
|
|
2502
|
+
function toggleCwdBrowse() {
|
|
2503
|
+
if (state.cwdBrowse) { state.cwdBrowse = null; render(); return; }
|
|
2504
|
+
loadCwdBrowseDir(state.cwdDraft || state.chatCwd || '');
|
|
2505
|
+
}
|
|
2506
|
+
function cwdBrowseCrumb(segIdx) {
|
|
2507
|
+
if (!state.cwdBrowse || !state.cwdBrowse.segments) return;
|
|
2508
|
+
const target = segIdx === 0 ? '' : state.cwdBrowse.segments.slice(0, segIdx).join('/');
|
|
2509
|
+
loadCwdBrowseDir(target);
|
|
2510
|
+
}
|
|
2511
|
+
// "use this folder" commits the browse popover's CURRENT directory into the
|
|
2512
|
+
// draft input (leaving the popover open state cleared) so the user still
|
|
2513
|
+
// confirms via the normal save flow (which re-validates + persists to recent).
|
|
2514
|
+
function cwdBrowsePick(path) {
|
|
2515
|
+
if (!path) return;
|
|
2516
|
+
state.cwdDraft = path;
|
|
2517
|
+
state.cwdError = null;
|
|
2518
|
+
state.cwdBrowse = null;
|
|
2519
|
+
debouncedCwdProbe();
|
|
2520
|
+
render();
|
|
2521
|
+
}
|
|
2522
|
+
|
|
2372
2523
|
function offlineBanner() {
|
|
2524
|
+
// A session-expired 401 is a distinct, more actionable case than a plain
|
|
2525
|
+
// network-down offline state (the fix is "reload", not "wait for
|
|
2526
|
+
// reconnect") - surfaced first since it takes priority as an explanation.
|
|
2527
|
+
if (state.sessionExpired) {
|
|
2528
|
+
return Alert({ key: 'sessionexpired', kind: 'error', title: 'Session expired',
|
|
2529
|
+
children: [
|
|
2530
|
+
h('span', { key: 'setxt' }, 'Your access token is no longer valid (it may have rotated or expired). Reload the page to sign in again. '),
|
|
2531
|
+
Btn({ key: 'sereload', onClick: () => window.location.reload(), children: 'reload now' }),
|
|
2532
|
+
] });
|
|
2533
|
+
}
|
|
2373
2534
|
if (state.health.status === 'ok' || state.health.status === 'unknown') return null;
|
|
2374
2535
|
return Alert({ key: 'offline', kind: 'error', title: 'Backend unreachable',
|
|
2375
|
-
children:
|
|
2536
|
+
children: [
|
|
2537
|
+
h('span', { key: 'otxt' }, 'agentgui can\'t reach the server (' + (state.health.error || state.health.status) + '). Chat and history actions will fail until it reconnects. '),
|
|
2538
|
+
// Previously the only recovery was waiting for the WS backoff timer or
|
|
2539
|
+
// a manual page reload - a direct retry button re-probes /health
|
|
2540
|
+
// immediately instead of making the user guess how long to wait.
|
|
2541
|
+
Btn({ key: 'oretry', disabled: !!state.healthChecking, onClick: () => recheckHealth(), children: state.healthChecking ? 'checking…' : 'retry now' }),
|
|
2542
|
+
] });
|
|
2376
2543
|
}
|
|
2377
2544
|
|
|
2378
2545
|
// (The working-directory bar now lives in the AgentChat kit; agentgui wires its
|
|
@@ -2805,19 +2972,31 @@ async function sendChat(textArg) {
|
|
|
2805
2972
|
// Validate the cwd draft while editing (debounced) so an invalid path reads as
|
|
2806
2973
|
// invalid before the save click, via the existing confined /api/stat endpoint.
|
|
2807
2974
|
const debouncedCwdProbe = debounce(async () => {
|
|
2808
|
-
const
|
|
2975
|
+
const raw = (state.cwdDraft ?? '').trim();
|
|
2809
2976
|
if (!state.cwdEditing) return;
|
|
2810
|
-
|
|
2977
|
+
const isAbsolute = /^([/\\]|[A-Za-z]:[/\\])/.test(raw);
|
|
2978
|
+
let path = raw;
|
|
2979
|
+
if (raw && !isAbsolute && state.chatCwd) {
|
|
2980
|
+
// Mirror onCwdSave's relative-subpath resolution so the live-typing hint
|
|
2981
|
+
// reflects the same interpretation the save button will actually apply.
|
|
2982
|
+
const base = state.chatCwd.replace(/[/\\]+$/, '');
|
|
2983
|
+
const sep = state.chatCwd.includes('\\') && !state.chatCwd.includes('/') ? '\\' : '/';
|
|
2984
|
+
path = base + sep + raw.replace(/^\.[/\\]/, '');
|
|
2985
|
+
} else if (raw && !isAbsolute) {
|
|
2986
|
+
state.cwdChecking = false; render(); return; // no cwd to resolve against yet - onCwdSave surfaces the error on save
|
|
2987
|
+
} else if (!raw) {
|
|
2988
|
+
state.cwdChecking = false; render(); return;
|
|
2989
|
+
}
|
|
2811
2990
|
state.cwdChecking = true; render();
|
|
2812
|
-
const probed =
|
|
2991
|
+
const probed = raw; // compare against the RAW (unresolved) draft to detect staleness
|
|
2813
2992
|
try {
|
|
2814
|
-
const st = await B.statPath(state.backend,
|
|
2993
|
+
const st = await B.statPath(state.backend, path); // query the RESOLVED absolute path
|
|
2815
2994
|
if ((state.cwdDraft ?? '').trim() !== probed) return; // draft moved on
|
|
2816
2995
|
state.cwdError = (!st || st.ok === false) ? 'folder not found on the server'
|
|
2817
2996
|
: (!st.dir ? 'that path is not a directory' : null);
|
|
2818
2997
|
} catch (e) {
|
|
2819
2998
|
if ((state.cwdDraft ?? '').trim() !== probed) return;
|
|
2820
|
-
state.cwdError = e.status === 403 ? 'outside the accessible folders'
|
|
2999
|
+
state.cwdError = e.status === 403 ? 'outside the accessible folders - try "browse…" or a chip above'
|
|
2821
3000
|
: (e.status === 404 ? 'folder not found on the server' : null);
|
|
2822
3001
|
}
|
|
2823
3002
|
state.cwdChecking = false;
|
|
@@ -2864,10 +3043,10 @@ function eventMatchesFilter(e, f) {
|
|
|
2864
3043
|
|
|
2865
3044
|
// Scroll to + flash the first error event, widening the render window (and
|
|
2866
3045
|
// clearing the type filter) so the row is actually rendered.
|
|
2867
|
-
function
|
|
2868
|
-
const idx = state.events.findIndex(e => e.isError);
|
|
3046
|
+
function jumpToEvent(idx) {
|
|
2869
3047
|
if (idx < 0) return;
|
|
2870
3048
|
state.eventFilter = 'all';
|
|
3049
|
+
state._errorNavIdx = idx;
|
|
2871
3050
|
const fromEnd = state.events.length - idx;
|
|
2872
3051
|
if (fromEnd > state.eventsLimit) state.eventsLimit = Math.ceil(fromEnd / 300) * 300;
|
|
2873
3052
|
render();
|
|
@@ -2879,6 +3058,21 @@ function jumpToFirstError() {
|
|
|
2879
3058
|
if (row) { row.scrollIntoView({ block: 'center' }); row.classList.add('event-flash'); setTimeout(() => row.classList.remove('event-flash'), 2000); }
|
|
2880
3059
|
});
|
|
2881
3060
|
}
|
|
3061
|
+
function jumpToFirstError() {
|
|
3062
|
+
const idx = state.events.findIndex(e => e.isError);
|
|
3063
|
+
jumpToEvent(idx);
|
|
3064
|
+
}
|
|
3065
|
+
// Persistent next/prev navigation between error events - jumpToFirstError
|
|
3066
|
+
// alone only reaches the FIRST error once; a session with multiple errors had
|
|
3067
|
+
// no way to step through them without manually scanning the event list.
|
|
3068
|
+
function jumpToNextError(dir) {
|
|
3069
|
+
const errIdxs = state.events.reduce((acc, e, i) => { if (e.isError) acc.push(i); return acc; }, []);
|
|
3070
|
+
if (!errIdxs.length) return;
|
|
3071
|
+
const cur = state._errorNavIdx;
|
|
3072
|
+
let pos = errIdxs.indexOf(cur);
|
|
3073
|
+
pos = pos < 0 ? (dir > 0 ? 0 : errIdxs.length - 1) : (pos + dir + errIdxs.length) % errIdxs.length;
|
|
3074
|
+
jumpToEvent(errIdxs[pos]);
|
|
3075
|
+
}
|
|
2882
3076
|
|
|
2883
3077
|
function historyMain() {
|
|
2884
3078
|
if (!state.selectedSid) {
|
|
@@ -2904,8 +3098,14 @@ function historyMain() {
|
|
|
2904
3098
|
}
|
|
2905
3099
|
|
|
2906
3100
|
const sess = (Array.isArray(state.sessions) ? state.sessions : []).find(s => s.sid === state.selectedSid);
|
|
3101
|
+
// sess.model is the raw ccsniff-sourced field (41st-run fix); ccsniff only
|
|
3102
|
+
// reads Claude Code's own JSONL so the agent is always constant. The
|
|
3103
|
+
// History detail header previously never surfaced either, reading
|
|
3104
|
+
// identity-thin next to the same session's Running-panel/Live-dashboard
|
|
3105
|
+
// rows which both show an agent+model badge.
|
|
3106
|
+
const agentModelBit = sess?.model ? ((agentById('claude-code')?.name || 'Claude Code') + ' · ' + sess.model) : null;
|
|
2907
3107
|
const lede = sess
|
|
2908
|
-
? (projectLabel(sess.project) || pathBasename(sess.cwd) || 'unknown location') + ' · ' + plural(sess.events || 0, 'event') + ' · ' + plural(sess.userTurns || 0, 'turn') + ' · ' + fmtRelTime(sess.last)
|
|
3108
|
+
? (projectLabel(sess.project) || pathBasename(sess.cwd) || 'unknown location') + (agentModelBit ? ' · ' + agentModelBit : '') + ' · ' + plural(sess.events || 0, 'event') + ' · ' + plural(sess.userTurns || 0, 'turn') + ' · ' + fmtRelTime(sess.last)
|
|
2909
3109
|
: UNTITLED_CONVERSATION;
|
|
2910
3110
|
|
|
2911
3111
|
const head = PageHeader({
|
|
@@ -2923,6 +3123,11 @@ function historyMain() {
|
|
|
2923
3123
|
onClick: () => downloadBlob(JSON.stringify(state.events, null, 2), (projectLabel(sess?.project) || 'session') + '-' + state.selectedSid + '.json', 'application/json'),
|
|
2924
3124
|
children: 'export' }),
|
|
2925
3125
|
hasErrors ? Btn({ key: 'jumperr', onClick: jumpToFirstError, children: 'jump to first error' }) : null,
|
|
3126
|
+
// Persistent next/prev stepping between errors - jump-to-first alone only
|
|
3127
|
+
// ever reaches the FIRST one; a session with several errors had no way to
|
|
3128
|
+
// step through the rest without manually scrolling/scanning.
|
|
3129
|
+
hasErrors ? Btn({ key: 'errprev', title: 'previous error', 'aria-label': 'previous error', onClick: () => jumpToNextError(-1), children: 'prev error' }) : null,
|
|
3130
|
+
hasErrors ? Btn({ key: 'errnext', title: 'next error', 'aria-label': 'next error', onClick: () => jumpToNextError(1), children: 'next error' }) : null,
|
|
2926
3131
|
].filter(Boolean));
|
|
2927
3132
|
|
|
2928
3133
|
if (state.events.length === 0) {
|
|
@@ -2966,7 +3171,9 @@ function historyMain() {
|
|
|
2966
3171
|
}, { turns: 0, tools: 0, errors: 0 });
|
|
2967
3172
|
const meta = SessionMeta({
|
|
2968
3173
|
items: [
|
|
2969
|
-
sess && sess.cwd ? { label: 'directory', value: sess.cwd, title: sess.cwd
|
|
3174
|
+
sess && sess.cwd ? { label: 'directory', value: sess.cwd, title: sess.cwd,
|
|
3175
|
+
actionLabel: 'use as chat cwd',
|
|
3176
|
+
onAction: () => { state.chatCwd = sess.cwd; lsSet('agentgui.cwd', sess.cwd); pushRecentCwd(sess.cwd); announce('working directory set to ' + sess.cwd); render(); } } : null,
|
|
2970
3177
|
(() => { const dur = sessionDuration(); return dur ? { label: 'duration', value: dur } : null; })(),
|
|
2971
3178
|
{ label: 'session id', value: state.selectedSid.slice(0, 8) + '…', title: state.selectedSid, onCopy: () => copyText(state.selectedSid, 'session id copied') },
|
|
2972
3179
|
// Spelled counter vocabulary in the detail strip (events/turns/tools/
|
|
@@ -3937,6 +4144,13 @@ function registerWsStatusOnce() {
|
|
|
3937
4144
|
if (state.health.ws) { delete state.health.ws; render(); }
|
|
3938
4145
|
}
|
|
3939
4146
|
});
|
|
4147
|
+
// backend.js already detects a mid-session 401 (a token that stopped being
|
|
4148
|
+
// valid - e.g. PASSWORD rotated, cookie expired) and exports this hook, but
|
|
4149
|
+
// nothing ever subscribed to it: every subsequent fetch just failed
|
|
4150
|
+
// silently with no on-screen indication of WHY. Surface it as a persistent
|
|
4151
|
+
// banner instructing a reload (the only real recovery - the token lives in
|
|
4152
|
+
// window.__WS_TOKEN, injected server-side at page load).
|
|
4153
|
+
B.onSessionExpired?.(() => { state.sessionExpired = true; render(); });
|
|
3940
4154
|
}
|
|
3941
4155
|
|
|
3942
4156
|
hydratePrefs();
|
|
@@ -4041,8 +4255,16 @@ window.addEventListener('keydown', (e) => {
|
|
|
4041
4255
|
}
|
|
4042
4256
|
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
|
4043
4257
|
if (typing) {
|
|
4044
|
-
|
|
4045
|
-
|
|
4258
|
+
// The cwd editor's own text input is the one exception: Escape there must
|
|
4259
|
+
// close the editor in a single press (matching every other Escape-
|
|
4260
|
+
// closeable surface in the app), not blur-then-require-a-second-press.
|
|
4261
|
+
if (e.key === 'Escape' && t.classList && t.classList.contains('agentchat-cwd-input')) {
|
|
4262
|
+
t.blur();
|
|
4263
|
+
// Fall through to the ladder below instead of returning early.
|
|
4264
|
+
} else {
|
|
4265
|
+
if (e.key === 'Escape') t.blur();
|
|
4266
|
+
return;
|
|
4267
|
+
}
|
|
4046
4268
|
}
|
|
4047
4269
|
if (e.key === 'Escape') {
|
|
4048
4270
|
// Priority ladder for transient state (modals/drawers are kit-handled):
|
|
@@ -4052,6 +4274,17 @@ window.addEventListener('keydown', (e) => {
|
|
|
4052
4274
|
// backdrop listener covers in-dialog focus; this covers everything else).
|
|
4053
4275
|
if (state.files.dialog) { if (!state.files.dialog.busy) closeFileDialog(); return; }
|
|
4054
4276
|
if (state.chat.confirmingEdit) { state.chat.confirmingEdit = null; render(); announce('edit cancelled'); return; }
|
|
4277
|
+
// cwd editor: the browse popover is a nested layer within it - Escape
|
|
4278
|
+
// closes the popover first (one level of the nesting) before falling
|
|
4279
|
+
// through to closing the whole editor on a second press, matching the
|
|
4280
|
+
// dialog-then-page Escape convention used elsewhere in the app.
|
|
4281
|
+
if (state.cwdEditing) {
|
|
4282
|
+
if (state.cwdBrowse) { state.cwdBrowse = null; render(); announce('folder browser closed'); return; }
|
|
4283
|
+
state.cwdEditing = false; state.cwdDraft = undefined; state.cwdError = null; state.cwdChecking = false;
|
|
4284
|
+
render(); announce('cwd edit cancelled');
|
|
4285
|
+
requestAnimationFrame(() => { const btn = document.querySelector('.agentchat-cwd-btn'); if (btn) btn.focus(); });
|
|
4286
|
+
return;
|
|
4287
|
+
}
|
|
4055
4288
|
if (state.confirmingClearData) { state.confirmingClearData = false; render(); announce('clear cancelled'); return; }
|
|
4056
4289
|
if (state.confirmingNewChat) { clearTimeout(_newChatArmTimer); state.confirmingNewChat = false; render(); announce('new chat cancelled'); return; }
|
|
4057
4290
|
if (state.live.confirmingStopAll || state.live.confirmingStopSelected) {
|