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:
+17
-2
@@ -173,6 +173,13 @@ def _agent_context(db: DBSession, user: User, chat: Chat | None) -> dict:
|
||||
current = db.get(SshProfile, chat.ssh_profile_id)
|
||||
if current is not None and current.owner_id != user.id:
|
||||
current = None
|
||||
elif chat is None and profiles:
|
||||
# The new-chat screen. Which connection is *chosen* is a decision being
|
||||
# made in the browser, so the server cannot know it -- what it can say is
|
||||
# that there is one to choose, which is all the panels need in order to
|
||||
# exist. They are pointed at a target by `lembas:agent-target`, and show
|
||||
# nothing until they are.
|
||||
current = profiles[0]
|
||||
|
||||
return {
|
||||
"agent_profiles": profiles,
|
||||
@@ -194,7 +201,13 @@ def _agent_context(db: DBSession, user: User, chat: Chat | None) -> dict:
|
||||
# total gate would remove a working feature because one source is
|
||||
# unavailable. Absent on the new-chat screen for the reason the scope
|
||||
# menu is: there is no row yet to hang a tab on.
|
||||
"canvas_enabled": chat is not None,
|
||||
# Also before the chat exists, where it opens on the connection being
|
||||
# chosen in the composer. That reverses an earlier decision -- "there is
|
||||
# no row yet to hang a tab on" -- which was true of the *storage* and
|
||||
# was never a reason to withhold the panel: a draft holds its tabs in
|
||||
# memory and hands them over when the chat is created. See
|
||||
# services/agent/draft.py.
|
||||
"canvas_enabled": chat is not None or bool(profiles),
|
||||
# And whether it may *also* reach project files. Re-derived server-side
|
||||
# on every canvas request; this flag only decides what the panel offers.
|
||||
"canvas_agent": canvas_service.agent_ready(db, user, chat) is not None,
|
||||
@@ -234,7 +247,9 @@ def _terminal_enabled(db: DBSession, user: User, chat: Chat | None, profile) ->
|
||||
from lembas.db.models import KIND_AGENT
|
||||
from lembas.services.agent import ssh as ssh_service
|
||||
|
||||
if chat is None or chat.kind != KIND_AGENT or profile is None:
|
||||
# `chat is None` is the new-chat screen, which may open a shell on the
|
||||
# connection being chosen there. Everything else still has to hold.
|
||||
if profile is None or (chat is not None and chat.kind != KIND_AGENT):
|
||||
return False
|
||||
if not permissions.has(db, user, "agent.terminal"):
|
||||
return False
|
||||
|
||||
Reference in New Issue
Block a user