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 +15 -15
- package/PUNCHLIST-PARITY.md +6 -0
- package/lib/http-handler.js +20 -1
- package/package.json +1 -1
- package/scripts/validate-mutations.mjs +28 -13
- package/site/app/js/app.js +126 -6
- package/site/app/vendor/anentrypoint-design/247420.css +124 -12
- package/site/app/vendor/anentrypoint-design/247420.js +14 -14
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
|
-
|
|
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
|
package/PUNCHLIST-PARITY.md
CHANGED
|
@@ -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.
|
package/lib/http-handler.js
CHANGED
|
@@ -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,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
|
|
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
|
|
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:
|
|
17
|
-
await check('delete-out-of-roots', 403, await post('/api/delete', { path:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
31
|
-
await check('rename-conflict-409', 409, await post('/api/rename', { path: ROOT
|
|
32
|
-
await check('delete-nonempty-no-recursive-409', 409, await post('/api/delete', { path: ROOT
|
|
33
|
-
await check('delete-recursive-ok', 200, await post('/api/delete', { path: ROOT
|
|
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(
|
|
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 || ''))); }
|
package/site/app/js/app.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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(--
|
|
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);
|
|
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); }
|