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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.1061",
3
+ "version": "1.0.1063",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "electron/main.js",
@@ -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 loadSession(s.sid, { focusEventI: s._focusEventI, focusEventTs: s._focusEventTs });
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: 'agentgui can\'t reach the server (' + (state.health.error || state.health.status) + '). Chat and history actions will fail until it reconnects.' });
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 jumpToFirstError() {
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 loadSession(hp.sid, { fromHash: true });
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 {