Files
LLeMbas/src/lembas/api/canvas.py
T
Jaroslav Beneš 4c78215e31 Narrow a chat before it starts, and find a file rather than spell it
Six things, all found by using the thing rather than by reading it.

The scope menu only appeared once a chat existed, on the reasoning that there was
no row to post to. True, and the wrong conclusion: the harness puts a tool's
guidance in front of the model the moment the tool is offered, so the menu could
not be reached until after the model had been told how to keep notes and handed
the tools to do it -- and switching it off then does not un-send that turn. It is
on the new-chat screen now and writes nothing: `_scope_context` builds a stand-in
Chat, which is `draft.as_chat`'s trick again, and the switches ride along with
the first message. Checked means on and a browser submits only the ticked boxes,
so every gate also renders a hidden input naming it and `start_chat` subtracts one
list from the other; inverting the control would read backwards under a menu that
says everything is on unless you say otherwise. Only the off ones are written,
because absent means on and one representation of it is what keeps "why is this
off?" to a single answer. Nothing is validated against the offered set, since
scope_json narrows after every gate -- naming a gate that was never offered
switches off something that was not on.

Then the scheduling instructions, audited against a 4B model on this machine
rather than against my own reading of them. Ten realistic requests, ten
compiled, twice over -- so the prompt is sound. What was not sound was
`describe`, which built a phrase by joining fragments and read "Every the 1st at
09:00" for the commonest monthly schedule there is, and "Every of January" for a
month with no day. That string is the whole of what somebody sees before
approving a schedule and the whole of what the model is told about its own chat,
so a phrase nobody can parse is a review step nobody performs. It reads as
English now, collapses Monday-to-Friday to "every weekday" and seven days to
"every day", and every case in the test is a rule that model actually produced.

The one mistake it made was naming Wednesday for "every other tuesday", so the
weekday numbering is spelled out rather than left as "0-6, Monday is 0": getting
that wrong is the error here that still looks like a working schedule. Roughly
one call in six also came back empty -- a local runner swapping models under the
request will do that -- so an unusable reply is asked for once more before giving
up. Not on an LLMError: an endpoint that refused will refuse again, and the
reader is better served by the form than by waiting twice for the same answer.

Canvas asked for a typed path, which was the last control in the application
expecting somebody to remember an absolute path on another machine -- the same
complaint the folder page's directory field answered with a picker. /browse takes
pick=file and the same fragment makes files buttons, because a second copy of
that listing is a second place for the path arithmetic to be got subtly
differently. The button carries data-canvas-open rather than an hx-post since the
path is not known until the dialog closes, and ui.js posts it through htmx.ajax
so the response lands in the panel exactly as every other canvas action's does.
The key is `agent:<path>`, so a file opened by hand and one opened by the model
are one tab rather than two spellings of it. The tabs already existed and already
closed; they now square off at the bottom and the active one takes the body's
background, so which is selected is structural rather than a tint nobody can see
in a theme they did not choose. Highlighting was already there for every language
named and is checked for fifteen of them.

Three smaller ones. Tabs kept their scroll position, so switching from a long
panel to a short one left the browser clamping to that panel's bottom: the end of
it above a screen of nothing, which reads as a page that failed to load. Nothing
in CSS can reset a scroll position. The sidebar's footer and the composer sit
either side of one vertical edge and were both content-sized, so their top
borders met it at different heights and read as one line that had been broken --
`--footer-height` is a calc of the pieces the footer is built from, applied as a
min-height to both, which is exactly what `--header-height` already does at the
top of the shell. And "Add a workflow" sat flush against the list it adds to,
stated as an adjacency because `.btn-row` is right to carry no margin everywhere
else it appears.

Both pieces of JavaScript were driven under a DOM stub before committing, which
is how the tab listener's delegation and the canvas button's six behaviours were
checked at all -- `node --check` parses a file that does nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 22:37:47 +02:00

243 lines
9.1 KiB
Python

