Look around the machine before deciding to talk about it

The terminal and the canvas both needed a Chat, so they were missing from the
one screen where you are choosing which machine to work on. A draft is the
smallest thing that fixes it: an id, and the three facts behind it.

The trick is that a draft resolves to a *transient* Chat -- constructed, never
added to a session. `canvas.agent_ready`, `_executor`, `_load_agent`, `_save_agent`
and `agent_session.resolve` read exactly four attributes between them and none
of them queries or writes the row, so all of it works unchanged and nothing had
to learn what a draft is. Proven against a real sshd rather than a stub: a
transient chat opens and saves a project file over the same SFTP path a real one
uses, and the database stays empty throughout.

Chats are still created lazily. A draft is not a chat and never becomes one;
when the first prompt makes the real one, the shell is re-keyed into it and the
open tabs are copied across. `terminal.rekey` moves the registry key *and*
`session.chat_id`, because close_for_profile, close_for_owner and the reaper all
pop by the field -- a stale one would leave a dead session that `get` keeps
handing out. The shell is only adopted when its profile and directory match the
chat as finally resolved, since `_new_chat` settles an empty directory to the
connection's own; otherwise it is left alone rather than transplanted onto a
chat that says it runs elsewhere.

Two canvas sources are refused on a draft, by name, and one of them is a hole
rather than an inconvenience. `_load_file` authorises with
`attachment.chat_id != chat.id`, and an upload made on the new-chat screen is
stored with `chat_id=None` -- so a draft whose chat carried no id would make that
comparison `None != None`, which is False, and open every unclaimed attachment
its owner has. `as_chat` does set an id, so it already fails; the refusal is
stated anyway, because a guarantee that lives in an id-shaped coincidence is one
the next change breaks without noticing.

Adoption needed almost no JavaScript: start_chat already answers with
HX-Redirect, so the page reloads and the canvas adopts by construction while the
terminal reconnects to the re-keyed session and replays its scrollback -- the "a
reload is indistinguishable from a second tab" property working for us. What
re-points them mid-screen is a `lembas:agent-target` event, dispatched from
`setDir` and the connection select because assigning to a hidden field's value
fires nothing on its own. Driven under a DOM stub before committing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-04 21:33:34 +02:00
parent fe43e95b79
commit 4643d1b584
16 changed files with 902 additions and 16 deletions
+44 -1
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 # 1513 tests, ~92s
pytest # 1529 tests, ~93s
# 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;
@@ -1211,6 +1211,49 @@ Plain forms opt in with `data-confirm`, lone submit buttons with
plus the picker block in `ui.js`; the value lives in a hidden input so it still
behaves as a form field.
**The panels work before the chat does, and a draft is how.** The terminal and
the canvas both needed a `Chat`, which meant they were unavailable on the one
screen where you are deciding *which machine to work on*. `services/agent/draft.py`
holds an id and the three facts behind it -- owner, connection, directory -- and
`as_chat` builds a **transient `Chat`**, constructed and never added to a
session. That is the whole trick: `canvas.agent_ready`, `_executor`,
`_load_agent`/`_save_agent` and `agent_session.resolve` read only `user_id`,
`kind`, `ssh_profile_id` and `project_dir`, and none of them queries or writes
the row, so every one of them works unchanged and none had to learn what a draft
is. `id` and `canvas_json` are set explicitly: both are *column* defaults, which
SQLAlchemy applies at flush, and this row is never flushed.
The id is **derived** from (owner, connection, directory) rather than invented,
so returning to the same new-chat screen finds the shell already running there
instead of quietly opening a second. The owner is in the hash because two people
pointed at the same directory would otherwise share a *shell*.
**Two canvas sources are refused on a draft, by name.** `scratch` needs a row --
`scratch_service.for_chat` would write a `ScratchDoc` keyed on a chat that does
not exist. `file` is the one that matters: `_load_file` authorises with
`attachment.chat_id != chat.id`, and an upload made on the new-chat screen is
stored with `chat_id=None`, so a draft whose chat carried no id would make that
`None != None` -- False -- and open every unclaimed attachment its owner has.
`as_chat` does set an id, so the comparison already fails; the refusal is stated
anyway, because a guarantee that lives in an id-shaped coincidence is one the
next change breaks silently.
**Adoption is a re-key and a copy, and the redirect does most of it.**
`start_chat` already answers `204` + `HX-Redirect`, so the browser reloads and
both panels re-render with the real id -- the canvas adopts by construction,
since its URLs are server-rendered, and the terminal reconnects to the re-keyed
session and replays its scrollback. `terminal.rekey` moves **both the registry
key and `session.chat_id`**: `close_for_profile`, `close_for_owner` and the
reaper all pop by the field, so a stale one would leave a dead session that
`get` keeps handing out. The shell is adopted only when its profile and
directory match the chat **as finally resolved** -- `_new_chat` falls back to the
connection's login directory when the field is empty -- and otherwise left where
it is rather than transplanted onto a chat that says it runs somewhere else.
`lembas:agent-target` is what re-points them when the selection changes; `ui.js`
dispatches it from `setDir` and the connection select, because assigning to a
hidden field's `.value` fires nothing on its own.
**Chats are created lazily.** There is no endpoint that makes an empty chat.
"New chat" is a link to `/chat`, which renders a composer with no row behind
it; `POST /api/chats/start` writes the chat together with its first message.