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 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
@@ -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 rawS = routePath.split('?')[0].slice('/api/stat'.length).replace(/^\//, '');
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 rawQ = routePath.split('?')[0].slice('/api/list'.length).replace(/^\//, '');
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 rawF = routePath.split('?')[0].slice('/api/file/'.length);
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 rawD = routePath.split('?')[0].slice('/api/download/'.length);
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 imagePath = routePath.slice('/api/image/'.length);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.1060",
3
+ "version": "1.0.1062",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "electron/main.js",
@@ -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: () => { state.cwdEditing = true; state.cwdDraft = state.chatCwd || ''; state.cwdError = null; render(); requestAnimationFrame(() => { const inp = document.querySelector('.agentchat-cwd-input'); if (inp) inp.focus(); }); },
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
- const path = (state.cwdDraft ?? '').trim();
2338
- // A relative cwd would resolve against the server process dir, not what
2339
- // the user means - require an absolute path (POSIX /..., UNC \\..., or
2340
- // Windows drive X:\...). Blank is valid (server default).
2341
- if (path && !/^([/\\]|[A-Za-z]:[/\\])/.test(path)) {
2342
- state.cwdError = 'enter an absolute path (e.g. /home/you/proj or C:\\proj) or leave blank';
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: 'agentgui can\'t reach the server (' + (state.health.error || state.health.status) + '). Chat and history actions will fail until it reconnects.' });
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 path = (state.cwdDraft ?? '').trim();
2975
+ const raw = (state.cwdDraft ?? '').trim();
2809
2976
  if (!state.cwdEditing) return;
2810
- if (!path || !/^([/\\]|[A-Za-z]:[/\\])/.test(path)) { state.cwdChecking = false; render(); return; }
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 = path;
2991
+ const probed = raw; // compare against the RAW (unresolved) draft to detect staleness
2813
2992
  try {
2814
- const st = await B.statPath(state.backend, probed);
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 jumpToFirstError() {
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 } : null,
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
- if (e.key === 'Escape') t.blur();
4045
- return;
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) {