"""The canvas panel: open a file, read it, change it, save it.
Every route answers with an HTML fragment, errors included. An exception page
swapped into a side panel is a blank side panel, and a panel that goes blank
tells somebody nothing about why.
`GET` never moves the active tab. There is no CSRF token in this application and
the session cookie is SameSite Lax, so a state-changing GET is a link somebody
can be made to follow -- and one of the things a tab can be is a file on
somebody's server.
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Form, HTTPException, Request, Response, status
from sqlalchemy.orm import Session as DBSession
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
log = logging.getLogger(__name__)
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.
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,
user: User,
chat: Chat,
*,
key: str = "",
message: str = "",
conflict: canvas_service.Doc | None = None,
mine: str = "",
) -> Response:
"""The strip and whichever tab is in front, as one fragment.
Both together, always. Rendering only the body would leave the strip showing
a tab that is no longer there after a close, and rendering only the strip
would leave the previous file on screen after a switch.
"""
wanted = key or canvas_service.active_of(chat)
doc: canvas_service.Doc | None = None
error = message
if wanted and not error:
try:
doc = await canvas_service.load(db, user, chat, wanted)
except canvas_service.Refused as exc:
error = str(exc)
except Exception: # pragma: no cover - a machine going away mid-request
log.exception("canvas could not open %s", wanted)
error = "That could not be opened."
body = ""
if doc is not None and doc.text:
# The one `|safe` in this panel, and it is safe because pygments escapes
# what it is given. Markdown goes through render_markdown, the single
# path in this application allowed to emit HTML. Everything else -- the
# editor's contents, the titles, the paths -- is escaped by Jinja.
body = render_markdown(doc.text) if doc.markdown else highlight_code(doc.text, doc.language)
return templates.TemplateResponse(
request,
"chat/_canvas_inner.html",
{
"user": user,
"chat": chat,
"tabs": canvas_service.tabs_of(chat),
"active": wanted,
"doc": doc,
"rendered": body,
"error": error,
"conflict": conflict,
"mine": mine,
"canvas_agent": canvas_service.agent_ready(db, user, chat) is not None,
# What the "Open a file" dialog browses. The endpoint it calls is
# hung off the profile rather than the chat, so the button has to
# carry the profile -- and the directory it should start in, or it
# opens at the account's home and every path is a walk from there.
"agent_profile_id": chat.ssh_profile_id or "",
"agent_dir": chat.project_dir or "",
},
)
@router.get("/{chat_id}/canvas")
async def show(request: Request, db: Db, user: RequiredUser, chat_id: str, key: str = ""):
"""Whatever is in front, or the tab named by `?key=`.
Read-only in every sense: a `?key=` that is not open does not become open,
it is simply shown. Opening is a POST.
"""
chat = _owned_chat(db, chat_id, user.id)
return await _panel(request, db, user, chat, key=key)
@router.post("/{chat_id}/canvas/tabs")
async def open_tab(
request: Request,
db: Db,
user: RequiredUser,
chat_id: str,
key: str = Form(...),
title: str = Form(""),
):
"""Open a file, or bring an already-open one to the front.
Idempotent, because opening what is already open is switching to it -- the
same reason `generation.ensure` is idempotent.
"""
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:
return await _panel(request, db, user, chat, message=str(exc))
state = canvas_service.open_tab(
dict(chat.canvas_json or {}),
{"key": doc.key, "title": title.strip() or doc.title, "source": doc.key.split(":")[0]},
)
# Reassigned rather than mutated: an in-place edit of a JSON column is not
# reliably detected as a change.
chat.canvas_json = state
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
# swapped in -- the same reach into live state `request_stop` makes.
live = generation_service.running_for(chat.id)
if live is not None:
canvas_service.open_tab(live.canvas, {"key": doc.key, "title": doc.title})
return await _panel(request, db, user, chat, key=doc.key)
@router.post("/{chat_id}/canvas/tabs/close")
async def close_tab(
request: Request, db: Db, user: RequiredUser, chat_id: str, key: str = Form(...)
):
chat = _owned_chat(db, chat_id, user.id)
chat.canvas_json = canvas_service.close_tab(dict(chat.canvas_json or {}), key)
if not _remember_tabs(chat, chat.canvas_json):
db.commit()
live = generation_service.running_for(chat.id)
if live is not None:
canvas_service.close_tab(live.canvas, key)
return await _panel(request, db, user, chat)
@router.post("/{chat_id}/canvas/save")
async def save(
request: Request,
db: Db,
user: RequiredUser,
chat_id: str,
key: str = Form(...),
text: str = Form(""),
revision: str = Form(""),
):
"""Write it back.
A conflict comes back as a card, at 200, so htmx swaps it: the panel has to
be able to show Overwrite, Discard mine and Show what changed, and none of
those can be offered from an error status htmx will not render. Never save
silently over a change; never discard silently either.
"""
chat = _owned_chat(db, chat_id, user.id)
try:
await canvas_service.save(db, user, chat, key, text, revision)
except Conflict:
try:
theirs = await canvas_service.load(db, user, chat, key)
except canvas_service.Refused as exc:
return await _panel(request, db, user, chat, key=key, message=str(exc))
return await _panel(request, db, user, chat, key=key, conflict=theirs, mine=text)
except canvas_service.Refused as exc:
return await _panel(request, db, user, chat, key=key, message=str(exc))
except Exception: # pragma: no cover - the machine going away mid-write
log.exception("canvas could not save %s", key)
return await _panel(
request, db, user, chat, key=key, message="That could not be saved."
)
return await _panel(request, db, user, chat, key=key)