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:
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
Pointing the terminal and the canvas at a chat that does not exist yet.
|
||||
|
||||
Both panels take their identity from the server: the terminal from
|
||||
`data-url`, the canvas from URLs rendered inside the fragments it swaps in.
|
||||
Neither builds a URL from parts. So all this has to do is get a *draft id* for
|
||||
whatever the composer currently has selected, put it where the panels look,
|
||||
and ask them to reload.
|
||||
|
||||
Only ever on the new-chat screen -- a chat that exists already has an id and
|
||||
nothing here runs. See services/agent/draft.py for what a draft is and how it
|
||||
is adopted when the first prompt turns it into a real chat.
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var composer = document.querySelector(".composer");
|
||||
if (!composer || composer.dataset.chatId) return;
|
||||
|
||||
var current = "";
|
||||
|
||||
/* The id travels with the first message so the server knows which draft to
|
||||
adopt. A hidden field rather than a header, because it has to arrive as
|
||||
part of the form that creates the chat. */
|
||||
function field() {
|
||||
var form = document.querySelector(".composer__form");
|
||||
if (!form) return null;
|
||||
var found = form.querySelector('input[name="draft_id"]');
|
||||
if (!found) {
|
||||
found = document.createElement("input");
|
||||
found.type = "hidden";
|
||||
found.name = "draft_id";
|
||||
form.appendChild(found);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
function point(id) {
|
||||
if (id === current) return;
|
||||
current = id;
|
||||
|
||||
var hidden = field();
|
||||
if (hidden) hidden.value = id;
|
||||
|
||||
/* The terminal: one write, because every consumer re-reads `dataset.url`
|
||||
per call rather than caching it. Reconnecting is the panel's own job --
|
||||
it exposes `repoint` so the socket is closed and reopened by the code
|
||||
that owns the closing flag. */
|
||||
var terminal = document.querySelector("[data-terminal]");
|
||||
if (terminal) {
|
||||
terminal.dataset.url = id ? "/api/chats/" + id + "/terminal/ws" : "";
|
||||
if (window.lembas && window.lembas.repointTerminal) {
|
||||
window.lembas.repointTerminal();
|
||||
}
|
||||
}
|
||||
|
||||
/* The canvas builds no URLs in JavaScript at all -- they are all inside the
|
||||
fragment. So re-pointing it is re-issuing the request that fetches that
|
||||
fragment, and letting the server render the new id into everything. */
|
||||
var inner = document.getElementById("canvas-inner");
|
||||
if (inner && id && window.htmx) {
|
||||
inner.setAttribute("hx-get", "/api/chats/" + id + "/canvas");
|
||||
window.htmx.ajax("GET", "/api/chats/" + id + "/canvas", {
|
||||
target: "#canvas-inner",
|
||||
swap: "innerHTML"
|
||||
});
|
||||
}
|
||||
var panel = document.querySelector("[data-canvas]");
|
||||
if (panel) panel.dataset.chat = id;
|
||||
}
|
||||
|
||||
document.addEventListener("lembas:agent-target", function (event) {
|
||||
var target = event.detail || {};
|
||||
if (!target.profileId) {
|
||||
point("");
|
||||
return;
|
||||
}
|
||||
/* One round trip per change of target, and the answer is stable: the id is
|
||||
derived from (owner, connection, directory), so coming back to a target
|
||||
finds the shell already running there rather than opening a second. */
|
||||
fetch(
|
||||
"/api/agents/" + encodeURIComponent(target.profileId) +
|
||||
"/draft?dir=" + encodeURIComponent(target.projectDir || ""),
|
||||
{ credentials: "same-origin" }
|
||||
)
|
||||
.then(function (response) { return response.ok ? response.json() : null; })
|
||||
.then(function (body) { if (body && body.id) point(body.id); })
|
||||
.catch(function () { /* no panels rather than a broken one */ });
|
||||
});
|
||||
})();
|
||||
@@ -463,6 +463,26 @@
|
||||
document.addEventListener("lembas:theme", function () {
|
||||
if (term) term.options.theme = readTheme();
|
||||
});
|
||||
|
||||
/* The panel now belongs to a different shell: the new-chat screen changed
|
||||
connection or directory, so `dataset.url` has been rewritten and the
|
||||
socket is pointed at the wrong machine. Closing is deliberate -- hence
|
||||
the flag, which is what stops "Disconnected" being reported for something
|
||||
nobody lost -- and the screen is cleared because this really is a
|
||||
different shell, unlike a reconnect to the same one.
|
||||
|
||||
Reconnecting is left to the panel being opened, so a target changed while
|
||||
the terminal is shut costs nothing. */
|
||||
window.lembas = window.lembas || {};
|
||||
window.lembas.repointTerminal = function () {
|
||||
if (socket) {
|
||||
closedOnPurpose = true;
|
||||
socket.close();
|
||||
socket = null;
|
||||
}
|
||||
if (term) term.reset();
|
||||
if (panel && !panel.hidden && panel.dataset.url) connect();
|
||||
};
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user