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:
@@ -24,6 +24,7 @@ from lembas.api.deps import Db, RequiredUser, require_permission
|
||||
from lembas.api.pages import sidebar_context
|
||||
from lembas.db.models import AUTH_METHODS, AUTH_PASSWORD, SshProfile
|
||||
from lembas.services import settings_store
|
||||
from lembas.services.agent import draft as draft_service
|
||||
from lembas.services.agent import index as index_service
|
||||
from lembas.services.agent import jobs as jobs_service
|
||||
from lembas.services.agent import ssh as ssh_service
|
||||
@@ -276,6 +277,23 @@ def _job_chat(db: Db, user: RequiredUser, chat_id: str):
|
||||
return chat, agent_session.resolve(db, chat, user)
|
||||
|
||||
|
||||
@router.get("/api/agents/{profile_id}/draft")
|
||||
async def draft_target(db: Db, user: RequiredUser, profile_id: str, dir: str = ""):
|
||||
"""The id the panels should use for a chat that does not exist yet.
|
||||
|
||||
Hung off the profile rather than the chat for the reason `browse` is: the
|
||||
caller is the *new*-chat composer, where the connection and the directory
|
||||
are the things being chosen. Ownership of the profile is the whole
|
||||
authorisation, as everywhere else in this module.
|
||||
|
||||
Deterministic, so asking twice for the same target gives the same id and
|
||||
finds the shell already running there rather than opening a second one.
|
||||
"""
|
||||
profile = _profile(db, user, profile_id)
|
||||
draft = draft_service.remember(user.id, profile.id, dir or profile.default_dir or "")
|
||||
return {"id": draft.id, "dir": draft.project_dir}
|
||||
|
||||
|
||||
@router.get("/api/chats/{chat_id}/jobs")
|
||||
async def jobs_chip(request: Request, db: Db, user: RequiredUser, chat_id: str):
|
||||
"""How many jobs are running, as the chip in the composer row.
|
||||
|
||||
Reference in New Issue
Block a user