agentgui 1.0.1060 → 1.0.1061

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,33 @@
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
@@ -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.1061",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "electron/main.js",
@@ -1665,6 +1665,21 @@ function filesMain() {
1665
1665
  // stop-selected against sessions that no longer exist.
1666
1666
  const LIVE_PREFS_KEY = 'agentgui.live';
1667
1667
  const FILES_PREFS_KEY = 'agentgui.files';
1668
+ // Recent-cwd MRU: a small, most-recently-used list of working directories
1669
+ // actually saved, so switching between a regular handful of projects needs no
1670
+ // re-typing/re-browsing. Capped small - this is a quick-pick convenience, not
1671
+ // a full history (that's what the Files tab + browse popover are for).
1672
+ const CWD_RECENT_KEY = 'agentgui.cwd.recent';
1673
+ const CWD_RECENT_CAP = 6;
1674
+ function loadRecentCwds() {
1675
+ try { const v = JSON.parse(lsGet(CWD_RECENT_KEY) || '[]'); return Array.isArray(v) ? v : []; } catch { return []; }
1676
+ }
1677
+ function pushRecentCwd(path) {
1678
+ if (!path) return;
1679
+ const list = loadRecentCwds().filter((p) => p !== path);
1680
+ list.unshift(path);
1681
+ lsSet(CWD_RECENT_KEY, JSON.stringify(list.slice(0, CWD_RECENT_CAP)));
1682
+ }
1668
1683
  function persistLivePrefs() {
1669
1684
  lsSet(LIVE_PREFS_KEY, JSON.stringify({ sort: state.live.sort || 'status', errorsOnly: !!state.live.errorsOnly, filter: state.live.filter || '' }));
1670
1685
  if (state.tab === 'live') writeHash();
@@ -2296,6 +2311,16 @@ function chatMain() {
2296
2311
  cwdDraft: state.cwdDraft,
2297
2312
  cwdError: state.cwdError || null,
2298
2313
  cwdChecking: !!state.cwdChecking,
2314
+ // Practicality upgrade: allowed roots (one-click starting points, always
2315
+ // visible instead of buried in the Files tab's own picker), a small
2316
+ // recent-cwd MRU (one-click switch between the handful of directories a
2317
+ // user actually works in), and an inline browse popover (click through
2318
+ // subdirectories right here instead of round-tripping through Files).
2319
+ cwdRoots: (Array.isArray(state.files.roots) && state.files.roots.length)
2320
+ ? state.files.roots.map((r) => ({ path: r, label: truncate(projectLabel(r) || r, 14, 24) }))
2321
+ : undefined,
2322
+ cwdRecent: loadRecentCwds().filter((p) => p !== state.chatCwd),
2323
+ cwdBrowse: state.cwdBrowse || undefined,
2299
2324
  // Pasted images upload through the same confined endpoint the Files tab
2300
2325
  // uses, into the chat's current cwd, then insert the resulting relative
2301
2326
  // path into the draft - matching the desktop-composer expectation
@@ -2332,14 +2357,35 @@ function chatMain() {
2332
2357
  if (was !== now) render();
2333
2358
  },
2334
2359
  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(); }); },
2360
+ onCwdEdit: () => {
2361
+ state.cwdEditing = true; state.cwdDraft = state.chatCwd || ''; state.cwdError = null; render();
2362
+ requestAnimationFrame(() => { const inp = document.querySelector('.agentchat-cwd-input'); if (inp) inp.focus(); });
2363
+ // A fresh user has no way to discover what's even browsable if roots
2364
+ // are only fetched on the first "browse" click - prefetch them the
2365
+ // moment the editor opens (root-only listDir call, cheap) so the
2366
+ // always-visible roots row is populated on the very first open.
2367
+ if (!(state.files.roots && state.files.roots.length)) {
2368
+ B.listDir(state.backend, '').then((j) => { state.files.roots = j.roots || []; render(); }).catch(() => {});
2369
+ }
2370
+ },
2371
+ onCwdBrowseToggle: () => toggleCwdBrowse(),
2372
+ onCwdBrowseCrumb: (i) => cwdBrowseCrumb(i),
2373
+ onCwdBrowseEnter: (path) => loadCwdBrowseDir(path),
2374
+ onCwdBrowsePick: (path) => cwdBrowsePick(path),
2336
2375
  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';
