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
co-authored by Claude Opus 5
parent 30ddcba787
commit a63723713f
14 changed files with 853 additions and 14 deletions
+15 -1
View File
@@ -574,6 +574,7 @@ document.addEventListener("lembas:notify", function (event) {
if (dirLabel) dirLabel.textContent = value ? baseName(value) : "the login directory";
var button = dirLabel && dirLabel.closest("[data-dir-browse]");
if (button) button.title = value || "The connection's own login directory";
announce();
}
function sync() {
@@ -588,13 +589,25 @@ document.addEventListener("lembas:notify", function (event) {
return (option && option.dataset.dir) || "";
}
/* Which machine and which directory the panels should be looking at.
Dispatched rather than read, because nothing here owns the terminal or
the canvas -- and because assigning to a hidden field's `.value` fires no
event of its own, so `setDir` has to say so out loud. */
function announce() {
var chosen = kind && kind.value === "agent" ? (picker ? picker.value : "") : "";
document.dispatchEvent(new CustomEvent("lembas:agent-target", {
detail: { profileId: chosen, projectDir: dir ? dir.value : "" }
}));
}
root.addEventListener("change", function (event) {
if (event.target.name === "kind_choice") sync();
if (event.target.name === "kind_choice") { sync(); announce(); }
// Following the profile's own directory is a convenience, not a rule:
// once someone has chosen their own it is left alone.
if (event.target === picker && dir && !dir.dataset.touched) {
setDir(profileDefault());
}
if (event.target === picker) announce();
});
root.addEventListener("click", function (event) {
@@ -614,6 +627,7 @@ document.addEventListener("lembas:notify", function (event) {
so the two disagreed and the box offered a directory on a machine the
chat was not going to use. */
setDir(profileDefault());
announce();
}
/* The same directory picker, on a form that is not the composer.