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 fe43e95b79
commit 4643d1b584
16 changed files with 902 additions and 16 deletions
+18
View File
@@ -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.
+41 -3
View File
@@ -21,6 +21,7 @@ from lembas.api.deps import Db, RequiredUser
from lembas.db.models import Chat, User
from lembas.services import canvas as canvas_service
from lembas.services import generation as generation_service
from lembas.services.agent import draft as draft_service
from lembas.services.agent.base import Conflict
from lembas.services.markdown import highlight_code, render_markdown
from lembas.web.templating import templates
@@ -32,13 +33,38 @@ router = APIRouter(prefix="/api/chats", tags=["canvas"])
def _owned_chat(db: DBSession, chat_id: str, user_id: str) -> Chat:
"""404 rather than 403 for somebody else's chat: whether it exists at all is
not this account's business."""
not this account's business.
A draft id resolves to a transient `Chat` -- constructed, never saved --
which is what lets the canvas work on the new-chat screen without any of the
six sources learning that drafts exist. See services/agent/draft.py.
"""
if draft_service.is_draft(chat_id):
draft = draft_service.get(chat_id, user_id)
if draft is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That chat no longer exists.")
return draft_service.as_chat(draft)
chat = db.get(Chat, chat_id)
if chat is None or chat.user_id != user_id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That chat no longer exists.")
return chat
def _remember_tabs(chat: Chat, state: dict) -> bool:
"""Put the tab strip back where it came from. True when it was a draft.
A draft's tabs live in the registry rather than on a row, so the two write
paths below fork here rather than each remembering to check.
"""
if not draft_service.is_draft(chat.id):
return False
draft = draft_service.get(chat.id, chat.user_id)
if draft is not None:
draft.canvas_json = dict(state or {})
return True
async def _panel(
request: Request,
db: DBSession,
@@ -121,6 +147,16 @@ async def open_tab(
"""
chat = _owned_chat(db, chat_id, user.id)
# Two of the six sources need a real row behind them, and one of those is a
# hole rather than an inconvenience -- see draft.SOURCES_NEEDING_A_CHAT.
# Refused by source name, here, rather than left to fall out of an id
# comparison somewhere further in.
if draft_service.is_draft(chat.id) and draft_service.refuses(key.split(":", 1)[0]):
return await _panel(
request, db, user, chat,
message="That can only be opened once this chat exists. Send a message first.",
)
try:
doc = await canvas_service.load(db, user, chat, key)
except canvas_service.Refused as exc:
@@ -133,7 +169,8 @@ async def open_tab(
# Reassigned rather than mutated: an in-place edit of a JSON column is not
# reliably detected as a change.
chat.canvas_json = state
db.commit()
if not _remember_tabs(chat, state):
db.commit()
# A reply running right now holds its own snapshot, seeded when it started.
# Without this the next frame it sends would contradict what was just
@@ -151,7 +188,8 @@ async def close_tab(
):
chat = _owned_chat(db, chat_id, user.id)
chat.canvas_json = canvas_service.close_tab(dict(chat.canvas_json or {}), key)
db.commit()
if not _remember_tabs(chat, chat.canvas_json):
db.commit()
live = generation_service.running_for(chat.id)
if live is not None:
+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)
+17 -2
View File
@@ -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
+18 -4
View File
@@ -35,6 +35,7 @@ from lembas.db.session import session_scope
from lembas.security import permissions
from lembas.security.sessions import COOKIE_NAME, resolve_session
from lembas.services import settings_store
from lembas.services.agent import draft as draft_service
from lembas.services.agent import session as agent_session
from lembas.services.agent import terminal as terminal_service
from lembas.services.agent.base import ExecError
@@ -75,6 +76,20 @@ def _same_origin(websocket: WebSocket) -> bool:
return urlsplit(origin).netloc.lower() == host.lower()
def _chat_or_draft(db, user, chat_id: str):
"""The chat this panel belongs to, real or still being decided.
A draft resolves to a transient `Chat` -- see services/agent/draft.py --
which is what lets the terminal open on the new-chat screen without
`_prepare` or `agent_session.resolve` learning that drafts exist.
"""
if draft_service.is_draft(chat_id):
draft = draft_service.get(chat_id, user.id)
return draft_service.as_chat(draft) if draft is not None else None
chat = db.get(Chat, chat_id)
return chat if chat is not None and chat.user_id == user.id else None
def _prepare(db, user, chat_id: str) -> tuple[str, dict]:
"""Everything that has to be true, and what opening needs. One or the other.
@@ -85,8 +100,8 @@ def _prepare(db, user, chat_id: str) -> tuple[str, dict]:
if not permissions.has(db, user, "agent.terminal"):
return "You do not have permission to open a terminal.", {}
chat = db.get(Chat, chat_id)
if chat is None or chat.user_id != user.id:
chat = _chat_or_draft(db, user, chat_id)
if chat is None:
return "That chat no longer exists.", {}
if chat.kind != KIND_AGENT:
return "This is an ordinary chat, so it has no machine to open a shell on.", {}
@@ -205,8 +220,7 @@ async def last_command(db: Db, user: RequiredUser, chat_id: str) -> dict:
buffer could not produce it anyway: it holds what is on screen, hard-wrapped
at the terminal's width, with no way to tell a wrap from a newline.
"""
chat = db.get(Chat, chat_id)
if chat is None or chat.user_id != user.id:
if _chat_or_draft(db, user, chat_id) is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That chat no longer exists.")
if not permissions.has(db, user, "agent.terminal"):
raise HTTPException(status.HTTP_403_FORBIDDEN, "You cannot open a terminal.")