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>
85 lines
3.8 KiB
HTML
85 lines
3.8 KiB
HTML
{% from "_macros.html" import icon %}
|
|
{#
|
|
The terminal panel: a fourth child of .shell, to the left of the inspector and
|
|
never open beside it. Empty on a page load -- xterm is created the first time
|
|
the panel is opened, so the 280KB it costs is paid by somebody who asked for a
|
|
shell rather than by everyone who opened a chat.
|
|
|
|
What is typed here is not run past the chat's mode or its allow and deny
|
|
lists. Those govern the model, which reads pages and files it did not write;
|
|
the person at the keyboard holds the credential and could open the same shell
|
|
with an ssh client.
|
|
#}
|
|
<aside class="terminal" id="terminal" hidden aria-label="Terminal"
|
|
data-terminal
|
|
{# 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 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="--terminal-width" data-resize-min="384"
|
|
role="separator" aria-orientation="vertical" tabindex="0"
|
|
aria-label="Resize the terminal">
|
|
{{ icon("grip", "icon--sm") }}
|
|
</div>
|
|
|
|
<div class="panel-head">
|
|
<h2 class="panel-head__title">
|
|
{{ icon("terminal", "icon--sm") }}
|
|
<span>{{ agent_profile.name if agent_profile else "Terminal" }}</span>
|
|
<span class="terminal__where" data-terminal-where>{{ chat.project_dir }}</span>
|
|
</h2>
|
|
{#
|
|
Copy, Send and Auto. All three need to know where one command ends and
|
|
the next begins, which is what the shell integration provides; without it
|
|
the first two fall back to scraping the screen and say so, and Auto is
|
|
disabled rather than degraded. Forty arbitrary lines attached to every
|
|
message is worse than nothing attached at all.
|
|
#}
|
|
<button class="btn btn--icon btn--sm" type="button" data-terminal-copy
|
|
title="Copy the last command and its output"
|
|
aria-label="Copy the last command and its output">
|
|
{{ icon("copy", "icon--sm") }}
|
|
</button>
|
|
<button class="btn btn--icon btn--sm" type="button" data-terminal-send
|
|
title="Put the last command and its output into the message box"
|
|
aria-label="Send to chat">
|
|
{{ icon("arrow-up", "icon--sm") }}
|
|
</button>
|
|
{#
|
|
Three states, not two. A cycling icon button cannot say which of three it
|
|
is in, and this one decides whether things are sent to a model without
|
|
being asked again -- so it says so in words.
|
|
|
|
Not remembered between page loads on purpose: a switch that forwards every
|
|
command you run to a model is not something to inherit from last week.
|
|
#}
|
|
<select class="select select--sm" data-terminal-auto
|
|
aria-label="What to do when a command finishes"
|
|
title="What to do with each command you run">
|
|
<option value="off" selected>Auto: off</option>
|
|
<option value="copy">Auto: copy</option>
|
|
<option value="send">Auto: send</option>
|
|
</select>
|
|
<button class="btn btn--icon btn--sm" type="button" data-toggle="#terminal"
|
|
aria-label="Close terminal">
|
|
{{ icon("x", "icon--sm") }}
|
|
</button>
|
|
</div>
|
|
|
|
<div class="terminal__screen" data-terminal-screen></div>
|
|
|
|
<div class="terminal__status">
|
|
<span class="terminal__message" data-terminal-message>Connecting…</span>
|
|
<span class="terminal__last" data-terminal-last></span>
|
|
<span>Ctrl+Shift+C / V</span>
|
|
</div>
|
|
</aside>
|