2376
+ let path = (state.cwdDraft ?? '').trim();
2377
+ const isAbsolute = /^([/\\]|[A-Za-z]:[/\\])/.test(path);
2378
+ // A bare relative subpath (no leading / or drive letter) resolves
2379
+ // against the CURRENT chat cwd, not the server process dir - lets a
2380
+ // power user type a known subfolder name instead of the full path or
2381
+ // opening the browse popover. Only meaningful when a cwd is already
2382
+ // set; with no current cwd there's no sensible base to resolve against.
2383
+ if (path && !isAbsolute && state.chatCwd) {
2384
+ const base = state.chatCwd.replace(/[/\\]+$/, '');
2385
+ const sep = state.chatCwd.includes('\\') && !state.chatCwd.includes('/') ? '\\' : '/';
2386
+ path = base + sep + path.replace(/^\.[/\\]/, '');
2387
+ } else if (path && !isAbsolute) {
2388
+ 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
2389
  render();
2344
2390
  return;
2345
2391
  }
@@ -2350,7 +2396,7 @@ function chatMain() {
2350
2396
  if (!st || st.ok === false) { state.cwdError = 'directory not found on the server: ' + path; render(); return; }
2351
2397
  if (!st.dir) { state.cwdError = 'that path is not a directory'; render(); return; }
2352
2398
  } catch (e) {
2353
- state.cwdError = e.status === 403 ? 'outside the accessible folders'
2399
+ state.cwdError = e.status === 403 ? 'outside the accessible folders - use "browse…" or a "recent"/root chip above to pick a reachable one'
2354
2400
  : (e.status === 404 ? 'directory not found on the server: ' + path
2355
2401
  : (e.status ? 'directory not found on the server: ' + path : 'could not validate the path - server unreachable'));
2356
2402
  render();
@@ -2359,16 +2405,66 @@ function chatMain() {
2359
2405
  }
2360
2406
  state.cwdError = null;
2361
2407
  state.chatCwd = path;
2362
- if (state.chatCwd) lsSet('agentgui.cwd', state.chatCwd); else lsRemove('agentgui.cwd');
2363
- state.cwdEditing = false; state.cwdDraft = undefined; render();
2408
+ if (state.chatCwd) { lsSet('agentgui.cwd', state.chatCwd); pushRecentCwd(state.chatCwd); } else lsRemove('agentgui.cwd');
2409
+ state.cwdEditing = false; state.cwdDraft = undefined; state.cwdBrowse = null; render();
2364
2410
  },
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(); }); },
2411
+ 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
2412
  onCwdClear: () => { state.chatCwd = ''; lsRemove('agentgui.cwd'); render(); },
2367
2413
  onCwdDraft: (v) => { state.cwdDraft = v; state.cwdError = null; debouncedCwdProbe(); },
2368
2414
  }),
2369
2415
  ].filter(Boolean);
2370
2416
  }
2371
2417
 
