a63723713f
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>
52 lines
2.3 KiB
HTML
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>
|