agentgui 1.0.961 → 1.0.963

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/AGENTS.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # AgentGUI — Agent Notes
2
2
 
3
+ ## Files command surface + PASSWORD-boot fix (2026-06-11) — eleventh run
4
+
5
+ Lands the 10th run's deferred batch + the janks a live witness surfaced on this env. **Kit path on this (linux) env is `../design` = `/config/workspace/design`** (same repo as `c:\dev\anentrypoint-design` on the windows env); update flow unchanged: edit kit -> `node scripts/build.mjs` -> copy `dist/247420.{js,css}` into `site/app/vendor/anentrypoint-design/` -> witness -> push both.
6
+
7
+ **CRITICAL fix - PASSWORD-gated deploys could never boot the SPA.** `/gm/?token=` served index.html but the static subresources (`gm/js/app.js`, `gm/vendor/247420.{js,css}`) carry no token -> 401 -> blank page forever (witnessed: `readyState` stuck `loading`, `hasAgentgui:false`). `lib/http-handler.js` now sets an HttpOnly SameSite=Lax `agentgui_token` cookie (URL-encoded) on any successful auth and the gate accepts it; WS keeps `?token=`. Lax + the CSRF guard keep cross-site mutations rejected (a cross-site form never carries the Lax cookie on POST).
8
+
9
+ **Files is a command surface.** Kit `FileGrid` gains multi-select (`selectable`/`marked` Set keyed by path/`onMark({range})` shift-ranges/`onSelectAll`/`onClearSelection`, tri-state `[x]/[-]/[ ]` header checkbox over SHOWN rows, EACCES rows unmarkable, Ctrl/Cmd+A in-grid) + density modes (`density` list/compact/thumb + `onDensity` radiogroup; thumb tiles via `FileCell` with lazy `thumbUrl` images) + new `BulkBar` export (`{count,noun,nounPlural,actions,onClear,busy}`, pluralizes `-y` nouns). App: `state.files.marked` (cleared on dir change, pruned on refresh), BulkBar -> armed `bulk-delete` ConfirmDialog -> `Promise.allSettled` over the confined delete (failures STAY selected + count in dialog), Escape clears selection (ladder slot after stop-arms), density persisted in `agentgui.files`. **thumbUrl uses `downloadUrl` (fsAllowRoots) NOT `imageUrl` - `/api/image` has its own narrower allowlist and 403s repo files; `<img>` ignores the attachment disposition.**
10
+
11
+ **Shipped-jank fixes the witness caught.** (1) `.ds-file-open` (run 5) never had CSS - FileRow's UA-styled button sat in a grid sized for the old flat children; row+open are now flex strips with a full button reset. (2) filesMain DROPPED `permissions` in its entry mapping - the EACCES/read-only affordances were dead on the files tab. (3) The Crumb narrow collapse keys on the `.app` @container which doesn't exist under WorkspaceShell -> `.crumb-right` overflowed 420px viewports (h-scroll); mirrored into the `.ws-*` 900px media block. (4) webjsx attr-diff can leave a null-valued attribute unset when toggling - `data-density` is now always concrete. (5) F3 elevation unification: `.panel` = bg-2 + hairline + r-2 (no `--panel-shadow`), seg-control is-on = inset ring, chat image/pdf/link hover lift shadows dropped; overlay shadows (modal, jump pill) stay.
12
+
13
+ **Env/tooling.** `scripts/validate-mutations.mjs` is now portable (ROOT from env/cwd, sep-aware joins, posix traversal targets, Bearer from PASSWORD; csrf case expects 401 under PASSWORD / 403 open) - 17/17 PASS on this env. Browser witnessing here: plugkit's chromium needs `--no-sandbox` (written to `/etc/chromium.d/no-sandbox`); `page.goto` waits on hung CDN subresources so use `waitUntil:'commit'` + `waitForSelector`; the witness body is shell-interpolated (NEVER use `$(...)`/backticks - use `page.locator(...)`); same-URL `goto` with only a hash change does NOT reload (use `page.reload()` after re-vendoring).
14
+
3
15
  ## GUI Claude-Code-web parity sweep (2026-06-11) — tenth maximum-effort run
4
16
 
5
17
  Hunts the VISUAL + LAYOUT + MOTION design-language parity gap vs claude.ai/code (the user verdict: too primitive, layout jank). New workflow `.claude/workflows/gui-claude-code-parity.js` (6 design-language lenses: visual-language, layout-composition, chat-thread-craft, file-browser-fidelity, live-session-command, motion-microinteraction; hunt -> adversarial verify (kept-typography guard) -> plan). 66 agents -> 37 confirmed gaps (`PUNCHLIST-PARITY.md`). Pushed: kit `anentrypoint-design` 28387c3, agentgui 0a93fc6.
