agentgui 1.0.1061 → 1.0.1063
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
CHANGED
|
@@ -3106,3 +3106,15 @@
|
|
|
3106
3106
|
- id: docstudio-cue-session-group-eyebrow
|
|
3107
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
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-expanded-body-highlight
|
|
3113
|
+
subject: Search-result highlighting inside expanded event bodies never highlights the matched query term
|
|
3114
|
+
status: pending
|
|
3115
|
+
- id: gc-search-results-export
|
|
3116
|
+
subject: No per-session or global export of History search results themselves
|
|
3117
|
+
status: pending
|
|
3118
|
+
- id: gc-settings-storage-estimate
|
|
3119
|
+
subject: Settings has no cache/data breakdown beyond agentgui's own localStorage keys - no navigator.storage.estimate()
|
|
3120
|
+
status: pending
|
package/package.json
CHANGED
package/site/app/js/app.js
CHANGED
|
@@ -51,7 +51,7 @@ const ARM_RESET_MS = 4000;
|
|
|
51
51
|
|
|
52
52
|
// Full routable param set. Every view-defining piece of state round-trips
|
|
53
53
|
// through the hash so reload and Back/forward restore the exact view.
|
|
54
|
-
const HASH_KEYS = ['tab', 'sid', 'dir', 'file', 'q', 'project', 'section', 'filter', 'lsort', 'lfilter', 'lerr'];
|
|
54
|
+
const HASH_KEYS = ['tab', 'sid', 'dir', 'file', 'q', 'project', 'section', 'filter', 'lsort', 'lfilter', 'lerr', 'ets'];
|
|
55
55
|
function readHash() {
|
|
56
56
|
const hash = location.hash || '';
|
|
57
57
|
const out = {};
|
|
@@ -80,6 +80,11 @@ function buildHash() {
|
|
|
80
80
|
const q = (state.searchQ || '').trim();
|
|
81
81
|
if (q.length >= 2) parts.push('q=' + encodeURIComponent(q));
|
|
82
82
|
if (state.projectFilter) parts.push('project=' + encodeURIComponent(state.projectFilter));
|
|
83
|
+
// A session opened from a search hit carries the matched event's
|
|
84
|
+
// timestamp so reload/Back reproduces the same scrolled+flashed position
|
|
85
|
+
// a live click gives - without this, the anchor only existed in memory
|
|
86
|
+
// and a search-hit URL degraded to just the bare session on reload.
|
|
87
|
+
if (state._focusEventTs != null) parts.push('ets=' + encodeURIComponent(state._focusEventTs));
|
|
83
88
|
}
|
|
84
89
|
if (tab === 'settings' && state.settingsSection) parts.push('section=' + encodeURIComponent(state.settingsSection));
|
|
85
90
|
if (tab === 'live') {
|
|
@@ -822,7 +827,14 @@ function sessionsColumn() {
|
|
|
822
827
|
onNew: () => { navTo('chat'); newChat(); },
|
|
823
828
|
onSelect: (s) => {
|
|
824
829
|
if (state.tab === 'chat') resumeInChat({ sid: s.sid });
|
|
825
|
-
else
|
|
830
|
+
else {
|
|
831
|
+
// Persist the anchor so buildHash() can carry it into the URL -
|
|
832
|
+
// without this, reload/Back only ever restored the bare session,
|
|
833
|
+
// losing the matched event's scroll+flash position.
|
|
834
|
+
state._focusEventTs = s._focusEventTs ?? null;
|
|
835
|
+
loadSession(s.sid, { focusEventI: s._focusEventI, focusEventTs: s._focusEventTs });
|
|
836
|
+
writeHash({ push: true });
|
|
837
|
+
}
|
|
826
838
|
},
|
|
827
839
|
loading: state.searchBusy,
|
|
828
840
|
error: state.searchHits.error || null,
|
|
@@ -1012,6 +1024,15 @@ function fileMutationCopy(e) {
|
|
|
1012
1024
|
if (e.status === 413) return 'Too large (50MB upload cap).';
|
|
1013
1025
|
return e.message || 'The operation failed.';
|
|
1014
1026
|
}
|
|
1027
|
+
// On a 409 name-collision, suggest 'name (2)' / 'name (3)' etc instead of
|
|
1028
|
+
// leaving the user to retype the identical blocked string from scratch -
|
|
1029
|
+
// mirrors the numbering convention most desktop file managers use.
|
|
1030
|
+
function suggestAlternateName(name) {
|
|
1031
|
+
const m = /^(.*) \((\d+)\)(\.[^.]*)?$/.exec(name);
|
|
1032
|
+
if (m) return m[1] + ' (' + (parseInt(m[2], 10) + 1) + ')' + (m[3] || '');
|
|
1033
|
+
const dot = name.lastIndexOf('.');
|
|
1034
|
+
return dot > 0 ? name.slice(0, dot) + ' (2)' + name.slice(dot) : name + ' (2)';
|
|
1035
|
+
}
|
|
1015
1036
|
function openFileDialog(kind, file) {
|
|
1016
1037
|
const trigger = typeof document !== 'undefined' ? document.activeElement : null;
|
|
1017
1038
|
state.files.dialog = { kind, file: file || null, error: null, busy: false, _trigger: trigger || null };
|
|
@@ -1285,7 +1306,7 @@ function fileDialog() {
|
|
|
1285
1306
|
// flow, so a sibling alert was invisible and outside the focus trap).
|
|
1286
1307
|
if (d.kind === 'rename') {
|
|
1287
1308
|
return PromptDialog({
|
|
1288
|
-
title: 'Rename ' + d.file.name, value: d.file.name, placeholder: 'new name',
|
|
1309
|
+
title: 'Rename ' + d.file.name, value: d.suggestedValue ?? d.file.name, placeholder: 'new name',
|
|
1289
1310
|
error: d.error || null, busy: !!d.busy,
|
|
1290
1311
|
confirmLabel: d.busy ? 'renaming…' : 'rename', cancelLabel: 'cancel',
|
|
1291
1312
|
onCancel: closeFileDialog,
|
|
@@ -1295,7 +1316,17 @@ function fileDialog() {
|
|
|
1295
1316
|
runFileMutation(() => B.renameEntry(state.backend, d.file.path, v), 'renamed to ' + v,
|
|
1296
1317
|
(entries) => entries.map((e) => (e.path || e.name) === (d.file.path || d.file.name)
|
|
1297
1318
|
? { ...e, name: v, path: e.path ? e.path.slice(0, e.path.length - d.file.name.length) + v : v }
|
|
1298
|
-
: e))
|
|
1319
|
+
: e))
|
|
1320
|
+
.then(() => {
|
|
1321
|
+
// On a 409 name-collision, prefill the retry input with a
|
|
1322
|
+
// suggested alternate instead of leaving the identical blocked
|
|
1323
|
+
// string - matching the pattern uploadFiles' 409-retry rows
|
|
1324
|
+
// already establish for the same class of conflict.
|
|
1325
|
+
if (state.files.dialog === d && d.error && /already exists/i.test(d.error)) {
|
|
1326
|
+
d.suggestedValue = suggestAlternateName(v);
|
|
1327
|
+
render();
|
|
1328
|
+
}
|
|
1329
|
+
});
|
|
1299
1330
|
},
|
|
1300
1331
|
});
|
|
1301
1332
|
}
|
|
@@ -1348,11 +1379,18 @@ function fileDialog() {
|
|
|
1348
1379
|
}
|
|
1349
1380
|
return PromptDialog({
|
|
1350
1381
|
title: 'Move ' + n + ' selected ' + (n === 1 ? 'entry' : 'entries'),
|
|
1351
|
-
value: state.files.path || '', placeholder: 'destination folder path',
|
|
1382
|
+
value: d._draft ?? (state.files.path || ''), placeholder: 'destination folder path',
|
|
1352
1383
|
error: d.error || null, busy: !!d.busy,
|
|
1353
1384
|
confirmLabel: d.busy ? 'moving…' : 'move ' + n, cancelLabel: 'cancel',
|
|
1385
|
+
// A second/third allowed root has no discoverable path other than
|
|
1386
|
+
// typing it from memory - one-click chips for every accessible root,
|
|
1387
|
+
// matching the same practicality upgrade the cwd editor got.
|
|
1388
|
+
roots: (Array.isArray(state.files.roots) && state.files.roots.length > 1)
|
|
1389
|
+
? state.files.roots.map((r) => ({ path: r, label: truncate(projectLabel(r) || r, 14, 24) }))
|
|
1390
|
+
: undefined,
|
|
1354
1391
|
onCancel: closeFileDialog,
|
|
1355
|
-
onInput: (v) => { d.error = null; d._validateDest(v); },
|
|
1392
|
+
onInput: (v) => { d._draft = v; d.error = null; d._validateDest(v); },
|
|
1393
|
+
onPickRoot: (v) => { d._draft = v; d.error = null; d._validateDest(v); render(); },
|
|
1356
1394
|
onConfirm: (v) => {
|
|
1357
1395
|
if (!v) { d.error = 'enter a destination folder'; render(); return; }
|
|
1358
1396
|
if (v === state.files.path) { d.error = 'already in that folder - enter a different destination'; render(); return; }
|
|
@@ -1362,7 +1400,7 @@ function fileDialog() {
|
|
|
1362
1400
|
}
|
|
1363
1401
|
if (d.kind === 'mkdir') {
|
|
1364
1402
|
return PromptDialog({
|
|
1365
|
-
title: 'New folder', value: '', placeholder: 'folder name',
|
|
1403
|
+
title: 'New folder', value: d.suggestedValue ?? '', placeholder: 'folder name',
|
|
1366
1404
|
error: d.error || null, busy: !!d.busy,
|
|
1367
1405
|
confirmLabel: d.busy ? 'creating…' : 'create', cancelLabel: 'cancel',
|
|
1368
1406
|
onCancel: closeFileDialog,
|
|
@@ -1371,7 +1409,13 @@ function fileDialog() {
|
|
|
1371
1409
|
runFileMutation(() => B.makeDir(state.backend, state.files.path, v), 'created ' + v,
|
|
1372
1410
|
(entries) => entries.some((e) => e.name === v)
|
|
1373
1411
|
? entries
|
|
1374
|
-
: [...entries, { name: v, path: (state.files.path ? state.files.path.replace(/\/$/, '') + '/' : '') + v, type: 'dir' }])
|
|
1412
|
+
: [...entries, { name: v, path: (state.files.path ? state.files.path.replace(/\/$/, '') + '/' : '') + v, type: 'dir' }])
|
|
1413
|
+
.then(() => {
|
|
1414
|
+
if (state.files.dialog === d && d.error && /already exists/i.test(d.error)) {
|
|
1415
|
+
d.suggestedValue = suggestAlternateName(v);
|
|
1416
|
+
render();
|
|
1417
|
+
}
|
|
1418
|
+
});
|
|
1375
1419
|
},
|
|
1376
1420
|
});
|
|
1377
1421
|
}
|
|
@@ -2104,6 +2148,7 @@ function applyToolResult(parts, block) {
|
|
|
2104
2148
|
const ERR_COPY = [
|
|
2105
2149
|
[/^ws closed$/, 'Lost connection to the server.'],
|
|
2106
2150
|
[/connection lost during stream/, 'Lost connection while the agent was responding - use retry.'],
|
|
2151
|
+
[/connection dropped mid-turn/, 'Connection dropped mid-turn - the response above may be incomplete (events weren\'t replayed). Retry to try again.'],
|
|
2107
2152
|
[/no sessionId from server/, 'The server could not start the agent - check it is installed.'],
|
|
2108
2153
|
[/^sessions: \d+/, 'History is still indexing - try again in a moment.'],
|
|
2109
2154
|
];
|
|
@@ -2166,11 +2211,17 @@ function chatMain() {
|
|
|
2166
2211
|
banners.push(Alert({ key: 'cwderr', kind: 'warn', title: 'Invalid working directory', children: state.cwdError }));
|
|
2167
2212
|
}
|
|
2168
2213
|
if (state.chat.externalUpdate) {
|
|
2214
|
+
const hasDraft = !!(state.chat.draft && state.chat.draft.trim());
|
|
2169
2215
|
banners.push(Alert({ key: 'xupd', kind: 'info', title: 'This chat was updated in another tab',
|
|
2170
2216
|
children: [
|
|
2171
|
-
h('span', { key: 'xutxt' }, 'Reload it to see the latest turns, or dismiss to keep this tab\'s view. '
|
|
2217
|
+
h('span', { key: 'xutxt' }, 'Reload it to see the latest turns, or dismiss to keep this tab\'s view. '
|
|
2218
|
+
// "reload it" re-pulls the transcript from localStorage, which
|
|
2219
|
+
// silently discarded whatever was typed but not yet sent in THIS
|
|
2220
|
+
// tab - warn before that data-loss, not just after.
|
|
2221
|
+
+ (hasDraft ? 'This tab has an unsent draft that reloading will discard. ' : '')),
|
|
2172
2222
|
Btn({ key: 'xureload', disabled: state.chat.busy, onClick: () => {
|
|
2173
2223
|
if (state.chat.busy) return;
|
|
2224
|
+
if (hasDraft && !window.confirm('Reloading will discard your unsent draft in this tab. Continue?')) return;
|
|
2174
2225
|
state.chat.externalUpdate = false;
|
|
2175
2226
|
state.chat.messages = []; state.chat.resumeSid = null; state.chat.totalCost = 0;
|
|
2176
2227
|
restoreChat(); render();
|
|
@@ -2189,9 +2240,15 @@ function chatMain() {
|
|
|
2189
2240
|
Btn({ key: 'ptruncdismiss', onClick: () => { state.chat.persistTruncated = false; render(); }, children: 'dismiss' })] }));
|
|
2190
2241
|
}
|
|
2191
2242
|
if (state.agentsError) {
|
|
2243
|
+
// A failed agents.list call (server/network issue) is a distinct case
|
|
2244
|
+
// from "the list loaded but nothing is installed" (installHint below,
|
|
2245
|
+
// which only fires on a genuinely non-empty, all-unavailable list) -
|
|
2246
|
+
// recommending npx-install commands here would be actively wrong, since
|
|
2247
|
+
// we don't actually know whether anything is installed. Just clarify the
|
|
2248
|
+
// failure is transient/server-side, not a "nothing is installed" state.
|
|
2192
2249
|
banners.push(Alert({ key: 'agerr', kind: 'error', title: 'Could not load agents from the server',
|
|
2193
2250
|
children: [
|
|
2194
|
-
h('span', { key: 'agtxt', title: state.agentsError }, 'The agent list failed to load. '),
|
|
2251
|
+
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. '),
|
|
2195
2252
|
Btn({ key: 'agretry', onClick: () => loadAgents(), children: 'retry' })] }));
|
|
2196
2253
|
}
|
|
2197
2254
|
if (state.chat.loadingTranscript) {
|
|
@@ -2200,9 +2257,19 @@ function chatMain() {
|
|
|
2200
2257
|
}
|
|
2201
2258
|
if (state.chat.confirmingEdit) {
|
|
2202
2259
|
const isRetry = state.chat.confirmingEdit.kind === 'retry';
|
|
2260
|
+
// Name the actual count/cost about to be discarded instead of the vague
|
|
2261
|
+
// "the later turns" - a user retrying/editing message 2 of 40 needs to
|
|
2262
|
+
// know whether that's 1 turn or 38 before confirming a destructive undo.
|
|
2263
|
+
const truncIdx = state.chat.confirmingEdit.idx;
|
|
2264
|
+
const discarded = state.chat.messages.slice(truncIdx + 1);
|
|
2265
|
+
const discardedTurns = discarded.filter((m) => m.role === 'user').length;
|
|
2266
|
+
const discardedCost = discarded.reduce((s, m) => s + (typeof m.costUsd === 'number' ? m.costUsd : 0), 0);
|
|
2267
|
+
const discardSummary = discardedTurns
|
|
2268
|
+
? plural(discardedTurns, 'turn') + (discardedCost > 0 ? ' ($' + discardedCost.toFixed(4) + ')' : '')
|
|
2269
|
+
: null;
|
|
2203
2270
|
banners.push(Alert({ key: 'confedit', kind: 'warn', title: isRetry ? 'Retry this turn?' : 'Edit this message?',
|
|
2204
2271
|
children: [
|
|
2205
|
-
h('span', { key: 'cetext' }, (isRetry ? 'Retrying' : 'Editing') + ' will remove the later turns - continue? '),
|
|
2272
|
+
h('span', { key: 'cetext' }, (isRetry ? 'Retrying' : 'Editing') + ' will remove ' + (discardSummary ? discardSummary + ' after this point' : 'the later turns') + ' - continue? '),
|
|
2206
2273
|
Btn({ key: 'ceno', onClick: cancelEditAndResend, children: 'cancel' }),
|
|
2207
2274
|
Btn({ key: 'ceyes', danger: true, onClick: confirmEditAndResend, children: isRetry ? 'retry' : 'continue' })] }));
|
|
2208
2275
|
}
|
|
@@ -2466,9 +2533,25 @@ function cwdBrowsePick(path) {
|
|
|
2466
2533
|
}
|
|
2467
2534
|
|
|
2468
2535
|
function offlineBanner() {
|
|
2536
|
+
// A session-expired 401 is a distinct, more actionable case than a plain
|
|
2537
|
+
// network-down offline state (the fix is "reload", not "wait for
|
|
2538
|
+
// reconnect") - surfaced first since it takes priority as an explanation.
|
|
2539
|
+
if (state.sessionExpired) {
|
|
2540
|
+
return Alert({ key: 'sessionexpired', kind: 'error', title: 'Session expired',
|
|
2541
|
+
children: [
|
|
2542
|
+
h('span', { key: 'setxt' }, 'Your access token is no longer valid (it may have rotated or expired). Reload the page to sign in again. '),
|
|
2543
|
+
Btn({ key: 'sereload', onClick: () => window.location.reload(), children: 'reload now' }),
|
|
2544
|
+
] });
|
|
2545
|
+
}
|
|
2469
2546
|
if (state.health.status === 'ok' || state.health.status === 'unknown') return null;
|
|
2470
2547
|
return Alert({ key: 'offline', kind: 'error', title: 'Backend unreachable',
|
|
2471
|
-
children:
|
|
2548
|
+
children: [
|
|
2549
|
+
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. '),
|
|
2550
|
+
// Previously the only recovery was waiting for the WS backoff timer or
|
|
2551
|
+
// a manual page reload - a direct retry button re-probes /health
|
|
2552
|
+
// immediately instead of making the user guess how long to wait.
|
|
2553
|
+
Btn({ key: 'oretry', disabled: !!state.healthChecking, onClick: () => recheckHealth(), children: state.healthChecking ? 'checking…' : 'retry now' }),
|
|
2554
|
+
] });
|
|
2472
2555
|
}
|
|
2473
2556
|
|
|
2474
2557
|
// (The working-directory bar now lives in the AgentChat kit; agentgui wires its
|
|
@@ -2972,10 +3055,10 @@ function eventMatchesFilter(e, f) {
|
|
|
2972
3055
|
|
|
2973
3056
|
// Scroll to + flash the first error event, widening the render window (and
|
|
2974
3057
|
// clearing the type filter) so the row is actually rendered.
|
|
2975
|
-
function
|
|
2976
|
-
const idx = state.events.findIndex(e => e.isError);
|
|
3058
|
+
function jumpToEvent(idx) {
|
|
2977
3059
|
if (idx < 0) return;
|
|
2978
3060
|
state.eventFilter = 'all';
|
|
3061
|
+
state._errorNavIdx = idx;
|
|
2979
3062
|
const fromEnd = state.events.length - idx;
|
|
2980
3063
|
if (fromEnd > state.eventsLimit) state.eventsLimit = Math.ceil(fromEnd / 300) * 300;
|
|
2981
3064
|
render();
|
|
@@ -2987,6 +3070,21 @@ function jumpToFirstError() {
|
|
|
2987
3070
|
if (row) { row.scrollIntoView({ block: 'center' }); row.classList.add('event-flash'); setTimeout(() => row.classList.remove('event-flash'), 2000); }
|
|
2988
3071
|
});
|
|
2989
3072
|
}
|
|
3073
|
+
function jumpToFirstError() {
|
|
3074
|
+
const idx = state.events.findIndex(e => e.isError);
|
|
3075
|
+
jumpToEvent(idx);
|
|
3076
|
+
}
|
|
3077
|
+
// Persistent next/prev navigation between error events - jumpToFirstError
|
|
3078
|
+
// alone only reaches the FIRST error once; a session with multiple errors had
|
|
3079
|
+
// no way to step through them without manually scanning the event list.
|
|
3080
|
+
function jumpToNextError(dir) {
|
|
3081
|
+
const errIdxs = state.events.reduce((acc, e, i) => { if (e.isError) acc.push(i); return acc; }, []);
|
|
3082
|
+
if (!errIdxs.length) return;
|
|
3083
|
+
const cur = state._errorNavIdx;
|
|
3084
|
+
let pos = errIdxs.indexOf(cur);
|
|
3085
|
+
pos = pos < 0 ? (dir > 0 ? 0 : errIdxs.length - 1) : (pos + dir + errIdxs.length) % errIdxs.length;
|
|
3086
|
+
jumpToEvent(errIdxs[pos]);
|
|
3087
|
+
}
|
|
2990
3088
|
|
|
2991
3089
|
function historyMain() {
|
|
2992
3090
|
if (!state.selectedSid) {
|
|
@@ -3012,8 +3110,14 @@ function historyMain() {
|
|
|
3012
3110
|
}
|
|
3013
3111
|
|
|
3014
3112
|
const sess = (Array.isArray(state.sessions) ? state.sessions : []).find(s => s.sid === state.selectedSid);
|
|
3113
|
+
// sess.model is the raw ccsniff-sourced field (41st-run fix); ccsniff only
|
|
3114
|
+
// reads Claude Code's own JSONL so the agent is always constant. The
|
|
3115
|
+
// History detail header previously never surfaced either, reading
|
|
3116
|
+
// identity-thin next to the same session's Running-panel/Live-dashboard
|
|
3117
|
+
// rows which both show an agent+model badge.
|
|
3118
|
+
const agentModelBit = sess?.model ? ((agentById('claude-code')?.name || 'Claude Code') + ' · ' + sess.model) : null;
|
|
3015
3119
|
const lede = sess
|
|
3016
|
-
? (projectLabel(sess.project) || pathBasename(sess.cwd) || 'unknown location') + ' · ' + plural(sess.events || 0, 'event') + ' · ' + plural(sess.userTurns || 0, 'turn') + ' · ' + fmtRelTime(sess.last)
|
|
3120
|
+
? (projectLabel(sess.project) || pathBasename(sess.cwd) || 'unknown location') + (agentModelBit ? ' · ' + agentModelBit : '') + ' · ' + plural(sess.events || 0, 'event') + ' · ' + plural(sess.userTurns || 0, 'turn') + ' · ' + fmtRelTime(sess.last)
|
|
3017
3121
|
: UNTITLED_CONVERSATION;
|
|
3018
3122
|
|
|
3019
3123
|
const head = PageHeader({
|
|
@@ -3031,6 +3135,11 @@ function historyMain() {
|
|
|
3031
3135
|
onClick: () => downloadBlob(JSON.stringify(state.events, null, 2), (projectLabel(sess?.project) || 'session') + '-' + state.selectedSid + '.json', 'application/json'),
|
|
3032
3136
|
children: 'export' }),
|
|
3033
3137
|
hasErrors ? Btn({ key: 'jumperr', onClick: jumpToFirstError, children: 'jump to first error' }) : null,
|
|
3138
|
+
// Persistent next/prev stepping between errors - jump-to-first alone only
|
|
3139
|
+
// ever reaches the FIRST one; a session with several errors had no way to
|
|
3140
|
+
// step through the rest without manually scrolling/scanning.
|
|
3141
|
+
hasErrors ? Btn({ key: 'errprev', title: 'previous error', 'aria-label': 'previous error', onClick: () => jumpToNextError(-1), children: 'prev error' }) : null,
|
|
3142
|
+
hasErrors ? Btn({ key: 'errnext', title: 'next error', 'aria-label': 'next error', onClick: () => jumpToNextError(1), children: 'next error' }) : null,
|
|
3034
3143
|
].filter(Boolean));
|
|
3035
3144
|
|
|
3036
3145
|
if (state.events.length === 0) {
|
|
@@ -3830,6 +3939,10 @@ async function loadSession(sid, { focusEventI = null, focusEventTs = null, fromH
|
|
|
3830
3939
|
return;
|
|
3831
3940
|
}
|
|
3832
3941
|
state.selectedSid = sid;
|
|
3942
|
+
// A plain (non-search-hit) session open must not carry a stale event
|
|
3943
|
+
// anchor forward into the URL - only reset it when this call ISN'T itself
|
|
3944
|
+
// the one supplying a fresh focusEventTs.
|
|
3945
|
+
if (focusEventTs == null) state._focusEventTs = null;
|
|
3833
3946
|
state.events = [];
|
|
3834
3947
|
state.events._seen = new Set(); // O(1) dedupe by event index
|
|
3835
3948
|
state.eventsLoaded = false;
|
|
@@ -3995,9 +4108,11 @@ async function init() {
|
|
|
3995
4108
|
} else if (hp.sid) {
|
|
3996
4109
|
if (hp.q) state.searchQ = hp.q;
|
|
3997
4110
|
if (hp.project) state.projectFilter = hp.project;
|
|
4111
|
+
const bootFocusTs = hp.ets != null ? Number(hp.ets) : null;
|
|
4112
|
+
if (bootFocusTs != null && !Number.isNaN(bootFocusTs)) state._focusEventTs = bootFocusTs;
|
|
3998
4113
|
navTo('history', { push: false });
|
|
3999
4114
|
await refreshHistory();
|
|
4000
|
-
await loadSession(hp.sid, { fromHash: true });
|
|
4115
|
+
await loadSession(hp.sid, { fromHash: true, focusEventTs: bootFocusTs != null && !Number.isNaN(bootFocusTs) ? bootFocusTs : undefined });
|
|
4001
4116
|
if (state.searchQ.trim().length >= 2) runSearch();
|
|
4002
4117
|
} else if (bootTab !== state.tab) {
|
|
4003
4118
|
// Files deep-link: restore the directory the URL names (reload keeps
|
|
@@ -4022,6 +4137,7 @@ async function init() {
|
|
|
4022
4137
|
}
|
|
4023
4138
|
|
|
4024
4139
|
registerWsStatusOnce();
|
|
4140
|
+
registerSettingsScrollSpyOnce();
|
|
4025
4141
|
startActivePolling(); // surface running chats on any tab, not just history
|
|
4026
4142
|
startRelTimeTick();
|
|
4027
4143
|
startLiveTick(); // 1s elapsed advance on the live dashboard
|
|
@@ -4047,6 +4163,13 @@ function registerWsStatusOnce() {
|
|
|
4047
4163
|
if (state.health.ws) { delete state.health.ws; render(); }
|
|
4048
4164
|
}
|
|
4049
4165
|
});
|
|
4166
|
+
// backend.js already detects a mid-session 401 (a token that stopped being
|
|
4167
|
+
// valid - e.g. PASSWORD rotated, cookie expired) and exports this hook, but
|
|
4168
|
+
// nothing ever subscribed to it: every subsequent fetch just failed
|
|
4169
|
+
// silently with no on-screen indication of WHY. Surface it as a persistent
|
|
4170
|
+
// banner instructing a reload (the only real recovery - the token lives in
|
|
4171
|
+
// window.__WS_TOKEN, injected server-side at page load).
|
|
4172
|
+
B.onSessionExpired?.(() => { state.sessionExpired = true; render(); });
|
|
4050
4173
|
}
|
|
4051
4174
|
|
|
4052
4175
|
hydratePrefs();
|
|
@@ -4073,6 +4196,44 @@ function focusSettingsSection(id) {
|
|
|
4073
4196
|
el.addEventListener('blur', clear);
|
|
4074
4197
|
});
|
|
4075
4198
|
}
|
|
4199
|
+
const SETTINGS_SECTION_IDS = ['backend', 'server', 'agents', 'appearance', 'keyboard', 'data'];
|
|
4200
|
+
// Settings was deep-link-IN only: focusSettingsSection() (a section= URL param
|
|
4201
|
+
// or the ?-overlay jump) set state.settingsSection, but manually scrolling
|
|
4202
|
+
// never updated it back - so the URL/state silently went stale the instant a
|
|
4203
|
+
// user scrolled by hand, and Back could never step BETWEEN panels the way it
|
|
4204
|
+
// does for every other tab. A lightweight scroll-position scrollspy (not a
|
|
4205
|
+
// full IntersectionObserver - the settings scroll region is small/short-lived
|
|
4206
|
+
// enough that a debounced scroll-position check is simpler and avoids the
|
|
4207
|
+
// observer-lifecycle bookkeeping) keeps state.settingsSection (and the URL)
|
|
4208
|
+
// honest while the user scrolls, registered once like the WS/session-expired
|
|
4209
|
+
// listeners above.
|
|
4210
|
+
const debouncedSettingsScrollSpy = debounce(() => {
|
|
4211
|
+
if (state.tab !== 'settings') return;
|
|
4212
|
+
const region = document.querySelector('#agentgui-main');
|
|
4213
|
+
if (!region) return;
|
|
4214
|
+
const regionTop = region.getBoundingClientRect().top;
|
|
4215
|
+
let current = null;
|
|
4216
|
+
for (const id of SETTINGS_SECTION_IDS) {
|
|
4217
|
+
const el = document.getElementById(id);
|
|
4218
|
+
if (!el) continue;
|
|
4219
|
+
// The section whose top has scrolled past the region's own top edge
|
|
4220
|
+
// (with a little slack for the sticky header) is the "current" one -
|
|
4221
|
+
// same heuristic every scrollspy implementation uses.
|
|
4222
|
+
if (el.getBoundingClientRect().top - regionTop <= 80) current = id;
|
|
4223
|
+
}
|
|
4224
|
+
if (current && current !== state.settingsSection) {
|
|
4225
|
+
state.settingsSection = current;
|
|
4226
|
+
writeHash({ push: false }); // passive sync, not a Back-able step - matches search text/filter's replaceState treatment
|
|
4227
|
+
}
|
|
4228
|
+
}, 150);
|
|
4229
|
+
let settingsScrollSpyRegistered = false;
|
|
4230
|
+
function registerSettingsScrollSpyOnce() {
|
|
4231
|
+
if (settingsScrollSpyRegistered) return;
|
|
4232
|
+
settingsScrollSpyRegistered = true;
|
|
4233
|
+
document.addEventListener('scroll', (e) => {
|
|
4234
|
+
if (e.target && e.target.id === 'agentgui-main') debouncedSettingsScrollSpy();
|
|
4235
|
+
}, true); // capture: #agentgui-main itself is the scrolling element, not a bubling target
|
|
4236
|
+
}
|
|
4076
4237
|
|
|
4077
4238
|
// Browser Back/forward: diff the FULL hash param set against state and re-sync
|
|
4078
4239
|
// each piece. Everything here runs with writeHash:false / fromHash:true so the
|
|
@@ -4085,7 +4246,11 @@ window.addEventListener('popstate', () => {
|
|
|
4085
4246
|
// anything else - or a bare sid - opens it in history).
|
|
4086
4247
|
if (hp.sid && hp.sid !== state.selectedSid) {
|
|
4087
4248
|
if (tab === 'chat') resumeInChat({ sid: hp.sid }, { fromHash: true });
|
|
4088
|
-
else
|
|
4249
|
+
else {
|
|
4250
|
+
const popFocusTs = hp.ets != null ? Number(hp.ets) : null;
|
|
4251
|
+
state._focusEventTs = (popFocusTs != null && !Number.isNaN(popFocusTs)) ? popFocusTs : null;
|
|
4252
|
+
loadSession(hp.sid, { fromHash: true, focusEventTs: (popFocusTs != null && !Number.isNaN(popFocusTs)) ? popFocusTs : undefined });
|
|
4253
|
+
}
|
|
4089
4254
|
} else if (!hp.sid && state.selectedSid && tab === 'history') {
|
|
4090
4255
|
state.selectedSid = null;
|
|
4091
4256
|
state.events = [];
|
|
@@ -2686,6 +2686,17 @@
|
|
|
2686
2686
|
@media (hover: none), (pointer: coarse) {
|
|
2687
2687
|
.ds-247420 .ds-modal-input { font-size: 16px; min-height: 44px; }
|
|
2688
2688
|
}
|
|
2689
|
+
/* PromptDialog's optional roots-chip row (destination-path prompts with more
|
|
2690
|
+
than one allowed root) - same chip idiom the cwd editor's roots row uses. */
|
|
2691
|
+
.ds-247420 .ds-prompt-roots { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-top: 8px; }
|
|
2692
|
+
.ds-247420 .ds-prompt-root-chip {
|
|
2693
|
+
background: var(--bg-2); border: var(--bw-hair) solid var(--rule); color: var(--fg-2);
|
|
2694
|
+
border-radius: var(--r-pill); padding: 3px 10px; font: inherit; font-size: var(--fs-tiny);
|
|
2695
|
+
cursor: pointer;
|
|
2696
|
+
}
|
|
2697
|
+
.ds-247420 .ds-prompt-root-chip:hover { border-color: var(--accent); color: var(--fg); }
|
|
2698
|
+
.ds-247420 .ds-prompt-root-chip:focus-visible { outline: var(--focus-w) solid var(--focus-color); outline-offset: var(--focus-offset); }
|
|
2699
|
+
@media (pointer: coarse) { .ds-247420 .ds-prompt-root-chip { min-height: 44px; } }
|
|
2689
2700
|
/* In-body modal error (role=alert): mutation failures (409/403) surface INSIDE
|
|
2690
2701
|
the dialog, not behind the fixed overlay. */
|
|
2691
2702
|
.ds-247420 .ds-modal-error {
|