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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user