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:
@@ -16,7 +16,7 @@
|
||||
#}
|
||||
<aside class="canvas" id="canvas" hidden aria-label="Canvas"
|
||||
data-canvas
|
||||
data-chat="{{ chat.id }}"
|
||||
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
|
||||
@@ -28,7 +28,10 @@
|
||||
</div>
|
||||
|
||||
<div class="canvas__inner" id="canvas-inner"
|
||||
hx-get="/api/chats/{{ chat.id }}/canvas"
|
||||
{# 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">
|
||||
|
||||
@@ -12,9 +12,12 @@
|
||||
#}
|
||||
<aside class="terminal" id="terminal" hidden aria-label="Terminal"
|
||||
data-terminal
|
||||
data-url="/api/chats/{{ chat.id }}/terminal/ws"
|
||||
{# Empty before a chat exists: `draft.js` writes a draft id in as soon as
|
||||
the composer has a connection selected. Explicit rather than relying on
|
||||
Jinja rendering `None.id` as nothing. #}
|
||||
data-url="{{ "/api/chats/" ~ chat.id ~ "/terminal/ws" if chat else "" }}"
|
||||
data-label="{{ agent_profile.name if agent_profile else 'this connection' }}"
|
||||
data-dir="{{ chat.project_dir }}"
|
||||
data-dir="{{ chat.project_dir if chat else "" }}"
|
||||
data-resize-target>
|
||||
{#
|
||||
The left edge, dragged. A separator rather than a decoration: it takes
|
||||
|
||||
@@ -347,6 +347,12 @@
|
||||
{# Unconditional: every chat has a transcript, and this is what keeps a block
|
||||
somebody opened open across the swaps that arrive twelve times a second. #}
|
||||
<script src="{{ url_for('static', path='js/steps.js') }}" defer></script>
|
||||
{% if not chat and (canvas_enabled or terminal_enabled) %}
|
||||
{# Only where there is no chat yet. It points both panels at a draft id for
|
||||
whatever the composer has selected, and does nothing at all once a chat
|
||||
exists -- which is every other page this block renders on. #}
|
||||
<script src="{{ url_for('static', path='js/draft.js') }}" defer></script>
|
||||
{% endif %}
|
||||
{% if canvas_enabled %}
|
||||
<script src="{{ url_for('static', path='js/canvas.js') }}" defer></script>
|
||||
{% endif %}
|
||||
|
||||
Reference in New Issue
Block a user