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 30ddcba787
commit a63723713f
14 changed files with 853 additions and 14 deletions
+51
View File
@@ -40,6 +40,7 @@ from lembas.services import prompts as prompts_service
from lembas.services import steps as steps_service
from lembas.services import tokens as tokens_service
from lembas.services import tools as tools_service
from lembas.services.agent import draft as draft_service
from lembas.services.agent import policy as agent_policy
from lembas.services.agent import terminal as terminal_service
from lembas.services.markdown import escape_text, render_markdown
@@ -81,6 +82,53 @@ def _owned_chat(db: DBSession, chat_id: str, user_id: str) -> Chat:
return chat
def _adopt_draft(db: DBSession, user: User, draft_id: str, chat: Chat) -> None:
"""Hand the new-chat screen's shell and open files to the chat it became.
Between `_new_chat` and the first message deliberately: the chat has an id by
here, and `generation.ensure` below has not yet started a reply that would
read `chat.canvas_json`.
The shell is only adopted when it is a shell on the same target. `_new_chat`
settles `project_dir` last -- an empty one falls back to the connection's own
login directory -- so the comparison is against the chat as resolved, never
against what the form said. On a mismatch the session is left alone rather
than transplanted onto a chat that says it runs somewhere else; it belongs to
whatever draft it was opened under and is reaped on idle.
"""
if not draft_id or not draft_service.is_draft(draft_id):
return
draft = draft_service.get(draft_id, user.id)
if draft is None:
return
matches = (
chat.kind == KIND_AGENT
and draft.profile_id == (chat.ssh_profile_id or "")
and draft.project_dir == (chat.project_dir or "")
)
if not matches:
return
session = terminal_service.peek(draft_id)
if session is not None:
terminal_service.rekey(draft_id, chat.id)
# Only what a chat can actually reopen. A tab whose source needs a row it
# never had is dropped rather than carried across to fail on first click.
tabs = dict(draft.canvas_json or {})
kept = [
tab
for tab in tabs.get("tabs") or []
if not draft_service.refuses(str(tab.get("key", "")).split(":", 1)[0])
]
if kept:
chat.canvas_json = {**tabs, "tabs": kept}
db.commit()
draft_service.forget(draft_id)
def _new_chat(
db: DBSession,
user: User,
@@ -202,6 +250,7 @@ async def start_chat(
project_dir: str = Form(""),
agent_mode: str = Form(""),
reasoning_effort: str = Form(""),
draft_id: str = Form(""),
) -> Response:
"""Create a chat from its first message.
@@ -227,6 +276,8 @@ async def start_chat(
reasoning_effort=reasoning_effort,
)
_adopt_draft(db, user, draft_id, chat)
user_message = chat_service.create_message(db, chat, ROLE_USER, content)
if file_ids:
files_service.claim(db, ids=file_ids, user_id=user.id, message_id=user_message.id)