Files, open beside the conversation

A third side panel, built the way the terminal is and filled the way the
inspector is: tabs holding open files. Project files over SFTP in an agent
chat; notes, skills, knowledge documents, this chat's text attachments and its
own scratch document everywhere. Read with pygments, edited in a plain
textarea, saved with a conflict check.

A bug found on the way in, and the reason this needed its own read path.
`ssh.read_file` ends in `clean_output`, which strips ANSI escapes and decodes
with errors="replace" -- right for the output of a command, and fatal for an
editor: open a file containing an escape byte, press Save, and you have
silently rewritten it with the escapes gone and every undecodable byte replaced
by U+FFFD. `read_text`/`write_text` decode strictly, report binary rather than
mangling it, carry an mtime:size token for a file that moved underneath, and
refuse an oversize write rather than truncating -- `write_file` truncates
because a model is told how many bytes it wrote, and somebody pressing Save is
not. The model-facing pair is untouched: what it returns is a contract a model
has been shown. A truncated read opens read-only for the mirror-image reason.

Six sources go through one dispatch table, for the reason tool_labels.py is a
table: six independently written permission checks is how one ends up written
slightly differently, and that failure looks like editing somebody else's note.

A save on a project file bypasses agent/policy.py, which makes it the fourth
documented exception to "the modes do not govern the keyboard" and the first
that writes. Same argument as the terminal panel -- whoever owns the credential
could write the file with scp -- but the consequence is larger and is now said
out loud rather than left to be inferred.

The model opens tabs from the file tools it was already calling, so no new
schema and no tokens. It never brings one to the front: an agent reads forty
files in a long reply, and taking the screen each time would drag somebody
through all of them and lose any edit in progress. Only the strip is streamed,
guarded on truthiness so the frame can never blank itself -- an empty one would
close every open tab, the approval card you could press twice with the sign
reversed. Both halves are settled on the server, which is why canvas.js needs
no guard against a swap at all.

No vendored editor. CodeMirror 6 needs a bundler, which is hard rule 1;
CodeMirror 5 would be a larger payload than xterm on every page, and xterm is
the one heavy dependency precisely because it loads only where it can be used.
So: server-rendered highlighting for reading, a textarea for writing, and the
panel says there is no colour while you type rather than pretending.

Also here: a scratch document per chat, with `scratch_write` at RISK_READ on
plan_update's argument, and a test pinning the three numbers that decide a
panel's width -- LAYOUT_BOUNDS drops an unknown variable silently, so a panel
missing from it has a drag handle that works and forgets.