@@ -38,21 +50,7 @@ Coverage run reversing the prior scope cuts. Workflow `.claude/workflows/gui-com
38
50
 
39
51
  ## GUI cohesion sweep (2026-06-10) — seventh maximum-effort run
40
52
 
41
- The GUI reads as ONE polished chat-agent product (claude.ai/code / cowork / Claude-Desktop), not a kit of parts. New workflow deliverable `.claude/workflows/gui-cohesion.js` 6 cohesion *lenses* (chat-agent-feel, fsbrowse-file-view, simultaneous-live-sessions, shell-navigation, visual-system, crosscut-perf-a11y-security) benchmark each surface against the real reference products, an adversarial verifier confirms each gap (kept-typography guard), a synthesizer orders them. Distinct from `gui-audit` (jank), `gui-overhaul` (new surfaces), `gui-predictability-polish` (primitive edges) — this one hunts the **product-cohesion/feel gap**. Ran 61 agents -> 40 confirmed gaps (`PUNCHLIST-COHESION.md`).
42
-
43
- **Chat reads like a chat agent.** Every fenced code block has a per-block copy button: kit `chat.js` `injectCodeCopy()` wraps each rendered-markdown `<pre>` in `.chat-code-block` and injects a reveal-on-hover `.chat-code-copy` (idempotent via `data-copy-wired`, label flips copy->copied); the structured `CodeNode` head and `FilePreviewCode` header carry the same control. `ChatMessage` per-message actions render a text label beside the icon and use a real `copy` icon (not `page`). Mid-stream prose containing a code fence renders as a monospaced `<pre>` shell (`agent-chat.js` sets `preShell`, `chat.js` text renderer honors it) so it does not reflow prose->block on settle. `ChatComposer` shows a `context` line (agent · model · cwd) at the point of typing; `AgentChat` threads a stable per-agent `avatar` and `followups` chips after a settled turn.
44
-
45
- **Files is an fsbrowse-grade browser.** Kit `files.js` `FileGrid` caps a large dir at `FILE_GRID_CAP=200` with a `shown`/`onShowMore` "show N more" row, uses `role=group` (NOT `listbox` — the rows host real action `<button>`s, which is invalid listbox/option semantics), `FileRow` shows a `permissions`/`locked` ASCII tag (`read-only`/`no access`) and disables open for EACCES, and a `RootsPicker` segmented control replaces the borrowed history `.pill`. `files-modals.js` ships `FilePreviewPane` (a non-modal split-view region for the shell pane) alongside the modal `FileViewer` (kept as the `<900px` fallback), both with an ASCII `prev`/`next` stepper (`onPrev`/`onNext` + ArrowLeft/Right). The app renders the preview INTO the WorkspaceShell pane when wide, modal only when narrow.
46
-
47
- **All live sessions, managed at once.** Kit `sessions.js` `SessionCard` gains a `stale` status (`idle` word + non-pulsing `.status-dot-stale`, so a stuck agent differs from a busy one), a `selectable` checkbox (`role=checkbox`), and an `active` accent. `SessionDashboard` gains a sort `Select` (status/elapsed/activity/errors) + filter `SearchInput` + errors-only toggle, `stop-selected` bulk control, and a `streamState` line (the dashboard is listening even at 0 running). App `liveMain` derives `stale` (no activity >45s and no running tool), floats error-then-stale to the front, and keeps a per-sid `state.live.tally` incremented straight off the SSE stream so a brand-new chat shows motion before it lands in the history index.
48
-
49
- **One shell, one visual system.** Kit `shell.js` `WorkspaceShell` `stableFrame` keeps the pane grid TRACK present (width 0) when a tab has no pane, so the column geometry does not re-flow across tabs. There is ONE canonical `.status-dot-disc` (`status-dot-live` pulses via `color-mix`, `-error`/`-connecting`/`-stale` static, reduced-motion guarded) — `index.html`'s `.status-dot`/`agentgui-pulse` override is deleted and the crumb dot uses the kit modifier classes. `.ds-session-row.rail-*` tones now match `.row.rail-*` (accent/purple-2/flame). The rail footer pins a `ThemeToggle`; page titles are sentence-case. Touch targets floor to 44px under `@media (pointer:coarse)`.
50
-
51
- **Server.** `lib/http-handler.js` `/api/list` reports a per-entry `permissions` (`['read','write']` | `'EACCES'`) via `fs.accessSync`. New confined `/api/download` (raw bytes, `confineToRoots` realpath, 50MB cap, attachment disposition) — verified to 403 both out-of-roots and traversal. `backend.js` `listDir` carries `r.status` so the app maps 403/404 to plain copy; `downloadUrl()` is token-threaded.
52
-
53
- **Scope cuts (documented in punch-list, not gaps).** File rename/delete (read-only browser, no mutation endpoints), upload/DropZone (legacy `routes-upload.js` is conversationId-keyed, not reusable), retry kept last-turn-only (mid-thread retry would destroy history).
54
-
55
- **Witness.** localhost:3000/gm/ 0 console/page errors across chat/files/live/settings/history; per-block code copy + preview pane (opens with prev/next stepper) + dashboard stream line render; whole-DOM decorative-glyph count 0 (kept middot/ellipsis/dash); no h-scroll; a11y skip-link/`role=group`/`role=checkbox`/44px verified. Kit `lint-tokens`+`lint-glyphs` pass on build. **The app.js `C` destructure MUST list every kit component it calls (`Icon`/`RootsPicker`/`FilePreviewPane` were missing — the browser witness caught `Icon is not defined`).** Update flow unchanged: edit kit -> `node scripts/build.mjs` -> copy `dist/247420.{js,css}` into `site/app/vendor/anentrypoint-design/` -> witness -> push kit (rebase + rebuild dist when remote advances).
53
+ One-product feel sweep (61 agents -> 40 gaps, `PUNCHLIST-COHESION.md`, workflow `.claude/workflows/gui-cohesion.js`): per-block code copy, FilePreviewPane split view, SessionDashboard sort/filter/stale/stop-selected, stableFrame, one canonical `.status-dot-disc`, confined `/api/download`, per-entry `permissions`. Full detail in rs-learn (recall "agentgui GUI cohesion sweep 7th run"). Standing rule: **the app.js `C` destructure MUST list every kit component it calls** (a witness caught `Icon is not defined`).
56
54
 
57
55
  ## GUI predictability + polish sweep (2026-06-05) — fifth maximum-effort run
58
56
 
@@ -248,3 +246,5 @@ GUI source keeps typographic product characters — the middot separator `·`, t
248
246
  ## DS load = LOCAL vendor (resolved — the earlier unpkg note is stale)
249
247
 
250
248
  **`index.html` loads the DS kit from the local `./vendor/anentrypoint-design/247420.{js,css}`, NOT unpkg.** The prior "moved to unpkg @latest" note described a transient state that was reverted: the kit was re-vendored (with `AgentChat`) in `eb1eab3` and witnessed loading from `./vendor/` again (2026-06-04 and 2026-06-05 runs). The vendored copy IS the shipped UI — predictable, no upstream-publish drift. Update flow: edit the kit at `c:\dev\anentrypoint-design`, `node scripts/build.mjs`, copy `dist/247420.{js,css}` into `site/app/vendor/anentrypoint-design/`, browser-witness, then push the kit so unpkg stays in sync for other consumers. (The markdown stack marked/dompurify/prismjs still fetches from jsdelivr on first chat render.)
249
+
250
+ @.gm/next-step.md
@@ -34,3 +34,9 @@ build.mjs -> copy dist into site/app/vendor -> witness localhost:3000/gm/ (0 err
34
34
 
35
35
  ## Descoped
36
36
  - `/api/move-confined` drag-to-move endpoint: checkbox + bulk-delete uses the existing delete loop; new endpoint cut unless drag-to-move lands.
37
+
38
+ ## Landed by the 11th run (2026-06-11)
39
+ - G-multiselect + BulkBar: kit FileGrid selectable/marked/onMark(range)/tri-state select-all + BulkBar export; app marked Set + armed bulk-delete via allSettled (failures stay selected); Escape clears; Ctrl/Cmd+A.
40
+ - G-density: list/compact/thumb radiogroup + FileCell thumbnail tiles (confined downloadUrl), persisted in agentgui.files.
41
+ - F3 elevation unification: .panel hairline+bg-2+r-2, no panel/card/hover drop-shadows; overlays keep theirs.
42
+ - Toolbar up-button + grid keys in SHORTCUTS: landed previously in 503f66e.
@@ -156,10 +156,29 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
156
156
  _ok = _checkToken(_auth.slice(7));
157
157
  }
158
158
  // EventSource and same-origin links can't set headers - accept ?token= as fallback.
159
+ let _viaQuery = false;
159
160
  if (!_ok) {
160
161
  try {
161
162
  const _qsTok = new URL(req.url, 'http://localhost').searchParams.get('token');
162
- if (_qsTok) _ok = _checkToken(_qsTok);
163
+ if (_qsTok) { _ok = _checkToken(_qsTok); _viaQuery = _ok; }
164
+ } catch (_) {}
165
+ }
166
+ // Static subresources (js/css/vendor) are requested by the BROWSER, which
167
+ // can attach neither a header nor a ?token= - without a cookie the SPA
168
+ // can never boot behind PASSWORD (index.html 200s, app.js 401s, blank
169
+ // page). On any successful auth, set an HttpOnly SameSite=Lax cookie the
170
+ // gate accepts for subsequent requests. Lax + the CSRF guard below keep
171
+ // cross-site mutations rejected; the value is URL-encoded (a password
172
+ // may carry cookie-hostile characters like commas).
173
+ if (!_ok) {
174
+ try {
175
+ const _ck = req.headers['cookie'] || '';
176
+ const _m = /(?:^|;\s*)agentgui_token=([^;]+)/.exec(_ck);
177
+ if (_m) _ok = _checkToken(decodeURIComponent(_m[1]));
178
+ } catch (_) {}
179
+ } else if (_viaQuery || _auth) {
180
+ try {
181
+ res.setHeader('Set-Cookie', 'agentgui_token=' + encodeURIComponent(_pwd) + '; Path=/; HttpOnly; SameSite=Lax');
163
182
  } catch (_) {}
164
183
  }
165
184
  if (!_ok) { res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="agentgui"' }); res.end('Unauthorized'); return; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.961",
3
+ "version": "1.0.963",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "electron/main.js",
@@ -1,7 +1,16 @@
1
1
  // Live security validation of the confined file-mutation endpoints.
2
2
  // Run with the server up: node scripts/validate-mutations.mjs
3
+ // Portable: ROOT defaults to this repo (an allowed root on both platforms);
4
+ // override with VALIDATE_ROOT. A PASSWORD-gated server is handled via the
5
+ // PASSWORD env var (Bearer), matching lib/http-handler.js.
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
3
8
  const BASE = process.env.BASE || 'http://localhost:3000';
4
- const J = { 'Content-Type': 'application/json' };
9
+ const ROOT = process.env.VALIDATE_ROOT || path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
10
+ const SEP = ROOT.includes('\\') ? '\\' : '/';
11
+ const WIN = SEP === '\\';
12
+ const AUTH = process.env.PASSWORD ? { Authorization: 'Bearer ' + process.env.PASSWORD } : {};
13
+ const J = { 'Content-Type': 'application/json', ...AUTH };
5
14
  const results = [];
6
15
  async function post(route, body, headers = J) {
7
16
  const r = await fetch(BASE + route, { method: 'POST', headers, body: typeof body === 'string' ? body : JSON.stringify(body) });
@@ -11,29 +20,35 @@ async function check(name, expected, actual) {
11
20
  const ok = actual.status === expected;
12
21
  results.push({ name, expected, got: actual.status, ok, body: actual.body.slice(0, 80) });
13
22
  }
14
- const ROOT = 'C:\\dev\\agentgui';
23
+ const join = (...parts) => parts.join(SEP);
24
+ const OUTSIDE = WIN ? 'C:\\Windows\\Temp\\x.txt' : '/etc/hostname';
25
+ const OUTSIDE_DIR = WIN ? 'C:\\Windows' : '/etc';
26
+ const TRAVERSAL = join(ROOT, '..', '..', ...(WIN ? ['Windows', 'win.ini'] : ['etc', 'passwd']));
15
27
  // security cases
16
- await check('rename-traversal', 403, await post('/api/rename', { path: ROOT + '\\..\\..\\Windows\\win.ini', newName: 'x.txt' }));
17
- await check('delete-out-of-roots', 403, await post('/api/delete', { path: 'C:\\Windows\\Temp\\x.txt' }));
28
+ await check('rename-traversal', 403, await post('/api/rename', { path: TRAVERSAL, newName: 'x.txt' }));
29
+ await check('delete-out-of-roots', 403, await post('/api/delete', { path: OUTSIDE }));
18
30
  await check('delete-root-refused', 403, await post('/api/delete', { path: ROOT }));
19
31
  await check('mkdir-reserved-name', 400, await post('/api/mkdir', { dir: ROOT, name: 'CON' }));
20
32
  await check('mkdir-name-with-separator', 400, await post('/api/mkdir', { dir: ROOT, name: 'a/b' }));
21
33
  await check('mkdir-name-trailing-dot', 400, await post('/api/mkdir', { dir: ROOT, name: 'evil.' }));
22
34
  await check('mkdir-ads-colon', 400, await post('/api/mkdir', { dir: ROOT, name: 'a:b' }));
23
- await check('csrf-cross-site-form', 403, await post('/api/delete', 'path=x', { 'Content-Type': 'application/x-www-form-urlencoded', 'Sec-Fetch-Site': 'cross-site' }));
35
+ // A real cross-site form carries no Authorization header (forms cannot set
36
+ // one). On a PASSWORD-gated server the auth wall rejects it first (401);
37
+ // open servers hit the CSRF guard (403). Either way the mutation is refused.
38
+ await check('csrf-cross-site-form', process.env.PASSWORD ? 401 : 403, await post('/api/delete', 'path=x', { 'Content-Type': 'application/x-www-form-urlencoded', 'Sec-Fetch-Site': 'cross-site' }));
24
39
  // round trip
25
40
  await check('mkdir-ok', 200, await post('/api/mkdir', { dir: ROOT, name: '_wtest' }));
26
- const up = await fetch(BASE + '/api/upload-file?dir=' + encodeURIComponent(ROOT + '\\_wtest') + '&name=up.txt', { method: 'PUT', body: 'hello upload' });
41
+ const up = await fetch(BASE + '/api/upload-file?dir=' + encodeURIComponent(join(ROOT, '_wtest')) + '&name=up.txt', { method: 'PUT', headers: AUTH, body: 'hello upload' });
27
42
  results.push({ name: 'upload-ok', expected: 200, got: up.status, ok: up.status === 200 });
28
- const up2 = await fetch(BASE + '/api/upload-file?dir=' + encodeURIComponent(ROOT + '\\_wtest') + '&name=up.txt', { method: 'PUT', body: 'x' });
43
+ const up2 = await fetch(BASE + '/api/upload-file?dir=' + encodeURIComponent(join(ROOT, '_wtest')) + '&name=up.txt', { method: 'PUT', headers: AUTH, body: 'x' });
29
44
  results.push({ name: 'upload-conflict-409', expected: 409, got: up2.status, ok: up2.status === 409 });
30
- await check('rename-ok', 200, await post('/api/rename', { path: ROOT + '\\_wtest\\up.txt', newName: 'up2.txt' }));
31
- await check('rename-conflict-409', 409, await post('/api/rename', { path: ROOT + '\\_wtest\\up2.txt', newName: 'up2.txt' }));
32
- await check('delete-nonempty-no-recursive-409', 409, await post('/api/delete', { path: ROOT + '\\_wtest' }));
33
- await check('delete-recursive-ok', 200, await post('/api/delete', { path: ROOT + '\\_wtest', recursive: true }));
34
- const st = await fetch(BASE + '/api/stat/' + encodeURIComponent(ROOT));
45
+ await check('rename-ok', 200, await post('/api/rename', { path: join(ROOT, '_wtest', 'up.txt'), newName: 'up2.txt' }));
46
+ await check('rename-conflict-409', 409, await post('/api/rename', { path: join(ROOT, '_wtest', 'up2.txt'), newName: 'up2.txt' }));
47
+ await check('delete-nonempty-no-recursive-409', 409, await post('/api/delete', { path: join(ROOT, '_wtest') }));
48
+ await check('delete-recursive-ok', 200, await post('/api/delete', { path: join(ROOT, '_wtest'), recursive: true }));
49
+ const st = await fetch(BASE + '/api/stat/' + encodeURIComponent(ROOT), { headers: AUTH });
35
50
  results.push({ name: 'stat-ok', expected: 200, got: st.status, ok: st.status === 200 });
36
- const st2 = await fetch(BASE + '/api/stat/' + encodeURIComponent('C:\\Windows'));
51
+ const st2 = await fetch(BASE + '/api/stat/' + encodeURIComponent(OUTSIDE_DIR), { headers: AUTH });
37
52
  results.push({ name: 'stat-outside-403', expected: 403, got: st2.status, ok: st2.status === 403 });
38
53
  let fail = 0;
39
54
  for (const r of results) { if (!r.ok) fail++; console.log((r.ok ? 'PASS' : 'FAIL') + ' ' + r.name + ' expected=' + r.expected + ' got=' + r.got + (r.ok ? '' : ' body=' + (r.body || ''))); }
@@ -3,7 +3,7 @@ import * as B from './backend.js';
3
3
 
4
4
  installStyles().catch(() => {});
5
5
 
6
- const { AppShell, WorkspaceShell, WorkspaceRail, Topbar, Crumb, Side, Status, Chat, ChatComposer, AgentChat, ConversationList, SessionDashboard, Row, Panel, PageHeader, SearchInput, TextField, Select, Btn, Icon, EventList, Spinner, Alert, FileGrid, FileSkeleton, sortFiles, FileToolbar, RootsPicker, BreadcrumbPath, EmptyState, FileViewer, FilePreviewPane, FilePreviewCode, FilePreviewText, FilePreviewMedia, ThemeToggle, ContextPane, PromptDialog, ConfirmDialog, DropZone, UploadProgress, FilterPills, SessionMeta } = C;
6
+ const { AppShell, WorkspaceShell, WorkspaceRail, Topbar, Crumb, Side, Status, Chat, ChatComposer, AgentChat, ConversationList, SessionDashboard, Row, Panel, PageHeader, SearchInput, TextField, Select, Btn, Icon, EventList, Spinner, Alert, FileGrid, FileSkeleton, sortFiles, FileToolbar, RootsPicker, BreadcrumbPath, EmptyState, FileViewer, FilePreviewPane, FilePreviewCode, FilePreviewText, FilePreviewMedia, ThemeToggle, ContextPane, PromptDialog, ConfirmDialog, DropZone, UploadProgress, FilterPills, SessionMeta, BulkBar } = C;
7
7
 
8
8
  // One duration/bytes vocabulary across every surface: prefer the kit's shared
9
9
  // formatters (exported alongside the components), fall back to the local
@@ -496,6 +496,10 @@ const SHORTCUTS = [
496
496
  { keys: 'Enter / Shift+Enter', desc: 'send / new line (in the composer)' },
497
497
  { keys: '?', desc: 'show shortcuts' },
498
498
  { keys: 'Esc', desc: 'close overlays, cancel confirms, stop generation, or blur the field' },
499
+ { keys: 'Up / Down / Home / End', desc: 'move the focused file row (files grid)' },
500
+ { keys: 'Enter / Backspace', desc: 'open the focused file / go up a directory (files grid)' },
501
+ { keys: 'Ctrl/Cmd+A', desc: 'select all shown files (files grid); Shift+click selects a range' },
502
+ { keys: 'Left / Right', desc: 'previous / next file (file preview)' },
499
503
  ];
500
504
 
501
505
  function view() {
@@ -739,8 +743,16 @@ async function loadDir(dirPath, { fromHash = false } = {}) {
739
743
  const j = await B.listDir(state.backend, dirPath || '');
740
744
  // The filter text and show-more cap are per-directory state: keep them
741
745
  // across an in-place refresh (same path after a mutation), reset them when
742
- // the resolved directory actually changed.
743
- if (j.path !== state.files.path) { state.files.filter = ''; state.files.shown = null; }
746
+ // the resolved directory actually changed. The multi-select set follows the
747
+ // same rule: cleared on a real directory change, pruned to the surviving
748
+ // entries on an in-place refresh (a deleted file must not stay marked).
749
+ if (j.path !== state.files.path) {
750
+ state.files.filter = ''; state.files.shown = null;
751
+ state.files.marked = new Set(); state.files._lastMarkIdx = null;
752
+ } else if (state.files.marked && state.files.marked.size) {
753
+ const alive = new Set((j.entries || []).map((e) => e.path || e.name));
754
+ state.files.marked = new Set([...state.files.marked].filter((p) => alive.has(p)));
755
+ }
744
756
  state.files.path = j.path;
745
757
  state.files.segments = j.segments || [];
746
758
  state.files.entries = j.entries || [];
@@ -825,6 +837,69 @@ function openFileDialog(kind, file) {
825
837
  state.files.dialog = { kind, file: file || null, error: null, busy: false };
826
838
  render();
827
839
  }
840
+ // --- multi-select (marked) helpers. Marked entries are keyed by path so the
841
+ // set survives sort/filter changes; loadDir owns clearing/pruning it.
842
+ function filesMarked() {
843
+ if (!(state.files.marked instanceof Set)) state.files.marked = new Set();
844
+ return state.files.marked;
845
+ }
846
+ function markFile(f, { range = false } = {}) {
847
+ const marked = filesMarked();
848
+ const key = f.path || f.name;
849
+ const list = state.files._sorted || [];
850
+ const idx = list.findIndex((e) => (e.path || e.name) === key);
851
+ if (range && state.files._lastMarkIdx != null && idx >= 0) {
852
+ // Shift-click: mark the whole span between the anchor and this row (always
853
+ // mark, never unmark - the common bulk gesture; EACCES rows are skipped).
854
+ const [a, b] = [Math.min(state.files._lastMarkIdx, idx), Math.max(state.files._lastMarkIdx, idx)];
855
+ for (const e of list.slice(a, b + 1)) {
856
+ if (e.permissions === 'EACCES') continue;
857
+ marked.add(e.path || e.name);
858
+ }
859
+ } else if (marked.has(key)) marked.delete(key);
860
+ else marked.add(key);
861
+ state.files._lastMarkIdx = idx >= 0 ? idx : null;
862
+ render();
863
+ }
864
+ function clearFileSelection({ quiet = false } = {}) {
865
+ const marked = filesMarked();
866
+ if (!marked.size) return false;
867
+ marked.clear();
868
+ state.files._lastMarkIdx = null;
869
+ if (!quiet) announce('selection cleared');
870
+ render();
871
+ return true;
872
+ }
873
+ // Bulk delete every marked entry via the same confined endpoint the per-row
874
+ // delete uses. Partial failure keeps the FAILED paths marked (so a retry
875
+ // targets exactly what's left) and surfaces a count in the dialog.
876
+ async function runBulkDelete() {
877
+ const d = state.files.dialog;
878
+ if (!d || d.kind !== 'bulk-delete' || d.busy) return;
879
+ const marked = filesMarked();
880
+ const targets = (state.files.entries || []).filter((e) => marked.has(e.path || e.name));
881
+ if (!targets.length) { state.files.dialog = null; render(); return; }
882
+ d.busy = true; d.error = null; render();
883
+ const results = await Promise.allSettled(
884
+ targets.map((e) => B.deleteEntry(state.backend, e.path, e.type === 'dir')));
885
+ const failed = targets.filter((_, i) => results[i].status === 'rejected');
886
+ const okCount = targets.length - failed.length;
887
+ if (!failed.length) {
888
+ state.files.dialog = null;
889
+ marked.clear(); state.files._lastMarkIdx = null;
890
+ announce('deleted ' + okCount + ' ' + (okCount === 1 ? 'entry' : 'entries'));
891
+ } else {
892
+ // Keep only the failures marked; report the split inside the dialog.
893
+ state.files.marked = new Set(failed.map((e) => e.path || e.name));
894
+ const firstErr = results.find((r) => r.status === 'rejected');
895
+ d.busy = false;
896
+ d.error = 'deleted ' + okCount + ' of ' + targets.length + ' - '
897
+ + (failed.length === 1 ? '1 entry' : failed.length + ' entries') + ' failed ('
898
+ + fileMutationCopy(firstErr.reason || {}) + '). The failed entries stay selected.';
899
+ }
900
+ await loadDir(state.files.path, { fromHash: true });
901
+ render();
902
+ }
828
903
  function closeFileDialog() {
829
904
  // A mid-flight close would orphan the mutation's result and swallow its
830
905
  // error - hold the dialog open until the operation settles.
@@ -936,6 +1011,20 @@ function fileDialog() {
936
1011
  onConfirm: () => runFileMutation(() => B.deleteEntry(state.backend, d.file.path, isDir), 'deleted ' + d.file.name),
937
1012
  });
938
1013
  }
1014
+ if (d.kind === 'bulk-delete') {
1015
+ const n = filesMarked().size;
1016
+ const hasDir = (state.files.entries || []).some((e) => filesMarked().has(e.path || e.name) && e.type === 'dir');
1017
+ return ConfirmDialog({
1018
+ title: 'Delete ' + n + ' selected ' + (n === 1 ? 'entry' : 'entries'),
1019
+ message: (hasDir
1020
+ ? 'Folders are deleted with everything inside them. '
1021
+ : '') + 'This cannot be undone.',
1022
+ error: d.error || null, busy: !!d.busy,
1023
+ confirmLabel: d.busy ? 'deleting...' : 'delete ' + n, cancelLabel: 'cancel', destructive: true,
1024
+ onCancel: closeFileDialog,
1025
+ onConfirm: runBulkDelete,
1026
+ });
1027
+ }
939
1028
  if (d.kind === 'mkdir') {
940
1029
  return PromptDialog({
941
1030
  title: 'New folder', value: '', placeholder: 'folder name',
@@ -1053,7 +1142,9 @@ function filesMain() {
1053
1142
  // and an epoch (modifiedTs) so the kit's modified-sort orders by real time.
1054
1143
  const mapped = (f.entries || []).map((e) => {
1055
1144
  const ts = e.modified ? Date.parse(e.modified) : 0;
1056
- return { name: e.name, type: e.type, size: e.size, modified: ts ? fmtRelTime(ts) : null, modifiedTs: ts, path: e.path };
1145
+ // permissions rides along so the kit's EACCES/read-only tag, disabled-open
1146
+ // and unmarkable-checkbox logic actually fire (it was silently dropped here).
1147
+ return { name: e.name, type: e.type, size: e.size, modified: ts ? fmtRelTime(ts) : null, modifiedTs: ts, path: e.path, permissions: e.permissions };
1057
1148
  });
1058
1149
  const filtered = f.filter
1059
1150
  ? mapped.filter(e => e.name.toLowerCase().includes(f.filter.toLowerCase()))
@@ -1072,6 +1163,20 @@ function filesMain() {
1072
1163
  shown: f.shown,
1073
1164
  onShowMore: (n) => { state.files.shown = n; render(); },
1074
1165
  emptyText: f.filter ? 'No files match "' + f.filter + '"' : 'Empty directory',
1166
+ // Multi-select: marked is path-keyed; shift-click ranges over the same
1167
+ // sorted order the grid shows; select-all covers the SHOWN window only.
1168
+ selectable: !!f.path,
1169
+ marked: filesMarked(),
1170
+ onMark: markFile,
1171
+ onSelectAll: (keys) => { state.files.marked = new Set(keys); state.files._lastMarkIdx = null; announce(keys.length + ' selected'); render(); },
1172
+ onClearSelection: () => clearFileSelection(),
1173
+ // Density: list / compact / thumbnails. Thumbnails stream through the
1174
+ // confined /api/download (same fsAllowRoots as the listing - /api/image
1175
+ // has its OWN narrower allowlist and 403s repo files; <img> ignores the
1176
+ // attachment disposition).
1177
+ density: f.density || 'list',
1178
+ onDensity: (d2) => { state.files.density = d2; persistFilesPrefs(); render(); },
1179
+ thumbUrl: (file) => B.downloadUrl(state.backend, file.path),
1075
1180
  sort: { key: f.sort || 'name', dir: f.sortDir || 'asc', onSort: (k) => {
1076
1181
  // Click the active column to flip direction; a new column resets to asc.
1077
1182
  if (state.files.sort === k) state.files.sortDir = state.files.sortDir === 'asc' ? 'desc' : 'asc';
@@ -1115,6 +1220,9 @@ function filesMain() {
1115
1220
  const toolbar = FileToolbar({
1116
1221
  left: [crumb],
1117
1222
  right: [
1223
+ // Explicit up-a-directory affordance for mouse users (the grid also takes
1224
+ // Backspace); disabled at a root depth where fileUp would no-op.
1225
+ Btn({ key: 'up', disabled: (f.segments || []).length <= 1, 'aria-label': 'up a directory', onClick: () => fileUp(), children: 'up' }),
1118
1226
  f.path ? Btn({ key: 'newdir', onClick: () => openFileDialog('mkdir'), children: 'new folder' }) : null,
1119
1227
  f.path ? Btn({ key: 'upload', onClick: () => {
1120
1228
  const inp = document.createElement('input');
@@ -1144,6 +1252,15 @@ function filesMain() {
1144
1252
  PageHeader({ compact: true, title: 'Files', lede: 'Browse and manage the server filesystem within the allowed roots. Click a folder to open it; pick one as the chat working directory.' }),
1145
1253
  rootsRow ? h('div', { key: 'froots' }, rootsRow) : null,
1146
1254
  h('div', { key: 'ftb' }, toolbar),
1255
+ // Bulk action strip - appears while a multi-select is active; delete runs
1256
+ // through the same armed ConfirmDialog vocabulary as the per-row delete.
1257
+ filesMarked().size ? h('div', { key: 'fbulk' }, BulkBar({
1258
+ count: filesMarked().size,
1259
+ noun: 'entry',
1260
+ busy: !!(f.dialog && f.dialog.kind === 'bulk-delete' && f.dialog.busy),
1261
+ actions: [{ label: 'delete selected', danger: true, onClick: () => openFileDialog('bulk-delete') }],
1262
+ onClear: () => clearFileSelection(),
1263
+ })) : null,
1147
1264
  (f.uploads && f.uploads.length) ? h('div', { key: 'fup' }, UploadProgress({
1148
1265
  // Recovery affordances per row: 'replace' on a name collision (409),
1149
1266
  // dismiss on any error row (errors otherwise persist until the next batch).
@@ -1172,7 +1289,7 @@ function persistLivePrefs() {
1172
1289
  lsSet(LIVE_PREFS_KEY, JSON.stringify({ sort: state.live.sort || 'status', errorsOnly: !!state.live.errorsOnly }));
1173
1290
  }
1174
1291
  function persistFilesPrefs() {
1175
- lsSet(FILES_PREFS_KEY, JSON.stringify({ sort: state.files.sort || 'name', sortDir: state.files.sortDir || 'asc' }));
1292
+ lsSet(FILES_PREFS_KEY, JSON.stringify({ sort: state.files.sort || 'name', sortDir: state.files.sortDir || 'asc', density: state.files.density || 'list' }));
1176
1293
  }
1177
1294
  function hydratePrefs() {
1178
1295
  try {
@@ -1181,7 +1298,7 @@ function hydratePrefs() {
1181
1298
  } catch {}
1182
1299
  try {
1183
1300
  const fp = JSON.parse(lsGet(FILES_PREFS_KEY) || 'null');
1184
- if (fp) { if (fp.sort) state.files.sort = fp.sort; if (fp.sortDir) state.files.sortDir = fp.sortDir; }
1301
+ if (fp) { if (fp.sort) state.files.sort = fp.sort; if (fp.sortDir) state.files.sortDir = fp.sortDir; if (fp.density) state.files.density = fp.density; }
1185
1302
  } catch {}
1186
1303
  }
1187
1304
 
@@ -3250,6 +3367,9 @@ window.addEventListener('keydown', (e) => {
3250
3367
  clearTimeout(_stopAllArmTimer); clearTimeout(_stopSelArmTimer);
3251
3368
  render(); announce('stop cancelled'); return;
3252
3369
  }
3370
+ // A live file multi-select is transient state too: Escape drops it before
3371
+ // falling through to stop-generation.
3372
+ if (state.tab === 'files' && filesMarked().size) { clearFileSelection(); return; }
3253
3373
  if (state.chat.busy && state.tab === 'chat') { cancelChat(); announce('generation stopped'); return; }
3254
3374
  return;
3255
3375
  }
@@ -904,10 +904,10 @@
904
904
  ============================================================ */
905
905
  .ds-247420 .panel {
906
906
  background: var(--panel-1, var(--bg-2));
907
- border-radius: var(--r-3);
907
+ border: var(--bw-hair) solid var(--rule);
908
+ border-radius: var(--r-2);
908
909
  margin: 0 0 var(--space-5);
909
910
  padding: var(--space-3);
910
- box-shadow: var(--panel-shadow);
911
911
  position: relative;
912
912
  }
913
913
  .ds-247420 .panel.panel-inline { background: var(--bg-2); padding: var(--space-3) var(--space-3); }
@@ -1296,10 +1296,10 @@
1296
1296
  gap: 3px;
1297
1297
  }
1298
1298
  .ds-247420 .ds-file-row {
1299
- grid-template-columns: 22px minmax(0, 1fr) auto;
1300
- gap: 10px; padding: 10px 12px;
1299
+ gap: 8px; padding: 10px 12px;
1301
1300
  font-size: var(--fs-xs);
1302
1301
  }
1302
+ .ds-247420 .ds-file-open { gap: 10px; }
1303
1303
  /* On mobile, fold meta under the title and keep actions inline. */
1304
1304
  .ds-247420 .ds-file-row .ds-file-meta { font-size: var(--fs-micro); }
1305
1305
  .ds-247420 .ds-file-row .ds-file-actions { opacity: 1; }
@@ -1307,6 +1307,7 @@
1307
1307
  reachable by touch (cohesion sweep). */
1308
1308
  .ds-247420 .ds-file-act, .ds-247420 .ds-file-sort-btn, .ds-247420 .ds-crumb-seg { min-height: 44px; }
1309
1309
  .ds-247420 .ds-file-filter-input { min-height: 44px; }
1310
+ .ds-247420 .ds-file-check, .ds-247420 .ds-file-selectall, .ds-247420 .ds-density-btn { min-height: 44px; min-width: 44px; }
1310
1311
 
1311
1312
  /* Chat */
1312
1313
  .ds-247420 .chat-stack { max-width: 100%; min-width: 0; }
@@ -1533,10 +1534,13 @@
1533
1534
  @keyframes ds-skel-shimmer { 100% { transform: translateX(100%); } }
1534
1535
  @media (prefers-reduced-motion: reduce) { .ds-247420 .ds-skel::after { animation: none; } }
1535
1536
 
1536
- /* File row — linear layout (icon · title · meta · actions) */
1537
+ /* File row — linear layout (checkbox? · open-button[code · icon · title · meta] · actions).
1538
+ The row is a flex strip; the open <button> is the stretching member and lays
1539
+ out the entry's own columns. It needs a full reset (UA button chrome off)
1540
+ because FileRow wraps the row content in a real button for native keyboard
1541
+ semantics. */
1537
1542
  .ds-247420 .ds-file-row {
1538
- display: grid; grid-template-columns: 26px minmax(0, 1fr) auto auto;
1539
- gap: 14px; align-items: center;
1543
+ display: flex; align-items: center; gap: 10px;
1540
1544
  padding: 11px 16px; background: var(--bg);
1541
1545
  border: var(--bw-hair) solid transparent;
1542
1546
  border-radius: var(--r-2); color: var(--fg);
@@ -1545,6 +1549,14 @@
1545
1549
  border-color var(--dur-snap) var(--ease),
1546
1550
  transform var(--dur-snap) var(--ease);
1547
1551
  }
1552
+ .ds-247420 .ds-file-open {
1553
+ display: flex; align-items: center; gap: 14px;
1554
+ flex: 1 1 auto; min-width: 0;
1555
+ background: none; border: 0; padding: 0; margin: 0;
1556
+ color: inherit; font: inherit; text-align: left; cursor: pointer;
1557
+ }
1558
+ .ds-247420 .ds-file-open[disabled] { cursor: default; }
1559
+ .ds-247420 .ds-file-open:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: var(--r-1); }
1548
1560
  .ds-247420 .ds-file-row:hover {
1549
1561
  background: var(--bg-2);
1550
1562
  border-color: var(--rule);
@@ -1566,7 +1578,7 @@
1566
1578
  .ds-247420 .ds-file-row .title {
1567
1579
  font-weight: 500; font-size: var(--fs-sm);
1568
1580
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
1569
- min-width: 0;
1581
+ min-width: 0; flex: 1 1 auto;
1570
1582
  }
1571
1583
  .ds-247420 .ds-file-row .code {
1572
1584
  font-family: var(--ff-mono); font-size: var(--fs-micro);
@@ -1608,6 +1620,99 @@
1608
1620
  .ds-247420 .ds-file-act:hover { background: var(--bg-3); color: var(--fg); }
1609
1621
  .ds-247420 .ds-file-act-warn:hover { background: color-mix(in oklab, var(--warn) 18%, var(--bg)); color: var(--warn); }
1610
1622
 
1623
+ /* Multi-select — per-row checkbox, header tri-state, bulk action strip. */
1624
+ .ds-247420 .ds-file-check {
1625
+ display: inline-flex; align-items: center; justify-content: center;
1626
+ min-width: 28px; height: 28px; padding: 0 4px;
1627
+ background: transparent; border: 0; border-radius: var(--r-1);
1628
+ font-family: var(--ff-mono); font-size: var(--fs-tiny); color: var(--fg-3);
1629
+ cursor: pointer;
1630
+ transition: background var(--dur-snap) var(--ease), color var(--dur-snap) var(--ease);
1631
+ }
1632
+ .ds-247420 .ds-file-check:hover { background: var(--bg-3); color: var(--fg); }
1633
+ .ds-247420 .ds-file-check.is-marked { color: var(--accent); }
1634
+ .ds-247420 .ds-file-check:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
1635
+ .ds-247420 .ds-file-check[disabled] { opacity: 0.4; cursor: default; }
1636
+ .ds-247420 .ds-file-row.is-marked { background: var(--accent-tint); border-color: var(--accent); }
1637
+ .ds-247420 .ds-file-selectall {
1638
+ display: inline-flex; align-items: center; gap: var(--space-1);
1639
+ font-family: var(--ff-mono); font-size: var(--fs-tiny); color: var(--fg-2);
1640
+ cursor: pointer; background: none; border: none;
1641
+ padding: var(--space-1); border-radius: var(--r-1);
1642
+ }
1643
+ .ds-247420 .ds-file-selectall:hover { background: var(--bg-2); color: var(--fg); }
1644
+ .ds-247420 .ds-file-selectall:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
1645
+ .ds-247420 .ds-file-controls { display: flex; align-items: center; gap: var(--space-2); padding: 0 var(--space-2) var(--space-1); flex-wrap: wrap; }
1646
+ .ds-247420 .ds-file-controls .spread { flex: 1 1 auto; }
1647
+ .ds-247420 .ds-file-controls .ds-file-sort { padding: 0; }
1648
+ .ds-247420 .ds-bulkbar {
1649
+ display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap;
1650
+ padding: var(--space-1) var(--space-2);
1651
+ background: var(--bg-2); border: var(--bw-hair) solid var(--rule); border-radius: var(--r-2);
1652
+ }
1653
+ .ds-247420 .ds-bulkbar-count { font-size: var(--fs-tiny); color: var(--fg-2); }
1654
+
1655
+ /* Density picker — list / compact / thumbnails. */
1656
+ .ds-247420 .ds-density {
1657
+ display: inline-flex; gap: 2px; padding: 2px;
1658
+ background: var(--bg-2); border: var(--bw-hair) solid var(--rule); border-radius: var(--r-1);
1659
+ }
1660
+ .ds-247420 .ds-density-btn {
1661
+ font-size: var(--fs-tiny); color: var(--fg-2);
1662
+ background: none; border: none; cursor: pointer;
1663
+ padding: 2px var(--space-2); border-radius: calc(var(--r-1) - 2px);
1664
+ transition: background var(--dur-snap) var(--ease), color var(--dur-snap) var(--ease);
1665
+ }
1666
+ .ds-247420 .ds-density-btn:hover { color: var(--fg); }
1667
+ .ds-247420 .ds-density-btn.active { background: var(--accent-tint); color: var(--accent); }
1668
+ .ds-247420 .ds-density-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
1669
+
1670
+ /* Compact density — tighter rows for long listings. */
1671
+ .ds-247420 .ds-file-grid[data-density="compact"] { gap: 2px; }
1672
+ .ds-247420 .ds-file-grid[data-density="compact"] .ds-file-row { padding: 5px 12px; gap: 10px; }
1673
+ .ds-247420 .ds-file-grid[data-density="compact"] .ds-file-row .title { font-size: var(--fs-xs); }
1674
+ .ds-247420 .ds-file-grid[data-density="compact"] .ds-file-row .ds-file-meta { font-size: var(--fs-micro); }
1675
+ .ds-247420 .ds-file-grid[data-density="compact"] .ds-file-icon { width: 20px; height: 20px; font-size: 13px; }
1676
+
1677
+ /* Thumbnail density — tile grid; image entries carry a real lazy thumbnail. */
1678
+ .ds-247420 .ds-file-grid-thumb {
1679
+ display: grid; grid-template-columns: repeat(auto-fill, minmax(132px, 1fr));
1680
+ gap: var(--space-3); align-items: start;
1681
+ }
1682
+ .ds-247420 .ds-file-cell {
1683
+ position: relative;
1684
+ background: var(--bg); border: var(--bw-hair) solid var(--rule); border-radius: var(--r-2);
1685
+ overflow: hidden;
1686
+ transition: border-color var(--dur-snap) var(--ease), background var(--dur-snap) var(--ease);
1687
+ }
1688
+ .ds-247420 .ds-file-cell:hover { border-color: var(--fg-3); }
1689
+ .ds-247420 .ds-file-cell.active { border-color: var(--accent); background: var(--accent-tint); }
1690
+ .ds-247420 .ds-file-cell.is-marked { border-color: var(--accent); background: var(--accent-tint); }
1691
+ .ds-247420 .ds-file-cell.is-locked { opacity: 0.6; }
1692
+ .ds-247420 .ds-file-cell-open {
1693
+ display: flex; flex-direction: column; align-items: stretch; gap: var(--space-1);
1694
+ width: 100%; padding: var(--space-1); margin: 0;
1695
+ background: none; border: none; cursor: pointer; color: var(--fg);
1696
+ }
1697
+ .ds-247420 .ds-file-cell-open[disabled] { cursor: default; }
1698
+ .ds-247420 .ds-file-cell-open:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; border-radius: var(--r-2); }
1699
+ .ds-247420 .ds-file-cell-media {
1700
+ display: flex; align-items: center; justify-content: center;
1701
+ width: 100%; aspect-ratio: 4 / 3;
1702
+ background: var(--bg-2); border-radius: var(--r-1); overflow: hidden;
1703
+ }
1704
+ .ds-247420 .ds-file-cell-media .ds-file-icon { width: 34px; height: 34px; font-size: 24px; }
1705
+ .ds-247420 .ds-file-cell-thumb { width: 100%; height: 100%; object-fit: cover; }
1706
+ .ds-247420 .ds-file-cell-name {
1707
+ font-size: var(--fs-tiny); text-align: left;
1708
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
1709
+ }
1710
+ .ds-247420 .ds-file-cell-meta { font-family: var(--ff-mono); font-size: var(--fs-micro); color: var(--fg-3); text-align: left; }
1711
+ .ds-247420 .ds-file-cell-check {
1712
+ position: absolute; top: 4px; left: 4px; z-index: 1;
1713
+ background: color-mix(in oklab, var(--bg) 78%, transparent);
1714
+ }
1715
+
1611
1716
  /* Card mode — opt-in via data-columns; switches the list to a grid. */
1612
1717
  .ds-247420 .ds-file-grid[data-columns] { display: grid; gap: var(--space-3); }
1613
1718
  .ds-247420 .ds-file-grid[data-columns="1"] { grid-template-columns: 1fr; }
@@ -1990,7 +2095,7 @@
1990
2095
  .ds-247420 .ds-segmented .ds-seg-btn:hover { color: var(--fg); }
1991
2096
  .ds-247420 .ds-segmented .ds-seg-btn.is-on {
1992
2097
  background: var(--bg); color: var(--fg);
1993
- box-shadow: var(--shadow-1);
2098
+ box-shadow: inset 0 0 0 var(--bw-hair, 1px) var(--rule);
1994
2099
  }
1995
2100
  .ds-247420 .ds-theme-toggle.btn {
1996
2101
  font-family: var(--ff-mono); font-size: var(--fs-xs);
@@ -2196,7 +2301,6 @@
2196
2301
  .ds-247420 .chat-msg:hover .chat-image,
2197
2302
  .ds-247420 .chat-image:hover {
2198
2303
  border-color: color-mix(in oklab, var(--accent) 35%, transparent);
2199
- box-shadow: 0 4px 16px color-mix(in oklab, var(--fg) 12%, transparent);
2200
2304
  }
2201
2305
  .ds-247420 .chat-image img { display: block; width: 100%; height: auto; max-height: 360px; object-fit: cover; }
2202
2306
  .ds-247420 .chat-image .cap,
@@ -2237,7 +2341,7 @@
2237
2341
  border-radius: 12px; overflow: hidden; max-width: min(100%, 480px);
2238
2342
  transition: box-shadow 0.12s ease, border-color 0.12s ease;
2239
2343
  }
2240
- .ds-247420 .chat-msg:hover .chat-pdf { border-color: color-mix(in oklab, var(--accent) 30%, transparent); box-shadow: 0 2px 12px color-mix(in oklab, var(--fg) 6%, transparent); }
2344
+ .ds-247420 .chat-msg:hover .chat-pdf { border-color: color-mix(in oklab, var(--accent) 30%, transparent); }
2241
2345
  .ds-247420 .chat-pdf-head {
2242
2346
  display: flex; align-items: center; gap: 10px; padding: 10px 14px;
2243
2347
  background: color-mix(in oklab, var(--fg) 5%, var(--bg-2));
@@ -2265,7 +2369,6 @@
2265
2369
  .ds-247420 .chat-link:hover {
2266
2370
  background: var(--bg-3);
2267
2371
  border-color: color-mix(in oklab, var(--accent) 40%, transparent);
2268
- box-shadow: 0 2px 12px color-mix(in oklab, var(--fg) 8%, transparent);
2269
2372
  }
2270
2373
  .ds-247420 .chat-link:not(:has(.thumb)):not(:has(img)) { grid-template-columns: 1fr; }
2271
2374
  .ds-247420 .chat-link .thumb,
@@ -3378,6 +3481,15 @@
3378
3481
  .ds-247420 .ws-shell.ws-sessions-open .ws-scrim, .ds-247420 .ws-shell.ws-pane-open .ws-scrim { opacity: 1; pointer-events: auto; }
3379
3482
  /* The mobile drawer-toggle affordances live in the crumb bar. */
3380
3483
  .ds-247420 .ws-drawer-toggle { display: inline-flex; }
3484
+ /* The Crumb's narrow collapse elsewhere keys on the .app @container, which
3485
+ does not exist inside WorkspaceShell - mirror it here so the crumb bar
3486
+ cannot overflow a narrow viewport (trail hides, leaf ellipsizes, the
3487
+ right cluster wraps under instead of pushing past the edge). */
3488
+ .ds-247420 .ws-crumb { padding-top: 6px; padding-bottom: 6px; }
3489
+ .ds-247420 .ws-crumb .app-crumb { flex-wrap: wrap; min-width: 0; }
3490
+ .ds-247420 .ws-crumb .app-crumb > .sep, .ds-247420 .ws-crumb .app-crumb > span:not(.crumb-right):not(.leaf) { display: none; }
3491
+ .ds-247420 .ws-crumb .app-crumb .leaf { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
3492
+ .ds-247420 .ws-crumb .app-crumb .crumb-right { margin-left: 0; flex-wrap: wrap; min-width: 0; display: inline-flex; }
3381
3493
  }
3382
3494
  /* Drawer toggles are mobile-only chrome; hidden on the desktop grid. */
3383
3495
  .ds-247420 .ws-drawer-toggle { display: none; align-items: center; justify-content: center; width: 40px; height: 40px; border: none; background: none; color: var(--fg-2); cursor: pointer; border-radius: var(--r-1); }