Files
LLeMbas/src/lembas/web/templates/chat/_canvas.html
T
Jaroslav Beneš a63723713f 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>
2026-08-04 21:33:34 +02:00

52 lines
2.3 KiB
HTML

{% from "_macros.html" import icon %}
{#
The canvas panel: files, open beside the conversation.
Built the way the terminal panel is -- a child of .shell, `hidden` until a
toggle removes it, its own drag handle, the shared panel head. Filled the way
the *inspector* is, though: `hx-trigger="intersect once"`, because a hidden
element never intersects, so a panel nobody opens costs one element and no
round trip to somebody's machine. There is nothing heavy to construct here,
which is the whole reason it does not need the terminal's lazy-build dance.
Everything shown inside came off somebody else's disk, or out of a model. It
is rendered through pygments (which escapes), through render_markdown (the one
path allowed to emit HTML), or into a <textarea>, whose contents Jinja escapes
and which cannot contain markup by construction.
#}
<aside class="canvas" id="canvas" hidden aria-label="Canvas"
data-canvas
data-chat="{{ chat.id if chat else "" }}"
data-resize-target>
{# The left edge, dragged. A separator rather than a decoration: it takes
focus and answers the arrow keys, or the panel is only resizable with a
mouse and the grip is a focus trap that does nothing. #}
<div class="panel-resize" data-resize="--canvas-width" data-resize-min="384"
role="separator" aria-orientation="vertical" tabindex="0"
aria-label="Resize the canvas">
{{ icon("grip", "icon--sm") }}
</div>
<div class="canvas__inner" id="canvas-inner"
{# Before a chat exists this fetches nothing until `draft.js` rewrites it,
which is what "intersect once" gives for free: the panel is hidden until
it is opened, so the trigger has not fired yet. #}
hx-get="{{ "/api/chats/" ~ chat.id ~ "/canvas" if chat else "" }}"
hx-trigger="intersect once"
hx-target="this" hx-swap="innerHTML">
<div class="panel-head">
<h2 class="panel-head__title">
{{ icon("file-text", "icon--sm") }}
<span>Canvas</span>
</h2>
<button class="btn btn--icon btn--sm" type="button" data-toggle="#canvas"
aria-label="Close canvas">
{{ icon("x", "icon--sm") }}
</button>
</div>
<div class="canvas__body">
<p class="canvas__empty">Opening…</p>
</div>
</div>
</aside>