feat(files): a Files section over the caller's whole space
Nightly Build / build (push) Successful in 5m36s

Until now file browsing existed only inside a project, and the two memory
stores were reachable only by the agent's tools. `#files` is the general
surface: home, both memory stores, the shared folders and projects the
caller belongs to, plus the read-only skills and docs trees.

The root is virtual, and that is the design. Anchoring at `~` is wrong:
the explorer reads host-side, while `shared/`, `projects/`, `skills/` and
`docs/` are bind mounts inside the container — a page rooted at the home
would show less than the user has with no way to reach the rest, and on
native Linux would show Docker's empty mountpoint stubs, a door that
appears to work and leads nowhere. So level 0 is a synthetic list from the
new `GET /api/files/roots`, serialized from the caller's `UserFs` plus the
two virtual memory roots. It sends `kind`, never a label: labels are copy
and get translated.

`GET /api/files/dir` now answers `{ path, can_write, entries }`, and a
memory path is classified before `resolve_view_path` (which refuses one)
and listed from `memory_docs`: one level derived from the flat key space
by the pure `memory_docs::immediate_children`, over a single query whose
unslashed prefix also spots an exact note as "not a directory". Memory is
read-only from the page — every writer routes through `resolve_view_path`,
and `shared-memory/*` is `@fs_write require` for the agent, so a button
that walks past that rule is a decision of its own.

The explorer moves out of projects into `shared/file-explorer.js`, taking
`root` + `rootLabel` and reading `can_write` from the listing rather than
from its host: writability changes per branch and comes from the same
`UserFs::can_write_to` the server rejects writes with, so the buttons
offered and the writes accepted cannot disagree. Deep-linking needed it
steerable without a two-way binding, hence `rel` in and
`explorer-navigate` out — the event fires only for a click, never for a
`rel` the host set, so echoing it back is a no-op.

The URL carries the agent path of the open folder in one parameter, the
same vocabulary the assistant uses, so a link is shareable and pasteable
into a conversation; which root it belongs to is derived, not stored.

