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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.")
|
||||
|
||||
Reference in New Issue
Block a user