2418
+ // Inline cwd-browse popover: a lightweight directory-only listing reusing the
2419
+ // same confined B.listDir the Files tab uses, but writing to its own
2420
+ // state.cwdBrowse rather than state.files - opening/closing the cwd browser
2421
+ // must never disturb whatever the Files tab currently has loaded, and vice
2422
+ // versa (independent request-id guards against cross-talk if both are open).
2423
+ let _cwdBrowseReqId = 0;
2424
+ async function loadCwdBrowseDir(dirPath) {
2425
+ const myReq = (_cwdBrowseReqId += 1);
2426
+ state.cwdBrowse = { ...(state.cwdBrowse || {}), loading: true };
2427
+ render();
2428
+ try {
2429
+ const j = await B.listDir(state.backend, dirPath || '');
2430
+ if (_cwdBrowseReqId !== myReq) return;
2431
+ state.cwdBrowse = {
2432
+ current: j.path,
2433
+ segments: j.segments || [],
2434
+ rootLabel: (state.files.roots && state.files.roots.length > 1) ? 'roots' : 'root',
2435
+ // Directory-only listing: the cwd picker is for choosing a folder to run
2436
+ // in, not for browsing/opening files - filter out non-directory entries.
2437
+ entries: (j.entries || []).filter((e) => e.type === 'dir' || e.isDir || e.dir),
2438
+ loading: false,
2439
+ };
2440
+ render();
2441
+ } catch (e) {
2442
+ if (_cwdBrowseReqId !== myReq) return;
2443
+ state.cwdBrowse = { ...(state.cwdBrowse || {}), loading: false, entries: [] };
2444
+ render();
2445
+ }
2446
+ }
2447
+ function toggleCwdBrowse() {
2448
+ if (state.cwdBrowse) { state.cwdBrowse = null; render(); return; }
2449
+ loadCwdBrowseDir(state.cwdDraft || state.chatCwd || '');
2450
+ }
2451
+ function cwdBrowseCrumb(segIdx) {
2452
+ if (!state.cwdBrowse || !state.cwdBrowse.segments) return;
2453
+ const target = segIdx === 0 ? '' : state.cwdBrowse.segments.slice(0, segIdx).join('/');
2454
+ loadCwdBrowseDir(target);
2455
+ }
2456
+ // "use this folder" commits the browse popover's CURRENT directory into the
2457
+ // draft input (leaving the popover open state cleared) so the user still
2458
+ // confirms via the normal save flow (which re-validates + persists to recent).
2459
+ function cwdBrowsePick(path) {
2460
+ if (!path) return;
2461
+ state.cwdDraft = path;
2462
+ state.cwdError = null;
2463
+ state.cwdBrowse = null;
2464
+ debouncedCwdProbe();
2465
+ render();
2466
+ }
2467
+
2372
2468
  function offlineBanner() {
2373
2469
  if (state.health.status === 'ok' || state.health.status === 'unknown') return null;
2374
2470
  return Alert({ key: 'offline', kind: 'error', title: 'Backend unreachable',
@@ -2805,19 +2901,31 @@ async function sendChat(textArg) {
2805
2901
  // Validate the cwd draft while editing (debounced) so an invalid path reads as
2806
2902
  // invalid before the save click, via the existing confined /api/stat endpoint.
2807
2903
  const debouncedCwdProbe = debounce(async () => {
2808
- const path = (state.cwdDraft ?? '').trim();
2904
+ const raw = (state.cwdDraft ?? '').trim();
2809
2905
  if (!state.cwdEditing) return;
2810
- if (!path || !/^([/\\]|[A-Za-z]:[/\\])/.test(path)) { state.cwdChecking = false; render(); return; }
2906
+ const isAbsolute = /^([/\\]|[A-Za-z]:[/\\])/.test(raw);
2907
+ let path = raw;
2908
+ if (raw && !isAbsolute && state.chatCwd) {
2909
+ // Mirror onCwdSave's relative-subpath resolution so the live-typing hint
2910
+ // reflects the same interpretation the save button will actually apply.
2911
+ const base = state.chatCwd.replace(/[/\\]+$/, '');
2912
+ const sep = state.chatCwd.includes('\\') && !state.chatCwd.includes('/') ? '\\' : '/';
2913
+ path = base + sep + raw.replace(/^\.[/\\]/, '');
2914
+ } else if (raw && !isAbsolute) {
2915
+ state.cwdChecking = false; render(); return; // no cwd to resolve against yet - onCwdSave surfaces the error on save
2916
+ } else if (!raw) {
2917
+ state.cwdChecking = false; render(); return;
2918
+ }
2811
2919
  state.cwdChecking = true; render();
2812
- const probed = path;
2920
+ const probed = raw; // compare against the RAW (unresolved) draft to detect staleness
2813
2921
  try {
2814
- const st = await B.statPath(state.backend, probed);
2922
+ const st = await B.statPath(state.backend, path); // query the RESOLVED absolute path
2815
2923
  if ((state.cwdDraft ?? '').trim() !== probed) return; // draft moved on
2816
2924
  state.cwdError = (!st || st.ok === false) ? 'folder not found on the server'
2817
2925
  : (!st.dir ? 'that path is not a directory' : null);
2818
2926
  } catch (e) {
2819
2927
  if ((state.cwdDraft ?? '').trim() !== probed) return;
2820
- state.cwdError = e.status === 403 ? 'outside the accessible folders'
2928
+ state.cwdError = e.status === 403 ? 'outside the accessible folders - try "browse…" or a chip above'
2821
2929
  : (e.status === 404 ? 'folder not found on the server' : null);
2822
2930
  }
2823
2931
  state.cwdChecking = false;
@@ -2966,7 +3074,9 @@ function historyMain() {
2966
3074
  }, { turns: 0, tools: 0, errors: 0 });
2967
3075
  const meta = SessionMeta({
2968
3076
  items: [
2969
- sess && sess.cwd ? { label: 'directory', value: sess.cwd, title: sess.cwd } : null,
3077
+ sess && sess.cwd ? { label: 'directory', value: sess.cwd, title: sess.cwd,
3078
+ actionLabel: 'use as chat cwd',
3079
+ onAction: () => { state.chatCwd = sess.cwd; lsSet('agentgui.cwd', sess.cwd); pushRecentCwd(sess.cwd); announce('working directory set to ' + sess.cwd); render(); } } : null,
2970
3080
  (() => { const dur = sessionDuration(); return dur ? { label: 'duration', value: dur } : null; })(),
2971
3081
  { label: 'session id', value: state.selectedSid.slice(0, 8) + '…', title: state.selectedSid, onCopy: () => copyText(state.selectedSid, 'session id copied') },
2972
3082
  // Spelled counter vocabulary in the detail strip (events/turns/tools/
@@ -4041,8 +4151,16 @@ window.addEventListener('keydown', (e) => {
4041
4151
  }
4042
4152
  if (e.metaKey || e.ctrlKey || e.altKey) return;
4043
4153
  if (typing) {
4044
- if (e.key === 'Escape') t.blur();
4045
- return;
4154
+ // The cwd editor's own text input is the one exception: Escape there must
4155
+ // close the editor in a single press (matching every other Escape-
4156
+ // closeable surface in the app), not blur-then-require-a-second-press.
4157
+ if (e.key === 'Escape' && t.classList && t.classList.contains('agentchat-cwd-input')) {
4158
+ t.blur();
4159
+ // Fall through to the ladder below instead of returning early.
4160
+ } else {
4161
+ if (e.key === 'Escape') t.blur();
4162
+ return;
4163
+ }
4046
4164
  }
4047
4165
  if (e.key === 'Escape') {
4048
4166
  // Priority ladder for transient state (modals/drawers are kit-handled):
@@ -4052,6 +4170,17 @@ window.addEventListener('keydown', (e) => {
4052
4170
  // backdrop listener covers in-dialog focus; this covers everything else).
4053
4171
  if (state.files.dialog) { if (!state.files.dialog.busy) closeFileDialog(); return; }
4054
4172
  if (state.chat.confirmingEdit) { state.chat.confirmingEdit = null; render(); announce('edit cancelled'); return; }
4173
+ // cwd editor: the browse popover is a nested layer within it - Escape
4174
+ // closes the popover first (one level of the nesting) before falling
4175
+ // through to closing the whole editor on a second press, matching the
4176
+ // dialog-then-page Escape convention used elsewhere in the app.
4177
+ if (state.cwdEditing) {
4178
+ if (state.cwdBrowse) { state.cwdBrowse = null; render(); announce('folder browser closed'); return; }
4179
+ state.cwdEditing = false; state.cwdDraft = undefined; state.cwdError = null; state.cwdChecking = false;
4180
+ render(); announce('cwd edit cancelled');
4181
+ requestAnimationFrame(() => { const btn = document.querySelector('.agentchat-cwd-btn'); if (btn) btn.focus(); });
4182
+ return;
4183
+ }
4055
4184
  if (state.confirmingClearData) { state.confirmingClearData = false; render(); announce('clear cancelled'); return; }
4056
4185
  if (state.confirmingNewChat) { clearTimeout(_newChatArmTimer); state.confirmingNewChat = false; render(); announce('new chat cancelled'); return; }
4057
4186
  if (state.live.confirmingStopAll || state.live.confirmingStopSelected) {
@@ -91,8 +91,16 @@ export async function probeBackend(base) {
91
91
  // An empty path lands on the server's first allow-root. The server confines
92
92
  // every path to an allowlist root, so a 403 means the path is out of bounds.
93
93
  export async function listDir(base, dirPath) {
94
- const seg = dirPath ? '/' + encodeURIComponent(dirPath) : '';
95
- const r = await authedFetch(base + '/api/list' + seg);
94
+ // A path SEGMENT (encoded as %2F for its own slashes) is silently mangled by
95
+ // some reverse-proxy configs that normalize proxy_pass URIs - a nginx
96
+ // decode-then-reencode of the request path collapses %2F back into a literal
97
+ // slash before forwarding, so the app receives extra path segments instead
98
+ // of one opaque one, loses its leading slash on strip, and 403s every
99
+ // absolute-path listing that isn't the bare root. A query param is never
100
+ // subject to that URI-path normalization, so it survives any proxy in front
101
+ // of this app untouched.
102
+ const qs = dirPath ? '?dir=' + encodeURIComponent(dirPath) : '';
103
+ const r = await authedFetch(base + '/api/list' + qs);
96
104
  const j = await r.json().catch(() => ({}));
97
105
  if (!r.ok) {
98
106
  // Carry the HTTP status so the app can map 403/404 to plain copy (W9).
@@ -108,7 +116,10 @@ export async function listDir(base, dirPath) {
108
116
  // a 403 means out-of-bounds or unsupported type, 404 missing. Returns
109
117
  // { content, truncated }.
110
118
  export async function readFile(base, filePath) {
111
- const r = await authedFetch(base + '/api/file/' + encodeURIComponent(filePath));
119
+ // Query param, not a path segment - see listDir()'s comment above for why:
120
+ // a reverse proxy's URI normalization can silently collapse an encoded %2F
121
+ // path-segment slash back into a literal slash before forwarding.
122
+ const r = await authedFetch(base + '/api/file?path=' + encodeURIComponent(filePath));
112
123
  if (!r.ok) {
113
124
  const t = await r.text().catch(() => '');
114
125
  throw new Error(t || ('file: ' + r.status));
@@ -119,13 +130,13 @@ export async function readFile(base, filePath) {
119
130
 
120
131
  // Same-origin URL for an image file (served by /api/image, token-threaded).
121
132
  export function imageUrl(base, filePath) {
122
- return withToken(base + '/api/image/' + encodeURIComponent(filePath));
133
+ return withToken(base + '/api/image?path=' + encodeURIComponent(filePath));
123
134
  }
124
135
 
125
136
  // Same-origin download URL (served by /api/download, attachment disposition,
126
137
  // confined + token-threaded). Used by the Files row download action.
127
138
  export function downloadUrl(base, filePath) {
128
- return withToken(base + '/api/download/' + encodeURIComponent(filePath));
139
+ return withToken(base + '/api/download?path=' + encodeURIComponent(filePath));
129
140
  }
130
141
 
131
142
  // ---------- File mutations (confined server endpoints) ----------
@@ -143,10 +154,13 @@ async function mutateJSON(base, route, body) {
143
154
  return j;
144
155
  }
145
156
 
146
- // Stat a path (GET /api/stat/<encoded>) for cwd validation. Returns {ok, dir}.
157
+ // Stat a path (GET /api/stat?path=<encoded>) for cwd validation. Returns {ok, dir}.
147
158
  // Throws an Error carrying .status (403 outside roots, 404 missing).
148
159
  export async function statPath(base, p) {
149
- const r = await authedFetch(base + '/api/stat/' + encodeURIComponent(p));
160
+ // Query param, not a path segment - see the comment in listDir() above for
161
+ // why: a reverse proxy's URI normalization can silently collapse an encoded
162
+ // %2F path-segment slash back into a literal slash before forwarding.
163
+ const r = await authedFetch(base + '/api/stat?path=' + encodeURIComponent(p));
150
164
  const j = await r.json().catch(() => ({}));
151
165
  if (!r.ok) { const e = new Error(j.error || ('stat: ' + r.status)); e.status = r.status; throw e; }
152
166
  return j;
@@ -60,8 +60,14 @@
60
60
  --sky: #3A6EFF;
61
61
  /* --warn = interactive danger controls (buttons, banners, menus, error
62
62
  inputs); --danger below = passive error/status text and metrics
63
- (kpi/sparkline deltas, auth-error text). Two tokens, one convention. */
64
- --warn: #E0241A;
63
+ (kpi/sparkline deltas, auth-error text). Two tokens, one convention.
64
+ #E0241A measured 4.19:1 on --paper — fails WCAG AA 4.5:1 for the many
65
+ TEXT usages (.field-error, .ds-btn-warn, .ds-stat-val.err-rate, toast/
66
+ session error labels), not just the interactive-control (>=3:1) usages.
67
+ Darkened to clear AA on --paper (5.29:1), --paper-2 (4.78:1), and the
68
+ thebird warm-paper preset (5.25:1) while staying visually a clear
69
+ warm red. */
70
+ --warn: #C41C0C;
65
71
  /* Syntax-token aliases: light values pass 4.5:1 on paper; the dark blocks
66
72
  restore the bright dark-tuned originals. Components reference ONLY these. */
67
73
  --code-string: var(--green);
@@ -477,7 +483,7 @@
477
483
  --accent: var(--acid); --accent-fg: var(--ink); --accent-ink: var(--acid-deep);
478
484
  --accent-tint: color-mix(in oklab, var(--accent-ink) 22%, var(--bg));
479
485
  /* A paper island under a dark ancestor must not inherit the dark signal pair. */
480
- --flame: #C53E00; --amber: #7C570F; --warn: #E0241A; --sky: #3A6EFF;
486
+ --flame: #C53E00; --amber: #7C570F; --warn: #C41C0C; --sky: #3A6EFF;
481
487
  }
482
488
 
483
489
  /* thebird — warm-paper brand preset. A first-class swappable theme: it lives
@@ -951,6 +957,22 @@
951
957
  .ds-247420 .app-main > .ds-files-stack,
952
958
  .ds-247420 .app-main > .grow { flex: 1 1 auto; min-height: 0; }
953
959
  .ds-247420 .app-main.narrow { max-width: var(--measure-narrow); margin: 0 auto; }
960
+ /* Article-body heading rhythm and lede spacing, consumed by flatspace-rendered
961
+ long-form pages (docs, papers) that drop headings/panels directly into .app-main. */
962
+ .ds-247420 .app-main > h1,
963
+ .ds-247420 .app-main > h2,
964
+ .ds-247420 .app-main > h3 { margin-top: 36px; margin-bottom: 12px; }
965
+ .ds-247420 .app-main > h1:first-child { margin-top: 8px; }
966
+ .ds-247420 .app-main > .panel,
967
+ .ds-247420 .app-main > .work-detail-chips,
968
+ .ds-247420 .app-main > .cli { margin-top: 18px; margin-bottom: 18px; }
969
+ .ds-247420 .app-main .ds-lede { margin-top: 4px; margin-bottom: 18px; max-width: 64ch; line-height: 1.6; }
970
+ .ds-247420 .work-detail-chips { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; row-gap: 10px; }
971
+ @media (max-width: 720px) {
972
+ .ds-247420 .app-main > h1,
973
+ .ds-247420 .app-main > h2,
974
+ .ds-247420 .app-main > h3 { margin-top: 28px; }
975
+ }
954
976
 
955
977
  @media (min-width: 1400px) {
956
978
  /* Fluid ceiling, not a hard stop: scales from 1400px up to 1920px across the
@@ -7077,6 +7099,37 @@
7077
7099
  .ds-247420 .agentchat-cwd-input[aria-invalid='true'] { border-color: var(--flame); box-shadow: inset 0 0 0 var(--bw-hair) var(--flame); }
7078
7100
  .ds-247420 .agentchat-cwd-input[aria-busy='true'] { border-color: var(--fg-3); box-shadow: inset 0 0 0 var(--bw-hair) var(--fg-3); }
7079
7101
 
7102
+ /* cwd practicality upgrade: editing mode stacks the input row plus any
7103
+ optional roots/recent/browse rows the host wires, instead of a single flat
7104
+ row - each sub-row wraps independently at narrow widths. */
7105
+ .ds-247420 .agentchat-cwd-editing { flex-direction: column; align-items: stretch; }
7106
+ .ds-247420 .agentchat-cwd-row { display: flex; align-items: center; gap: var(--space-2, 8px); flex-wrap: wrap; }
7107
+ .ds-247420 .agentchat-cwd-roots, .ds-247420 .agentchat-cwd-recent {
7108
+ display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
7109
+ }
7110
+ .ds-247420 .agentchat-cwd-recent-label { font-size: var(--fs-tiny); color: var(--fg-3); }
7111
+ .ds-247420 .agentchat-cwd-chip {
7112
+ background: var(--bg-2); border: var(--bw-hair) solid var(--rule); color: var(--fg-2);
7113
+ border-radius: var(--r-pill); padding: 3px 10px; font: inherit; font-size: var(--fs-tiny);
7114
+ cursor: pointer;
7115
+ }
7116
+ .ds-247420 .agentchat-cwd-chip:hover { border-color: var(--accent); color: var(--fg); }
7117
+ .ds-247420 .agentchat-cwd-chip:focus-visible { outline: var(--focus-w) solid var(--focus-color); outline-offset: var(--focus-offset); }
7118
+ .ds-247420 .agentchat-cwd-browse {
7119
+ border: var(--bw-hair) solid var(--rule); border-radius: var(--r-1);
7120
+ padding: var(--space-2, 8px); background: var(--bg-2);
7121
+ display: flex; flex-direction: column; gap: 6px;
7122
+ max-height: 220px;
7123
+ }
7124
+ .ds-247420 .agentchat-cwd-browse-list { overflow-y: auto; display: flex; flex-direction: column; max-height: 160px; }
7125
+ .ds-247420 .agentchat-cwd-browse-item {
7126
+ background: none; border: none; color: var(--fg-2); text-align: left;
7127
+ padding: 6px 8px; border-radius: var(--r-1); font: inherit; font-size: var(--fs-tiny);
7128
+ cursor: pointer;
7129
+ }
7130
+ .ds-247420 .agentchat-cwd-browse-item:hover, .ds-247420 .agentchat-cwd-browse-item:focus-visible { background: var(--bg-3, var(--bg)); color: var(--fg); }
7131
+ .ds-247420 .agentchat-cwd-browse-loading, .ds-247420 .agentchat-cwd-browse-empty { padding: 6px 8px; font-size: var(--fs-tiny); color: var(--fg-3); }
7132
+
7080
7133
  /* head + thread */
7081
7134
  .ds-247420 .agentchat-head {
7082
7135
  display: flex;
@@ -7277,7 +7330,7 @@
7277
7330
  }
7278
7331
  /* Coarse-pointer (touch): raise cwd button to 44px minimum tap target */
7279
7332
  @media (pointer: coarse) {
7280
- .ds-247420 .agentchat-cwd-btn { min-height: 44px; }
7333
+ .ds-247420 .agentchat-cwd-btn, .ds-247420 .agentchat-cwd-chip, .ds-247420 .agentchat-cwd-browse-item { min-height: 44px; }
7281
7334
  }
7282
7335
 
7283
7336
  /* ----------------------------------------------------------------------------
@@ -7363,7 +7416,7 @@
7363
7416
  label sticks to the top of the scroll area for Claude-Desktop-style headers. */
7364
7417
  .ds-247420 .ds-session-group { display: flex; flex-direction: column; }
7365
7418
  .ds-247420 .ds-session-group-rows { display: flex; flex-direction: column; gap: 2px; }
7366
- .ds-247420 .ds-session-group-label { position: sticky; top: 0; z-index: 1; background: var(--bg-1, var(--bg)); font-size: var(--fs-tiny); font-weight: 600; color: var(--fg-3); text-transform: uppercase; letter-spacing: var(--tr-caps); padding: var(--space-2) var(--space-2) var(--space-1); }
7419
+ .ds-247420 .ds-session-group-label { position: sticky; top: 0; z-index: 1; background: var(--bg-1, var(--bg)); font-size: var(--fs-tiny); font-weight: 600; color: var(--fg-3); text-transform: uppercase; letter-spacing: var(--tr-label); padding: var(--space-2) var(--space-2) var(--space-1); }
7367
7420
  .ds-247420 .ds-session-row {
7368
7421
  position: relative; display: flex; align-items: center; gap: var(--space-2);
7369
7422
  width: 100%; padding: var(--space-2); min-height: 52px; margin-bottom: 2px;
@@ -7660,15 +7713,15 @@
7660
7713
  .ds-247420 .ds-session-meta-item + .ds-session-meta-item::before { content: '\00B7'; color: var(--fg-3); margin-right: var(--space-1); }
7661
7714
  .ds-247420 .ds-session-meta-label { color: var(--fg-3); }
7662
7715
  .ds-247420 .ds-session-meta-value { font-family: var(--ff-mono); color: var(--fg-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
7663
- .ds-247420 .ds-session-meta-copy {
7716
+ .ds-247420 .ds-session-meta-copy, .ds-247420 .ds-session-meta-action {
7664
7717
  padding: 2px 8px; min-height: 24px; cursor: pointer;
7665
7718
  background: var(--bg-2); border: var(--bw-hair) solid var(--bg-3); border-radius: var(--r-1);
7666
7719
  color: var(--fg-3); font-family: var(--ff-body); font-size: var(--fs-micro);
7667
7720
  }
7668
- .ds-247420 .ds-session-meta-copy:hover { background: var(--bg-3); color: var(--fg); }
7669
- .ds-247420 .ds-session-meta-copy:focus-visible { outline: var(--focus-w) solid var(--focus-color); outline-offset: 1px; }
7721
+ .ds-247420 .ds-session-meta-copy:hover, .ds-247420 .ds-session-meta-action:hover { background: var(--bg-3); color: var(--fg); }
7722
+ .ds-247420 .ds-session-meta-copy:focus-visible, .ds-247420 .ds-session-meta-action:focus-visible { outline: var(--focus-w) solid var(--focus-color); outline-offset: 1px; }
7670
7723
  @media (pointer: coarse), (max-width: 640px) {
7671
- .ds-247420 .ds-session-meta-copy { min-height: 44px; min-width: 44px; }
7724
+ .ds-247420 .ds-session-meta-copy, .ds-247420 .ds-session-meta-action { min-height: 44px; min-width: 44px; }
7672
7725
  }
7673
7726
 
7674
7727
  /* ============================================================================
@@ -7917,7 +7970,7 @@
7917
7970
 
7918
7971
  /* Live command-center: status-bucket groups + header breakdown + heartbeat. */
7919
7972
  .ds-247420 .ds-dash-group { display: flex; flex-direction: column; gap: var(--space-2); }
7920
- .ds-247420 .ds-dash-group-label { font-size: var(--fs-tiny); font-weight: 600; text-transform: uppercase; letter-spacing: var(--tr-caps); color: var(--fg-3); padding: 0 var(--space-1); }
7973
+ .ds-247420 .ds-dash-group-label { font-size: var(--fs-tiny); font-weight: 600; text-transform: uppercase; letter-spacing: var(--tr-label); color: var(--fg-3); padding: 0 var(--space-1); }
7921
7974
  .ds-247420 .ds-dash-breakdown { display: flex; align-items: center; gap: var(--space-1); font-size: var(--fs-sm); color: var(--fg-2); }
7922
7975
  /* Shape-channel discs mirror the .status-dot-disc vocabulary (solid /
7923
7976
  solid+halo / hollow ring) so a colour-blind user can scan the breakdown by