docs/: a new files.md, plus two pages this made false — shared-folders.md
claimed in three places that a shared folder has no explorer, and
memory.md never said a user can now read their own notes.
This commit is contained in:
Daniele
2026-08-22 20:09:56 +01:00
parent 934726a75d
commit 488c702517
19 changed files with 870 additions and 148 deletions
+18 -2
View File
@@ -194,9 +194,23 @@ A **project** is a shareable, self-service workspace: a folder at `{WD}/projects
**API** (`src/frontend/api/projects.rs`): `GET/POST /api/projects`, `GET/PUT/DELETE /api/projects/{id}`, `POST /api/projects/{id}/members`, `DELETE .../members/{user_id}`, `POST /api/projects/{id}/session`. `ProjectDetail` carries `root_path` — the agent path of the folder, computed server-side (owner username ≠ `owner_name`, which may be a display name) — the explorer's root. A `project-{id}` chat source provisions the `project-coordinator` agent with a project `RunContext` (`provisioning_for_source``skald_core::projects::build_project_run_context`: `project_root` + a system block with name/description/folder/members); every member keeps their **own private** `project-{id}` session — only the folder is shared.
**UI** (`web/components/projects/`): `index.js` (`<projects-page>` host — hash-routed: `#projects`, `#projects/{id}`, `#projects/{id}/sharing`, back/forward-aware), `project-list.js` (card grid + create/edit/delete modal), `project-board.js` (`<project-board-section>` — the detail page: header with **Open chat**, then a **Files / Sharing** tab bar using the `.project-tab-bar` styles in `css/projects/board.css`), `project-files.js` (`<project-files-panel>` — the explorer). The mobile app has its own read-only `shared/projects-page.js` (list → open project chat).
**UI** (`web/components/projects/`): `index.js` (`<projects-page>` host — hash-routed: `#projects`, `#projects/{id}`, `#projects/{id}/sharing`, back/forward-aware), `project-list.js` (card grid + create/edit/delete modal), `project-board.js` (`<project-board-section>` — the detail page: header with **Open chat**, then a **Files / Sharing** tab bar using the `.project-tab-bar` styles in `css/projects/board.css`, the Files tab being the shared `<file-explorer>` pointed at the project folder). The mobile app has its own read-only `shared/projects-page.js` (list → open project chat).
**The explorer** (`project-files.js`): one directory at a time via `GET /api/files/dir?path=…` (new endpoint in `src/frontend/api/files.rs`: immediate children with `name/path/is_dir/size/created_at/modified_at`, dirs-first; same `resolve_view_path` scoping as `/api/file`). Breadcrumb rooted at the project (`/` = `root_path`); file click → `window.openFile` (existing viewer); folder click → navigate. **Live**: it subscribes the open directory on the existing `/api/file/watch` socket (`web/lib/file-watcher.js` singleton — `notify` NonRecursive on a dir reports its direct children) and reloads debounced 300 ms, so files created by other members or by the agent in-container appear without a refresh. Write actions (new folder, upload incl. drag&drop, rename, delete) are shown only to `can_write` members and ride the existing `/api/file` endpoints — `POST` gained `dir:true` (mkdir), `DELETE` handles directories (`remove_dir_all`), and binary upload is the new `POST /api/file/upload?path=…` (raw body, 256 MiB `DefaultBodyLimit`). **Server-side write gate**: all `/api/file` write handlers now call `UserFs::can_write_to(agent_path)` (core-api) — home → true, `shared/`/`projects/` → the membership's `can_write`, `docs/` → false — closing the host-side bypass of the read-only bind mount (the container mount only gates in-container writes).
**The explorer** (`web/components/shared/file-explorer.js`, `<file-explorer>`): **not a project component** — it browses one subtree of the caller's namespace, given a `root` agent path (a project folder, a shared folder, the home, a memory store) and a `rootLabel` for the first crumb; projects are one caller of it. One directory at a time via `GET /api/files/dir?path=…` (`src/frontend/api/files.rs`: `{ path, can_write, entries }`, each entry `name/path/is_dir/size/created_at/modified_at`, dirs-first; same `resolve_view_path` scoping as `/api/file`, except a memory path, classified **before** it and listed from `memory_docs` — see the memory-namespace note). **`can_write` is read from that listing, never passed in**: it changes per branch (a shared folder without the flag, `skills/`, `docs/`, a memory store) and comes from the same `UserFs::can_write_to` the server rejects writes with, so the buttons offered and the writes accepted cannot disagree — a caller that thought it knew better would be the one place they could. Breadcrumb rooted at `root`; file click → `window.openFile` (existing viewer); folder click → navigate. **Live**: it subscribes the open directory on the existing `/api/file/watch` socket (`web/lib/file-watcher.js` singleton — `notify` NonRecursive on a dir reports its direct children) and reloads debounced 300 ms, so files created by other members or by the agent in-container appear without a refresh. Write actions (new folder, upload incl. drag&drop, rename, delete) are shown only to `can_write` members and ride the existing `/api/file` endpoints — `POST` gained `dir:true` (mkdir), `DELETE` handles directories (`remove_dir_all`), and binary upload is the new `POST /api/file/upload?path=…` (raw body, 256 MiB `DefaultBodyLimit`). **Server-side write gate**: all `/api/file` write handlers now call `UserFs::can_write_to(agent_path)` (core-api) — home → true, `shared/`/`projects/` → the membership's `can_write`, `docs/` → false — closing the host-side bypass of the read-only bind mount (the container mount only gates in-container writes).
## Files (`#files`)
The general file section: one page over **everything the caller can reach**, and the second consumer of `<file-explorer>` (see the Projects section for the component itself).
**The root is virtual, and that is the whole design.** Anchoring at `~` was the obvious move and is wrong: the explorer reads **host-side**, where the home is `{WD}/homes/{userid}` while `shared/{X}`, `projects/{O}/{S}`, `skills/` and `docs/` are bind mounts *inside the container* — so a page rooted at the home would show less than the user has, with no way to reach the rest, and on native Linux would show the mountpoint stubs Docker creates in the bind source: `shared/`, `docs/`, `skills/` present and **empty**. That is the memory-signpost failure exactly — a door that appears to work and leads nowhere. So level 0 is a synthetic list from `GET /api/files/roots`, serialized from the caller's `UserFs` (plus the two memory roots, which are virtual and so are not in it): `FsRoot { kind, path, name, owner, can_write }`, with **no `label`** — the server sends the discriminant, the frontend maps `kind` → label + icon, because labels are copy and get translated. Seven kinds, not six: `user-memory` and `shared-memory` are separate rather than one `memory` with a scope, since they are two stores with two names and a `scope` field would be a discriminant inside a discriminant. `skills`/`docs` appear only if the `UserFs` has them.
**The URL carries the agent path of the open folder** — one `path` parameter (`#files?path=shared/casa/foto`), the same vocabulary the assistant uses, so a link is shareable *and* pasteable into a conversation. Which root it belongs to is **derived** (`FilesPage._resolve`, longest-prefix over the roots list), never stored beside it: two values that can disagree are two chances to be wrong. A path under no root — a hand-edited URL, or a container-only `/tmp/…`, which this page does not serve — falls back to the root list with an error, rather than to an explorer that cannot explain itself.
**Deep-linking needed the explorer to be steerable without a two-way binding**, hence `rel` in + `explorer-navigate` out. The loop those two would form is cut by *what the event means*: it fires only for a click (`_navigate`), never for a `rel` the host set (`_open`), so echoing the event back as a property is a no-op — and a host that ignores the event entirely (the project board) still gets a working explorer.
**Memory is read-only here**, and it is scope rather than a property (blueprint `dir-explorer.md` task 5): every writer in `files.rs` routes through `resolve_view_path`, which refuses memory paths, and `shared-memory/*` is `@fs_write require` for the agent — giving a user a button that walks past that rule is a decision of its own. The listing side *is* wired: `list_dir` classifies memory **before** `resolve_view_path` and derives one level from the flat key space via `memory_docs::immediate_children`.
**Naming trap in the sidebar**: the `workspace` group already holds "Shared folders", which is the admin's CRUD *over one kind of root* — not this. The two entries must stay obviously different in copy.
## MCP connectors (blueprint §7/§14/§15)
@@ -520,6 +534,8 @@ The role editor (`roles-page.js`) sets the default group + an allowed-groups che
| `shared/config-form.js` | `ConfigFormController` | The schema-driven settings form, shared by `config-page.js` and the System agents page — one renderer and one write path (`PUT /api/config/{key}`) for every `ConfigSet` |
| `shared-folders.js` | `<shared-folders-page>` | `#shared-folders` — admin-only CRUD for on-disk shared folders (§6): create/describe/delete + per-member read-only/read-write grants; description feeds the assistant's `__SHARED_FOLDERS__` context |
| `projects/` | `<projects-page>` | `#projects` — host + list + board; the board is tabbed (**Files** explorer with live watcher + write actions, **Sharing** members), deep-linked `#projects/{id}[/sharing]`. See the Projects section |
| `files-page.js` | `<files-page>` | `#files` — the caller's whole space. Level 0 is the **virtual root** (`GET /api/files/roots`), level 1 the shared `<file-explorer>`. See the Files section |
| `shared/file-explorer.js` | `<file-explorer>` | The explorer itself, host-agnostic: `root` + `rootLabel` + optional `rel`, `can_write` read from the listing. Used by `#files` and the project board |
| `connector-detail.js` | `<connector-detail-page>` | A connector's own page (`#connector?name=X`): env/secret form + Test, the **OAuth login panel** (sign in → paste code → complete, §15), global enable. Access grants live **only** on the Users page (`users-page.js` — the `#users/{id}` page's connectors section, with the plugin grants right below it), so "who has what" has a single surface |
| `shared/connector-common.js` | (helpers) | Shared Connectors vocabulary: `statusOf` (incl. `needs_login` for a pending OAuth row), `STATUS_LABEL`, schema normalization, `jf` fetch |
| `llm-providers.js` | `<llm-providers-page>` | LLM provider management |