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