diff --git a/CLAUDE.md b/CLAUDE.md index ee67ac3..1c7ec2c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,7 @@ lembas info # paths + counts, useful when confused lembas secret-key # generate LEMBAS_SECRET_KEY lembas create-admin # create or promote an admin -pytest # 1513 tests, ~92s +pytest # 1529 tests, ~93s # PLAN.md tracks what is and is not built ruff check . # lint (line length 100) python scripts/build_artwork.py # regenerate artwork (SVG + PWA icons; @@ -1211,6 +1211,49 @@ Plain forms opt in with `data-confirm`, lone submit buttons with plus the picker block in `ui.js`; the value lives in a hidden input so it still behaves as a form field. +**The panels work before the chat does, and a draft is how.** The terminal and +the canvas both needed a `Chat`, which meant they were unavailable on the one +screen where you are deciding *which machine to work on*. `services/agent/draft.py` +holds an id and the three facts behind it -- owner, connection, directory -- and +`as_chat` builds a **transient `Chat`**, constructed and never added to a +session. That is the whole trick: `canvas.agent_ready`, `_executor`, +`_load_agent`/`_save_agent` and `agent_session.resolve` read only `user_id`, +`kind`, `ssh_profile_id` and `project_dir`, and none of them queries or writes +the row, so every one of them works unchanged and none had to learn what a draft +is. `id` and `canvas_json` are set explicitly: both are *column* defaults, which +SQLAlchemy applies at flush, and this row is never flushed. + +The id is **derived** from (owner, connection, directory) rather than invented, +so returning to the same new-chat screen finds the shell already running there +instead of quietly opening a second. The owner is in the hash because two people +pointed at the same directory would otherwise share a *shell*. + +**Two canvas sources are refused on a draft, by name.** `scratch` needs a row -- +`scratch_service.for_chat` would write a `ScratchDoc` keyed on a chat that does +not exist. `file` is the one that matters: `_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 +`None != None` -- False -- and open every unclaimed attachment its owner has. +`as_chat` does set an id, so the comparison already fails; the refusal is stated +anyway, because a guarantee that lives in an id-shaped coincidence is one the +next change breaks silently. + +**Adoption is a re-key and a copy, and the redirect does most of it.** +`start_chat` already answers `204` + `HX-Redirect`, so the browser reloads and +both panels re-render with the real id -- the canvas adopts by construction, +since its URLs are server-rendered, and the terminal reconnects to the re-keyed +session and replays its scrollback. `terminal.rekey` moves **both the registry +key and `session.chat_id`**: `close_for_profile`, `close_for_owner` and the +reaper all pop by the field, so a stale one would leave a dead session that +`get` keeps handing out. The shell is adopted only when its profile and +directory match the chat **as finally resolved** -- `_new_chat` falls back to the +connection's login directory when the field is empty -- and otherwise left where +it is rather than transplanted onto a chat that says it runs somewhere else. + +`lembas:agent-target` is what re-points them when the selection changes; `ui.js` +dispatches it from `setDir` and the connection select, because assigning to a +hidden field's `.value` fires nothing on its own. + **Chats are created lazily.** There is no endpoint that makes an empty chat. "New chat" is a link to `/chat`, which renders a composer with no row behind it; `POST /api/chats/start` writes the chat together with its first message. diff --git a/PLAN.md b/PLAN.md index 06509d0..d8e4391 100644 --- a/PLAN.md +++ b/PLAN.md @@ -7,7 +7,7 @@ that would be expensive to revisit. Kept current as work lands; the detail of **Status:** usable daily. Streaming chat, attachments, reasoning, tool calling with web search, custom HTTP tools and MCP servers, agent chats that work on a machine over SSH, a knowledge library, notes, memory and skills, speech in and -out, users and groups, model administration, installable as an app. 1513 tests, +out, users and groups, model administration, installable as an app. 1529 tests, `ruff` clean. --- @@ -131,6 +131,10 @@ be a different project, not a refactor. commands together. A deny pattern cannot either — so in Auto a compound line runs, which is the trade for Auto not asking about `cd build && make`. See CLAUDE.md; matching each segment would restore both and is not built +- [x] **The terminal and the canvas open before the chat exists** — on the + new-chat screen, against the connection and directory being chosen there, + and both re-point when that changes. The shell you opened and the files + you left open are adopted into the chat when you send the first prompt - [x] **Background jobs are visible** — a chip in the composer row counting what is still running, and a panel with each job's command, state, log tail and a Stop button. Survives a restart, because the job does diff --git a/src/lembas/api/agents.py b/src/lembas/api/agents.py index 6e33902..f67072e 100644 --- a/src/lembas/api/agents.py +++ b/src/lembas/api/agents.py @@ -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. diff --git a/src/lembas/api/canvas.py b/src/lembas/api/canvas.py index a28307e..3562de6 100644 --- a/src/lembas/api/canvas.py +++ b/src/lembas/api/canvas.py @@ -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: diff --git a/src/lembas/api/chats.py b/src/lembas/api/chats.py index 35f11de..e8bc481 100644 --- a/src/lembas/api/chats.py +++ b/src/lembas/api/chats.py @@ -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) diff --git a/src/lembas/api/pages.py b/src/lembas/api/pages.py index ad99bb9..0cc9755 100644 --- a/src/lembas/api/pages.py +++ b/src/lembas/api/pages.py @@ -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 diff --git a/src/lembas/api/terminal.py b/src/lembas/api/terminal.py index 8ff15e8..5ba043d 100644 --- a/src/lembas/api/terminal.py +++ b/src/lembas/api/terminal.py @@ -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.") diff --git a/src/lembas/services/agent/draft.py b/src/lembas/services/agent/draft.py new file mode 100644 index 0000000..d62f136 --- /dev/null +++ b/src/lembas/services/agent/draft.py @@ -0,0 +1,183 @@ +"""A chat that does not exist yet, so its panels can. + +Chats are created lazily -- there is no endpoint that makes an empty one, and +the row appears together with its first message. That is a rule worth keeping: +an opened-and-abandoned composer should leave nothing behind. But it also meant +the terminal and the canvas were unavailable on the one screen where you are +deciding *which machine to work on*, which is exactly when you want to look +around it first. + +A draft is the smallest thing that fixes that: an id, and the three facts the +panels need behind it. It is not a chat and never becomes one -- when the first +prompt is sent, a real chat is created and the draft's shell and tabs are +**adopted** into it, which is a re-key and a copy rather than a promotion. + +The id is derived from (owner, connection, directory) rather than invented, so +that returning to the same new-chat screen finds the same shell and the same +tabs instead of quietly starting a second one. It is a hash so that neither the +directory nor the owner is legible in a URL. +""" + +from __future__ import annotations + +import hashlib +import time +from dataclasses import dataclass, field +from typing import Any + +# How long a draft survives without being touched. Generous, because it is +# holding somebody's open files while they decide what to do; bounded, because +# nothing else will ever clean it up -- an abandoned new-chat screen leaves no +# row to cascade from and no chat to delete. +IDLE_TIMEOUT = 3600.0 + +# The prefix a draft id carries. It has to be distinguishable from a chat id at +# a glance and by code: `Chat.id` is 32 hex characters from `new_id`, so +# nothing here can collide with one by accident. +PREFIX = "draft_" + + +@dataclass +class Draft: + """What a draft knows, which is only what the panels ask for.""" + + id: str + owner_id: str + profile_id: str + project_dir: str + # The canvas's tab strip, in the shape `Chat.canvas_json` holds. In memory + # rather than on a row for the obvious reason, and carried onto the chat at + # adoption. + canvas_json: dict = field(default_factory=dict) + touched_at: float = field(default_factory=time.monotonic) + + +_DRAFTS: dict[str, Draft] = {} + + +def is_draft(chat_id: str) -> bool: + return bool(chat_id) and chat_id.startswith(PREFIX) + + +def key_for(owner_id: str, profile_id: str, project_dir: str) -> str: + """The id for one (owner, connection, directory), stably. + + Derived rather than random so that reopening the new-chat screen on the same + target finds the shell that is already running there. The owner is in the + hash so that two people pointed at the same directory of the same connection + do not share a draft -- they would share a *shell*, and the terminal's own + "one chat, one shell" rule is scoped to a person's chats. + """ + material = "\0".join((owner_id, profile_id, project_dir or "")) + digest = hashlib.sha256(material.encode("utf-8")).hexdigest() + return f"{PREFIX}{digest[:24]}" + + +def remember(owner_id: str, profile_id: str, project_dir: str) -> Draft: + """The draft for this target, created if this is the first time.""" + _sweep() + key = key_for(owner_id, profile_id, project_dir) + draft = _DRAFTS.get(key) + if draft is None: + draft = Draft( + id=key, owner_id=owner_id, profile_id=profile_id, project_dir=project_dir or "" + ) + _DRAFTS[key] = draft + draft.touched_at = time.monotonic() + return draft + + +def get(draft_id: str, owner_id: str) -> Draft | None: + """One draft, if it is this person's. + + The id is a hash of the owner, so a draft belonging to somebody else cannot + be guessed -- but it is checked rather than assumed, because "unguessable" + is not an authorisation and the next caller might build the id differently. + """ + draft = _DRAFTS.get(draft_id or "") + if draft is None or draft.owner_id != owner_id: + return None + draft.touched_at = time.monotonic() + return draft + + +def forget(draft_id: str) -> None: + _DRAFTS.pop(draft_id or "", None) + + +def clear() -> None: + _DRAFTS.clear() + + +def as_chat(draft: Draft) -> Any: + """A `Chat` the panels can use, constructed and never saved. + + This is the whole trick, and it is worth being precise about why it is safe. + `canvas.agent_ready`, `canvas._executor`, `_load_agent`/`_save_agent` and + `agent_session.resolve` read exactly four things off a chat -- `user_id`, + `kind`, `ssh_profile_id` and `project_dir` -- and none of them passes the + chat to a query or writes it back. So a transient row satisfies every one of + them unchanged, and no code that already works has to learn what a draft is. + + `id` and `canvas_json` are set explicitly: both are *column* defaults, which + SQLAlchemy applies at flush, and this row is never flushed. An unset `id` is + not a cosmetic problem -- see `SOURCES_NEEDING_A_CHAT`. + """ + from lembas.db.models import KIND_AGENT, Chat + + return Chat( + id=draft.id, + user_id=draft.owner_id, + kind=KIND_AGENT, + ssh_profile_id=draft.profile_id, + project_dir=draft.project_dir, + canvas_json=dict(draft.canvas_json or {}), + agent_mode="", + scope_json={}, + ) + + +# Canvas sources a draft may not open, refused by name. +# +# `scratch` needs a row: `scratch_service.for_chat` would write a `ScratchDoc` +# keyed on a chat that does not exist, which is the lazy-creation rule broken +# outright rather than bent. +# +# `file` is the one that matters. `canvas._load_file` authorises with +# `attachment.chat_id != chat.id`, and an upload made on the new-chat screen is +# stored with `chat_id=None`. If a draft's chat carried no id, `None != None` is +# False and every unclaimed attachment its owner has would open from any draft +# canvas. `as_chat` sets an id, so that comparison already fails -- but relying +# on it would mean the guarantee lives in an id-shaped coincidence. It is stated +# here instead, where it can be read and tested. +SOURCES_NEEDING_A_CHAT = frozenset({"scratch", "file"}) + + +def refuses(source: str) -> bool: + return source in SOURCES_NEEDING_A_CHAT + + +def _sweep() -> None: + """Drop drafts nobody has touched in a long while. + + On write rather than on a timer: a draft holds no connection and no process, + only a little state, so there is nothing to close and nothing that leaks by + being late. The shell it points at has its own reaper. + """ + cutoff = time.monotonic() - IDLE_TIMEOUT + for key in [k for k, d in _DRAFTS.items() if d.touched_at < cutoff]: + _DRAFTS.pop(key, None) + + +__all__ = [ + "SOURCES_NEEDING_A_CHAT", + "Draft", + "as_chat", + "clear", + "forget", + "get", + "is_draft", + "key_for", + "refuses", + "remember", +] diff --git a/src/lembas/services/agent/terminal.py b/src/lembas/services/agent/terminal.py index a69c76c..4302870 100644 --- a/src/lembas/services/agent/terminal.py +++ b/src/lembas/services/agent/terminal.py @@ -620,6 +620,30 @@ async def close_chat(chat_id: str, reason: str = CLOSED_REVOKED) -> bool: return True +def rekey(old: str, new: str) -> Session | None: + """Move a live session from one id to another, keeping the shell. + + What adoption is made of: a shell opened on the new-chat screen under a + draft id becomes the shell of the chat that screen turned into, with its + scrollback and whatever is half-typed at its prompt. Nothing reconnects -- + the browser navigates after `start_chat` and attaches to the session now + living under the real id, which is the "a reload is indistinguishable from a + second tab" property working for us rather than against us. + + **Both the key and the field.** `close_for_profile`, `close_for_owner` and + the reaper all pop by `session.chat_id` rather than by the key they found it + under, so a stale field would leave a closed session in the registry that + `get` keeps handing out and `count_for` keeps counting. + """ + session = _SESSIONS.pop(old, None) + if session is None: + return None + session.chat_id = new + _SESSIONS[new] = session + log.info("terminal adopted %s -> %s", old, new) + return session + + async def close_for_profile(profile_id: str) -> int: """End every shell opened on one connection. diff --git a/src/lembas/web/static/js/draft.js b/src/lembas/web/static/js/draft.js new file mode 100644 index 0000000..944befb --- /dev/null +++ b/src/lembas/web/static/js/draft.js @@ -0,0 +1,90 @@ +/* + Pointing the terminal and the canvas at a chat that does not exist yet. + + Both panels take their identity from the server: the terminal from + `data-url`, the canvas from URLs rendered inside the fragments it swaps in. + Neither builds a URL from parts. So all this has to do is get a *draft id* for + whatever the composer currently has selected, put it where the panels look, + and ask them to reload. + + Only ever on the new-chat screen -- a chat that exists already has an id and + nothing here runs. See services/agent/draft.py for what a draft is and how it + is adopted when the first prompt turns it into a real chat. +*/ +(function () { + "use strict"; + + var composer = document.querySelector(".composer"); + if (!composer || composer.dataset.chatId) return; + + var current = ""; + + /* The id travels with the first message so the server knows which draft to + adopt. A hidden field rather than a header, because it has to arrive as + part of the form that creates the chat. */ + function field() { + var form = document.querySelector(".composer__form"); + if (!form) return null; + var found = form.querySelector('input[name="draft_id"]'); + if (!found) { + found = document.createElement("input"); + found.type = "hidden"; + found.name = "draft_id"; + form.appendChild(found); + } + return found; + } + + function point(id) { + if (id === current) return; + current = id; + + var hidden = field(); + if (hidden) hidden.value = id; + + /* The terminal: one write, because every consumer re-reads `dataset.url` + per call rather than caching it. Reconnecting is the panel's own job -- + it exposes `repoint` so the socket is closed and reopened by the code + that owns the closing flag. */ + var terminal = document.querySelector("[data-terminal]"); + if (terminal) { + terminal.dataset.url = id ? "/api/chats/" + id + "/terminal/ws" : ""; + if (window.lembas && window.lembas.repointTerminal) { + window.lembas.repointTerminal(); + } + } + + /* The canvas builds no URLs in JavaScript at all -- they are all inside the + fragment. So re-pointing it is re-issuing the request that fetches that + fragment, and letting the server render the new id into everything. */ + var inner = document.getElementById("canvas-inner"); + if (inner && id && window.htmx) { + inner.setAttribute("hx-get", "/api/chats/" + id + "/canvas"); + window.htmx.ajax("GET", "/api/chats/" + id + "/canvas", { + target: "#canvas-inner", + swap: "innerHTML" + }); + } + var panel = document.querySelector("[data-canvas]"); + if (panel) panel.dataset.chat = id; + } + + document.addEventListener("lembas:agent-target", function (event) { + var target = event.detail || {}; + if (!target.profileId) { + point(""); + return; + } + /* One round trip per change of target, and the answer is stable: the id is + derived from (owner, connection, directory), so coming back to a target + finds the shell already running there rather than opening a second. */ + fetch( + "/api/agents/" + encodeURIComponent(target.profileId) + + "/draft?dir=" + encodeURIComponent(target.projectDir || ""), + { credentials: "same-origin" } + ) + .then(function (response) { return response.ok ? response.json() : null; }) + .then(function (body) { if (body && body.id) point(body.id); }) + .catch(function () { /* no panels rather than a broken one */ }); + }); +})(); diff --git a/src/lembas/web/static/js/terminal.js b/src/lembas/web/static/js/terminal.js index 1105dc2..349dae1 100644 --- a/src/lembas/web/static/js/terminal.js +++ b/src/lembas/web/static/js/terminal.js @@ -463,6 +463,26 @@ document.addEventListener("lembas:theme", function () { if (term) term.options.theme = readTheme(); }); + + /* The panel now belongs to a different shell: the new-chat screen changed + connection or directory, so `dataset.url` has been rewritten and the + socket is pointed at the wrong machine. Closing is deliberate -- hence + the flag, which is what stops "Disconnected" being reported for something + nobody lost -- and the screen is cleared because this really is a + different shell, unlike a reconnect to the same one. + + Reconnecting is left to the panel being opened, so a target changed while + the terminal is shut costs nothing. */ + window.lembas = window.lembas || {}; + window.lembas.repointTerminal = function () { + if (socket) { + closedOnPurpose = true; + socket.close(); + socket = null; + } + if (term) term.reset(); + if (panel && !panel.hidden && panel.dataset.url) connect(); + }; } if (document.readyState === "loading") { diff --git a/src/lembas/web/static/js/ui.js b/src/lembas/web/static/js/ui.js index 5a1d608..5fed26c 100644 --- a/src/lembas/web/static/js/ui.js +++ b/src/lembas/web/static/js/ui.js @@ -574,6 +574,7 @@ document.addEventListener("lembas:notify", function (event) { if (dirLabel) dirLabel.textContent = value ? baseName(value) : "the login directory"; var button = dirLabel && dirLabel.closest("[data-dir-browse]"); if (button) button.title = value || "The connection's own login directory"; + announce(); } function sync() { @@ -588,13 +589,25 @@ document.addEventListener("lembas:notify", function (event) { return (option && option.dataset.dir) || ""; } + /* Which machine and which directory the panels should be looking at. + Dispatched rather than read, because nothing here owns the terminal or + the canvas -- and because assigning to a hidden field's `.value` fires no + event of its own, so `setDir` has to say so out loud. */ + function announce() { + var chosen = kind && kind.value === "agent" ? (picker ? picker.value : "") : ""; + document.dispatchEvent(new CustomEvent("lembas:agent-target", { + detail: { profileId: chosen, projectDir: dir ? dir.value : "" } + })); + } + root.addEventListener("change", function (event) { - if (event.target.name === "kind_choice") sync(); + if (event.target.name === "kind_choice") { sync(); announce(); } // Following the profile's own directory is a convenience, not a rule: // once someone has chosen their own it is left alone. if (event.target === picker && dir && !dir.dataset.touched) { setDir(profileDefault()); } + if (event.target === picker) announce(); }); root.addEventListener("click", function (event) { @@ -614,6 +627,7 @@ document.addEventListener("lembas:notify", function (event) { so the two disagreed and the box offered a directory on a machine the chat was not going to use. */ setDir(profileDefault()); + announce(); } /* The same directory picker, on a form that is not the composer. diff --git a/src/lembas/web/templates/chat/_canvas.html b/src/lembas/web/templates/chat/_canvas.html index 350c08b..1e5a6e7 100644 --- a/src/lembas/web/templates/chat/_canvas.html +++ b/src/lembas/web/templates/chat/_canvas.html @@ -16,7 +16,7 @@ #}