Driven under a DOM stub and against the running application.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-04 09:21:03 +02:00
parent 50270e13f7
commit fd4db76c64
38 changed files with 3125 additions and 27 deletions
+132 -12
View File
@@ -20,7 +20,7 @@ lembas info # paths + counts, useful when confused
lembas secret-key # generate LEMBAS_SECRET_KEY
lembas create-admin # create or promote an admin
pytest # 1231 tests, ~75s
pytest # 1394 tests, ~85s
# PLAN.md tracks what is and is not built
ruff check . # lint (line length 100)
python scripts/build_artwork.py # regenerate artwork (SVG + PWA icons;
@@ -133,6 +133,8 @@ src/lembas/
files.py attachment validation, images, PDF/text extraction
reasoning.py splits thinking from the answer
settings_store.py runtime instance settings
canvas.py what is open in the canvas panel, and where it comes from
scratch.py a chat's own working document
uploads.py validated image storage
sse.py event framing
web/
@@ -140,6 +142,7 @@ src/lembas/
templates/ Jinja
static/ css, js, vendor, img, sw.js
js/commands.js the / table, and the keyboard that does the same jobs
js/canvas.js the canvas panel's three behaviours
js/composer.js the menu / and @ open, and the mirror that marks them
assets/ SVG masters and PWA icons (generated)
deploy/ systemd unit, nginx vhost, install/update scripts
@@ -265,11 +268,13 @@ means Markdown is re-rendered whole, which is required anyway -- a list or code
fence is only correct once its context exists.
**Two frames must be able to blank themselves, and the rest must not.**
`reasoning`, `tools` and `render` are only sent when they have something in
them, so a frame can never wipe what is on screen. `metrics`, `status` and
`ask` are sent on every version bump *including empty*, because each has to be
able to clear: an approval card that survived being answered would be a button
you could press twice.
`reasoning`, `tools`, `render` and `canvas` are only sent when they have
something in them, so a frame can never wipe what is on screen. `metrics`,
`status` and `ask` are sent on every version bump *including empty*, because
each has to be able to clear: an approval card that survived being answered
would be a button you could press twice. `canvas` is the sharpest case on the
other side — an empty one would close every tab somebody had open, which is the
same failure with the sign reversed.
**Stopping sets a flag the producer checks -- except while it is paused.**
`generation.request_stop()`; whatever arrived is kept and the message is marked
@@ -495,6 +500,39 @@ hours with nobody watching, so a restart rehydrates its watcher from the row
(`jobs.rehydrate`, in the lifespan) rather than forgetting the one thing the
feature promises. Cancelling a watcher never stops the detached remote job.
**A file a model reads and a file a person edits are not the same read.**
`ssh.read_file` ends in `base.clean_output`, which strips ANSI escape sequences
and decodes with `errors="replace"` — right for the output of a command, and
fatal for an editor: open a file containing an escape byte through it, press
Save, and you have silently rewritten it with the escapes gone and every
undecodable byte replaced by U+FFFD. `ssh.read_text`/`write_text` are Canvas's
own pair — strict decoding, `binary` reported rather than mangled, a `mtime:size`
token for detecting a file that moved underneath, and **oversize refused rather
than truncated**, because `write_file` truncates and a model is told how many
bytes it wrote while somebody pressing Save is not. The model-facing two are
deliberately untouched: what they return is a contract a model has been shown.
A truncated *read* opens read-only for the mirror-image reason — saving back the
first 256KB of a larger file is how the rest of it is deleted.
**Canvas is six sources behind one shape**, dispatched through one table in
`services/canvas.py` for the reason `tool_labels.py` and `sharing.RESOURCE_TYPES`
are tables: six independently written permission checks is how one of them ends
up written slightly differently, and the way *that* failure shows up is somebody
editing somebody else's note. A tab key is `"<source>:<ref>"`, split with
`partition` because a path may contain a colon. `path_key` is lifted out of
`agent/tools.py:_path_key` and shared, so a tab a model opened and one a person
opened are one tab rather than two spellings of the same file.
**A model fills the canvas strip; a person decides what is in front.**
`open_tab(..., activate=False)` is what the generation loop passes, and it is
the whole of how the panel avoids being unusable: an agent reads forty files in
a long reply, and taking the screen each time would drag somebody through all of
them and lose any edit in progress. Eviction at `MAX_TABS` never closes the tab
in front. Only the *strip* is streamed — pushing the contents would overwrite a
textarea somebody is typing in — which is also why `canvas.js` needs no guard
against a swap: both halves are settled on the server, where they cannot be lost
to a race.
**Files never go through a shell.** The SSH exec protocol carries one command
*string* that the far side parses, with no argv form at all, so a model-supplied
path in a command line is unavoidably a quoting problem. `file_read`/`file_write`
@@ -800,6 +838,57 @@ folder of contracts into the window would cost the context on every request
forever to answer one question. It therefore needs an existing chat, so it is
absent on the new-chat screen — the same reason project files are.
**The sidebar shows one kind at a time.** `Chat.kind` distinguishes an agent
chat everywhere except the one place a person looked. The switch is stored on
the account, and three things about it are not the obvious version. It lives
*inside* the fragment it swaps, or the two buttons would go on showing the side
you had just left. `Folder.shown_in` hides a folder the filter emptied and keeps
one that was empty to begin with — the second is a container somebody just made,
and hiding it means it can never be found again, let alone filed into. And with
agent chats switched off there is no switch and no filtering at all, rather than
one side of a fork nobody can move: an administrator turning the feature off
would otherwise strand whoever last left it on Agents in an empty sidebar.
**A command can be corrected before it is allowed, and the edit lands in exactly
one place.** `arguments` is the list `_run_calls` hands to `run_tool` as
`parsed=`, and `run_tool` never re-parses — so writing into it inside
`_authorise` is the only mutation the runner sees. Editing the `Item` does
nothing: it is frozen and display-only. Two things move with it. The raw
`call["arguments"]` string is rewritten, and the assistant turn is built **after**
`_authorise` rather than before it, or the model is told it ran what it proposed
while something else ran and every later round reasons from a transcript that is
quietly false. And `_remember_always` reads the edit, or "always allow this"
stores a standing permission for a command nobody approved — it still derives
the pattern itself through `policy.subject`, which yields nothing for a composed
command line. The box is offered only where the detail *is* an argument
(`tool_labels.DETAIL_KEYS`); anything whose detail is a `k=repr(v)` summary
cannot be put back, and a box there would silently change nothing.
**htmx's `hx-prompt` cannot be intercepted, and `hx-confirm` can.** htmx calls
the browser's `prompt()` synchronously and *then* fires `htmx:prompt` with the
answer already in hand, so cancelling the event only aborts the request and the
grey box appears regardless — `htmx:confirm` fires before, which is why that one
works. `data-prompt` in `ui.js` follows the `data-confirm-button` shape instead:
swallow the click, ask in the themed dialog, write the answer into `hx-vals`,
click again behind a guard flag. `JSON.stringify`, never concatenation, or a
folder called `"` produces `hx-vals` that does not parse and the request goes out
with the field missing rather than with the name. There is a test that no
template brings `hx-prompt` back.
**An agent chat is named from its first prompt and costs no model call.**
Somebody starting one states an objective, not a topic. An ordinary chat opens
with a question whose *answer* is what makes a title worth asking a model for,
and is unchanged. `update_chat` answers a rename with the same out-of-band pair
the `done` frame sends, so one response moves the heading and the sidebar row —
only on a rename, because sending it for every PATCH would overwrite the heading
from an unrelated save.
**Three numbers per panel decide a width, in three files, and two of them fail
silently.** `LAYOUT_BOUNDS` drops an unknown CSS variable on purpose, so a panel
missing from it has a drag handle that appears to work and forgets by the next
page load. The allowlist's lower bound, the handle's `data-resize-min` and the
`--*-width-min` token are pinned equal by `tests/test_layout_bounds.py`.
**Unread is polled, not pushed.** A browser on another chat has no connection
to the one that finished. `/api/chats/unread` returns out-of-band dot spans and
an `HX-Trigger` for the toast; `unread_notified` stops the same arrival being
@@ -843,11 +932,26 @@ already been a bug once.
`position` order. Pinned models get shortcuts in the chat sidebar and nothing
else -- a picker whose order differs from the admin screen is just confusing.
**System prompts are precedence, not concatenation.** chat > model > instance,
most specific wins outright (`services/chat.py:effective_system_prompt`).
Stacking them reads well in a settings screen and badly in practice: two layers
that disagree give the model contradictory instructions and nobody can tell
which is losing.
**System prompts are precedence, not concatenation.** chat > folder > model >
instance, most specific wins outright
(`services/chat.py:effective_system_prompt`). Stacking them reads well in a
settings screen and badly in practice: two layers that disagree give the model
contradictory instructions and nobody can tell which is losing.
The folder rung goes *above* the model deliberately: a model's prompt describes
the model wherever it is used, a folder's describes this piece of work whichever
model is pointed at it. It is read when a reply is built and never copied onto
the chat, so editing a folder reaches the chats already in it, and the walk up
the parents is bounded and cycle-safe because it runs on the request path.
`api/pages.py` mirrors the ladder for the settings panel's "inherited from"
hint, and has to keep mirroring it layer for layer — a panel naming the wrong
source is worse than one naming none, because it is believed.
A folder's other settings are **seeds**, copied by `_new_chat` into whatever the
request left empty and nothing it filled in: the folder says what this work
usually needs, the screen in front of somebody says what they want this time.
`Folder.ssh_profile_id` is a plain string rather than a ForeignKey, for the
reason `compacted_through_id` is, and is validated on read.
**JSON columns need reassignment.** `user.settings_json["theme"] = x` on a
plain dict is not detected. The columns use `MutableDict` (`db/types.py`), but
@@ -1266,7 +1370,7 @@ construction, while decoding each frame server-side would corrupt every
boundary. Only `resize`, `ready`, `closed` and `error` are text, and they are
JSON.
**The modes do not govern the keyboard, and now there are three exceptions, not
**The modes do not govern the keyboard, and now there are four exceptions, not
one.** `agent/policy.py` exists because a model reads pages, files and command
output it did not write and can be talked into things. A person typing into the
terminal panel holds the credential already and could open the same shell with
@@ -1281,6 +1385,22 @@ There is a test named after the first one, because it reads like a bug next to
`policy.py` and "fixing" it would make the panel useless in the mode people
spend the most time in.
The fourth is **Canvas saving a project file**, and it is the first of the four
that *writes*. Same argument — whoever owns the credential could write the file
with `scp` — but the consequence is larger and should not be inferred from the
other three: in Plan mode, "look but do not touch" is a promise about the model
and not about the panel. The gate is `canvas.agent_ready`, everything
`_terminal_enabled` checks except `agent.terminal`, and re-derived on every
request rather than trusted from the template flag of the same name.
**Editing a command on an approval card is not a fifth exception, and the reason
matters.** The deny list resolves to `ASK`, not to a refusal — it means "always
ask about this" — so a person who has typed the command themselves and pressed
Allow *is* the asking it was demanding, and re-checking would put the same card
up with no way past it. The instance's list still governs the model, because
`decide` reads it before the allow list, so a pattern "always allow" remembered
from an edit cannot widen past it.
**A control wired to a method its route does not serve fails silently.** The
agent-mode select posted with `hx-post` against a route that only answers
`PATCH`, so every change returned 405 and the mode never moved — for the whole