The composer decides what a chat is, and the topbar stops trying
The mode select in the topbar posted with hx-post against a route that only answers PATCH, so every change returned 405 and the mode never moved. htmx shows nothing when a request fails, so the control looked like it worked: the select stayed where you put it and the server ignored you. It has never worked. Two more of the same kind. A mode could not be chosen at all until the chat existed, so reaching Plan meant sending something in Manual first and letting the model answer under the wrong rules. And the project directory box was real and submitted, but unlabelled and squeezed to a few characters by the select beside it, so it read as broken -- which is how it was reported. So the kind, the connection, the directory and the mode move out of the strip above the text and into one toolbar row beneath it, where attach and send already are. The directory becomes a button that opens a browser over SFTP, because a path is something you would rather find than spell. `scan_dir` is new beside `list_dir`: a picker has to tell a directory from a file before it can draw the row, and `list_dir` backs a tool whose contract is a list of names and must not change under a model mid-conversation. Browsing is a person clicking, not a model calling, so it does not pass through policy.py -- the same argument the terminal panel rests on. It does mean Manual mode has a second exception now. Also: .chip was two components with one name, and the attachment card won, so the Chat/Agent pills silently wore its padding. --radius-md was used twice and declared nowhere, so both fell back to 0. .btn.is-active has been set by syncToggles since the terminal landed and styled by nothing. Enter-to-send ignored isComposing, so committing an IME candidate sent the message. The terminal had five colours of a sixteen-colour palette, with fallbacks from a palette that no longer exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
{% from "_macros.html" import icon %}
|
||||
{#
|
||||
One directory on the far side, inside the composer's directory dialog.
|
||||
|
||||
Swapped in whole on every step rather than filtered in the browser: a
|
||||
directory listing is a round trip to somebody else's machine, and there is no
|
||||
local copy to filter. `here` is what the Use button submits, so it is carried
|
||||
on the container rather than recomputed from whatever row was last clicked.
|
||||
|
||||
Everything here is a name from a remote filesystem, so everything is escaped
|
||||
by Jinja's autoescaping and none of it is ever marked safe.
|
||||
#}
|
||||
<div id="dir-results" data-here="{{ here }}">
|
||||
<p class="dialog__where">
|
||||
{{ icon("folder-open", "icon--sm") }}
|
||||
<span class="mono">{{ here }}</span>
|
||||
</p>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert--danger">{{ error }}</div>
|
||||
{% else %}
|
||||
<ul class="picker__list">
|
||||
{% if parent %}
|
||||
<li>
|
||||
<button class="picker__option" type="button" data-dir-open="{{ parent }}">
|
||||
{{ icon("chevron-left", "icon--sm") }}
|
||||
<span class="picker__option-body">
|
||||
<span class="picker__option-name">Up a level</span>
|
||||
<span class="picker__option-note mono">{{ parent }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% for entry in entries %}
|
||||
{# Files are listed but not selectable. Hiding them would make a directory
|
||||
of only files look empty, which is worse than showing what is there and
|
||||
not letting it be chosen. #}
|
||||
<li>
|
||||
{% if entry.is_dir %}
|
||||
<button class="picker__option" type="button"
|
||||
data-dir-open="{{ (here.rstrip('/') ~ '/' ~ entry.name) if here != '/' else '/' ~ entry.name }}">
|
||||
{{ icon("folder", "icon--sm") }}
|
||||
<span class="picker__option-body">
|
||||
<span class="picker__option-name">{{ entry.name }}</span>
|
||||
</span>
|
||||
{{ icon("chevron-right", "icon--sm") }}
|
||||
</button>
|
||||
{% else %}
|
||||
<span class="picker__option is-inert">
|
||||
{{ icon("file-text", "icon--sm") }}
|
||||
<span class="picker__option-body">
|
||||
<span class="picker__option-name">{{ entry.name }}</span>
|
||||
</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
||||
{% if not entries %}
|
||||
<li>
|
||||
<p class="muted text-sm" style="padding: var(--sp-3)">
|
||||
Nothing here.
|
||||
</p>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -6,27 +6,27 @@
|
||||
message; removing the chip removes the input, which is all the bookkeeping
|
||||
the client needs.
|
||||
#}
|
||||
<div class="chip" id="chip-{{ attachment.id }}">
|
||||
<div class="attach-chip" id="chip-{{ attachment.id }}">
|
||||
<input type="hidden" name="file_ids" value="{{ attachment.id }}">
|
||||
|
||||
{% if attachment.is_image %}
|
||||
<img class="chip__thumb" src="/api/files/{{ attachment.id }}/content" alt="">
|
||||
<img class="attach-chip__thumb" src="/api/files/{{ attachment.id }}/content" alt="">
|
||||
{% else %}
|
||||
<span class="chip__icon">
|
||||
<span class="attach-chip__icon">
|
||||
{{ icon("attach" if attachment.kind == "document" else "copy", "icon--sm") }}
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
<span class="chip__body">
|
||||
<span class="chip__name" title="{{ attachment.filename }}">{{ attachment.filename }}</span>
|
||||
<span class="chip__meta">
|
||||
<span class="attach-chip__body">
|
||||
<span class="attach-chip__name" title="{{ attachment.filename }}">{{ attachment.filename }}</span>
|
||||
<span class="attach-chip__meta">
|
||||
{{ attachment.human_size }}
|
||||
{%- if attachment.pages %} · {{ attachment.pages }} page{{ '' if attachment.pages == 1 else 's' }}{% endif %}
|
||||
{%- if attachment.width %} · {{ attachment.width }}×{{ attachment.height }}{% endif %}
|
||||
{%- if attachment.truncated %} · truncated{% endif %}
|
||||
</span>
|
||||
{% if attachment.extraction_error %}
|
||||
<span class="chip__warning">{{ attachment.extraction_error }}</span>
|
||||
<span class="attach-chip__warning">{{ attachment.extraction_error }}</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
the composer rather than only in the network tab. Dismissed by hand; it
|
||||
carries no hidden input, so it cannot be submitted with the message.
|
||||
#}
|
||||
<div class="chip chip--error">
|
||||
<span class="chip__icon">{{ icon("warning", "icon--sm") }}</span>
|
||||
<span class="chip__body">
|
||||
<span class="chip__name">{{ filename }}</span>
|
||||
<span class="chip__warning">{{ error }}</span>
|
||||
<div class="attach-chip attach-chip--error">
|
||||
<span class="attach-chip__icon">{{ icon("warning", "icon--sm") }}</span>
|
||||
<span class="attach-chip__body">
|
||||
<span class="attach-chip__name">{{ filename }}</span>
|
||||
<span class="attach-chip__warning">{{ error }}</span>
|
||||
</span>
|
||||
<button class="btn btn--icon btn--sm" type="button" aria-label="Dismiss"
|
||||
onclick="this.closest('.chip').remove()">
|
||||
onclick="this.closest('.attach-chip').remove()">
|
||||
{{ icon("x", "icon--sm") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,13 @@
|
||||
file_ids input, and being inside the form is what gets them serialised with
|
||||
the message. Keeping them outside and reaching for hx-include does not work:
|
||||
that attribute only has an effect on the element issuing the request.
|
||||
|
||||
Layout: chips, then the text, then one toolbar row underneath carrying
|
||||
everything that acts on the message. The kind selector and the connection
|
||||
used to sit in a strip *above* the text, inside the same bordered card, where
|
||||
they read as debris floating in the input rather than as controls. Below the
|
||||
text they are in the same place as attach and send, which is where the hand
|
||||
already is.
|
||||
#}
|
||||
<div class="composer" {% if can.get("files.upload") %}data-dropzone{% endif %}>
|
||||
{% if can.get("files.upload") %}
|
||||
@@ -54,128 +61,186 @@
|
||||
<input type="hidden" name="temporary" value="true">
|
||||
{% endif %}
|
||||
|
||||
{# Chat or Agent, chosen once. There is no switching afterwards: the
|
||||
tools offered, the harness and the approval loop all differ, so a
|
||||
conversation whose earlier turns ran somewhere else is not one
|
||||
conversation. Only shown when picking Agent would lead anywhere. #}
|
||||
{% if not chat and agent_profiles %}
|
||||
<input type="hidden" name="kind" value="chat" id="chat-kind">
|
||||
<div class="composer__kind" data-agent-picker>
|
||||
<label class="chip">
|
||||
<input type="radio" name="kind_choice" value="chat" checked>
|
||||
<span>{{ icon("chat", "icon--sm") }} Chat</span>
|
||||
</label>
|
||||
<label class="chip">
|
||||
<input type="radio" name="kind_choice" value="agent">
|
||||
<span>{{ icon("server", "icon--sm") }} Agent</span>
|
||||
</label>
|
||||
<textarea class="composer__input" name="content" rows="1"
|
||||
data-autosize data-max-height="320" data-composer-input
|
||||
placeholder="{% if chat %}Send a message…{% else %}Ask anything…{% endif %}"
|
||||
aria-label="Message" {{ 'autofocus' if not chat }}></textarea>
|
||||
|
||||
<span class="composer__kind-agent" hidden>
|
||||
<select class="select select--sm" name="ssh_profile_id" aria-label="Connection">
|
||||
{% for profile in agent_profiles %}
|
||||
<option value="{{ profile.id }}" data-dir="{{ profile.default_dir }}"
|
||||
{{ 'disabled' if not profile.verified }}>
|
||||
{{ profile.name }}{{ ' — not checked' if not profile.verified }}
|
||||
</option>
|
||||
<div class="composer__toolbar">
|
||||
<div class="composer__tools">
|
||||
{% if can.get("files.upload") %}
|
||||
{# A menu rather than the file picker straight away: there are four ways
|
||||
to attach something now, and only one of them is a file on disk.
|
||||
Uses the same picker machinery as the model chooser -- see ui.js. #}
|
||||
<div class="picker picker--up" data-picker>
|
||||
<button class="btn btn--icon composer__btn" type="button" data-picker-toggle
|
||||
aria-haspopup="menu" aria-expanded="false"
|
||||
aria-label="Attach" title="Attach">
|
||||
{{ icon("attach") }}
|
||||
</button>
|
||||
|
||||
<div class="picker__menu picker__menu--compact" data-picker-menu role="menu"
|
||||
hidden aria-label="Attach">
|
||||
<button class="picker__option" type="button" role="menuitem"
|
||||
data-attach="file">
|
||||
{{ icon("attach", "icon--sm") }}
|
||||
<span class="picker__option-body">
|
||||
<span class="picker__option-name">File</span>
|
||||
<span class="picker__option-note">PDF, text, code</span>
|
||||
</span>
|
||||
</button>
|
||||
<button class="picker__option" type="button" role="menuitem"
|
||||
data-attach="image">
|
||||
{{ icon("image", "icon--sm") }}
|
||||
<span class="picker__option-body">
|
||||
<span class="picker__option-name">Image</span>
|
||||
<span class="picker__option-note">Sent only to vision models</span>
|
||||
</span>
|
||||
</button>
|
||||
<button class="picker__option" type="button" role="menuitem"
|
||||
data-attach="link">
|
||||
{{ icon("link", "icon--sm") }}
|
||||
<span class="picker__option-body">
|
||||
<span class="picker__option-name">Link</span>
|
||||
<span class="picker__option-note">Fetch a page and attach its text</span>
|
||||
</span>
|
||||
</button>
|
||||
{% if can.get("library.use") %}
|
||||
<button class="picker__option" type="button" role="menuitem"
|
||||
data-attach="knowledge">
|
||||
{{ icon("archive", "icon--sm") }}
|
||||
<span class="picker__option-body">
|
||||
<span class="picker__option-name">Knowledge</span>
|
||||
<span class="picker__option-note">From your library</span>
|
||||
</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{#
|
||||
What this conversation is and where it runs.
|
||||
|
||||
On a new chat all of it is editable. On an existing one the kind, the
|
||||
connection and the directory are fixed -- update_chat refuses them
|
||||
with a 409, because a transcript whose earlier turns ran somewhere
|
||||
else is not one conversation -- so they render as a read-only chip and
|
||||
only the mode stays live. The mode is the exception on purpose: it
|
||||
decides what gets asked about, not what the conversation is.
|
||||
#}
|
||||
{% if not chat and agent_profiles %}
|
||||
<div class="composer__context" data-agent-picker>
|
||||
<input type="hidden" name="kind" value="chat" id="chat-kind">
|
||||
|
||||
<div class="segmented" role="group" aria-label="Kind of chat">
|
||||
<label class="segmented__option">
|
||||
<input type="radio" name="kind_choice" value="chat" checked>
|
||||
<span>{{ icon("chat", "icon--sm") }} Chat</span>
|
||||
</label>
|
||||
<label class="segmented__option">
|
||||
<input type="radio" name="kind_choice" value="agent">
|
||||
<span>{{ icon("bolt", "icon--sm") }} Agent</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<span class="composer__agent" data-agent-extra hidden>
|
||||
<select class="select select--sm" name="ssh_profile_id" aria-label="Connection">
|
||||
{% for profile in agent_profiles %}
|
||||
<option value="{{ profile.id }}" data-dir="{{ profile.default_dir }}"
|
||||
{{ 'disabled' if not profile.verified }}>
|
||||
{{ profile.name }}{{ ' — not checked' if not profile.verified }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
{# A button and a hidden field, not a text box. The text box was
|
||||
real and submitted, but unlabelled and squeezed to a few
|
||||
characters by the select beside it, so it read as broken. A path
|
||||
is also something you would rather find than spell. #}
|
||||
<input type="hidden" name="project_dir" value="" data-dir-value>
|
||||
<button class="btn btn--sm composer__dir" type="button" data-dir-browse
|
||||
aria-label="Project directory"
|
||||
title="Choose the directory this chat works in">
|
||||
{{ icon("folder", "icon--sm") }}
|
||||
<span class="composer__dir-path" data-dir-label>/</span>
|
||||
</button>
|
||||
|
||||
{% if agent_modes %}
|
||||
<select class="select select--sm" name="agent_mode" aria-label="Approval mode">
|
||||
{% for value, label, hint in agent_modes %}
|
||||
<option value="{{ value }}" title="{{ hint }}">{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{% elif chat and chat.kind == "agent" %}
|
||||
<div class="composer__context">
|
||||
<span class="composer__where" title="{{ chat.project_dir }}">
|
||||
{{ icon("bolt", "icon--sm") }}
|
||||
<span>{{ agent_profile.name if agent_profile else "connection missing" }}</span>
|
||||
<span class="composer__where-dir">{{ chat.project_dir }}</span>
|
||||
</span>
|
||||
|
||||
{# Its own form: nesting one inside the composer's form is invalid
|
||||
HTML, and the browser drops the inner one. #}
|
||||
<select class="select select--sm" name="agent_mode" aria-label="Approval mode"
|
||||
form="agent-mode-form">
|
||||
{% for value, label, hint in agent_modes %}
|
||||
<option value="{{ value }}" title="{{ hint }}"
|
||||
{{ 'selected' if value == chat.agent_mode }}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<input class="input input--sm input--mono" name="project_dir"
|
||||
value="{{ agent_profiles[0].default_dir }}"
|
||||
aria-label="Project directory" placeholder="/project">
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="composer__row">
|
||||
{% if can.get("files.upload") %}
|
||||
{# A menu rather than the file picker straight away: there are four ways
|
||||
to attach something now, and only one of them is a file on disk.
|
||||
Uses the same picker machinery as the model chooser -- see ui.js. #}
|
||||
<div class="picker picker--up" data-picker>
|
||||
<button class="btn btn--icon composer__btn" type="button" data-picker-toggle
|
||||
aria-haspopup="menu" aria-expanded="false"
|
||||
aria-label="Attach" title="Attach">
|
||||
{{ icon("attach") }}
|
||||
</button>
|
||||
|
||||
<div class="picker__menu picker__menu--compact" data-picker-menu role="menu"
|
||||
hidden aria-label="Attach">
|
||||
<button class="picker__option" type="button" role="menuitem"
|
||||
data-attach="file">
|
||||
{{ icon("attach", "icon--sm") }}
|
||||
<span class="picker__option-body">
|
||||
<span class="picker__option-name">File</span>
|
||||
<span class="picker__option-note">PDF, text, code</span>
|
||||
</span>
|
||||
</button>
|
||||
<button class="picker__option" type="button" role="menuitem"
|
||||
data-attach="image">
|
||||
{{ icon("image", "icon--sm") }}
|
||||
<span class="picker__option-body">
|
||||
<span class="picker__option-name">Image</span>
|
||||
<span class="picker__option-note">Sent only to vision models</span>
|
||||
</span>
|
||||
</button>
|
||||
<button class="picker__option" type="button" role="menuitem"
|
||||
data-attach="link">
|
||||
{{ icon("link", "icon--sm") }}
|
||||
<span class="picker__option-body">
|
||||
<span class="picker__option-name">Link</span>
|
||||
<span class="picker__option-note">Fetch a page and attach its text</span>
|
||||
</span>
|
||||
</button>
|
||||
{% if can.get("library.use") %}
|
||||
<button class="picker__option" type="button" role="menuitem"
|
||||
data-attach="knowledge">
|
||||
{{ icon("archive", "icon--sm") }}
|
||||
<span class="picker__option-body">
|
||||
<span class="picker__option-name">Knowledge</span>
|
||||
<span class="picker__option-note">From your library</span>
|
||||
</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<textarea class="composer__input" name="content" rows="1"
|
||||
data-autosize data-max-height="320" data-composer-input
|
||||
placeholder="{% if chat %}Send a message…{% else %}Ask anything…{% endif %}"
|
||||
aria-label="Message" {{ 'autofocus' if not chat }}></textarea>
|
||||
<div class="composer__actions">
|
||||
{% if can_dictate %}
|
||||
{# Recording is started and stopped by the same button; audio.js swaps
|
||||
data-mic-state and the icon with it. #}
|
||||
<button class="btn btn--icon composer__btn composer__mic" type="button"
|
||||
data-mic data-mic-state="idle"
|
||||
aria-label="Dictate a message" title="Dictate a message">
|
||||
<span class="composer__icon composer__icon--mic">{{ icon("mic") }}</span>
|
||||
<span class="composer__icon composer__icon--recording" aria-hidden="true">
|
||||
{{ icon("stop-circle") }}
|
||||
</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
{% if can_dictate %}
|
||||
{# Recording is started and stopped by the same button; audio.js swaps
|
||||
data-mic-state and the icon with it. #}
|
||||
<button class="btn btn--icon composer__btn composer__mic" type="button"
|
||||
data-mic data-mic-state="idle"
|
||||
aria-label="Dictate a message" title="Dictate a message">
|
||||
<span class="composer__icon composer__icon--mic">{{ icon("mic") }}</span>
|
||||
<span class="composer__icon composer__icon--recording" aria-hidden="true">
|
||||
{{ icon("stop-circle") }}
|
||||
</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
{#
|
||||
One button, two jobs. While a reply is being written it becomes Stop,
|
||||
because that is where the hand already is and a second button sitting
|
||||
permanently beside Send is clutter that is wrong most of the time.
|
||||
|
||||
{#
|
||||
One button, two jobs. While a reply is being written it becomes Stop,
|
||||
because that is where the hand already is and a second button sitting
|
||||
permanently beside Send is clutter that is wrong most of the time.
|
||||
|
||||
ui.js flips data-composer-action, and the type with it: as `submit`
|
||||
the form's own handler sends, as `button` the click handler stops.
|
||||
Both icons are rendered here and chosen in CSS, so the swap costs no
|
||||
layout and cannot flash an empty button.
|
||||
#}
|
||||
<button class="btn btn--primary btn--icon composer__btn" type="submit"
|
||||
data-composer-action="send" aria-label="Send">
|
||||
<span class="composer__icon composer__icon--send">{{ icon("send") }}</span>
|
||||
<span class="composer__icon composer__icon--stop" aria-hidden="true">
|
||||
<span class="composer__stop-square"></span>
|
||||
</span>
|
||||
</button>
|
||||
ui.js flips data-composer-action, and the type with it: as `submit`
|
||||
the form's own handler sends, as `button` the click handler stops.
|
||||
Both icons are rendered here and chosen in CSS, so the swap costs no
|
||||
layout and cannot flash an empty button.
|
||||
#}
|
||||
<button class="btn btn--primary btn--icon composer__btn" type="submit"
|
||||
data-composer-action="send" aria-label="Send">
|
||||
<span class="composer__icon composer__icon--send">{{ icon("send") }}</span>
|
||||
<span class="composer__icon composer__icon--stop" aria-hidden="true">
|
||||
<span class="composer__stop-square"></span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{# Outside the composer's form, and referenced by the mode select's `form`
|
||||
attribute above. hx-patch and not hx-post: there is no POST for a chat,
|
||||
only PATCH, and htmx shows nothing when a request 405s -- which is how
|
||||
this control spent its whole life doing nothing. #}
|
||||
{% if chat and chat.kind == "agent" %}
|
||||
<form id="agent-mode-form" hx-patch="/api/chats/{{ chat.id }}" hx-swap="none"
|
||||
hx-trigger="change"></form>
|
||||
{% endif %}
|
||||
|
||||
<p class="composer__hint">
|
||||
Enter to send, Shift+Enter for a new line.
|
||||
{% if can.get("files.upload") %}
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
opened and never on a page load nobody looked at.
|
||||
#}
|
||||
<aside class="inspector" id="inspector" hidden aria-label="Request inspector">
|
||||
<div class="inspector__header">
|
||||
<h2 class="inspector__title">{{ icon("search", "icon--sm") }} Inspector</h2>
|
||||
<div class="panel-head">
|
||||
<h2 class="panel-head__title">{{ icon("search", "icon--sm") }} Inspector</h2>
|
||||
<button class="btn btn--icon btn--sm" type="button" data-toggle="#inspector"
|
||||
aria-label="Close inspector">
|
||||
{{ icon("x", "icon--sm") }}
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
{{ icon("attach", "icon--sm") }}
|
||||
<span class="attachments__doc-body">
|
||||
<a href="/api/files/{{ attachment.id }}/content">{{ attachment.filename }}</a>
|
||||
<span class="chip__meta">
|
||||
<span class="attach-chip__meta">
|
||||
{{ attachment.human_size }}
|
||||
{%- if attachment.pages %} · {{ attachment.pages }} page{{ '' if attachment.pages == 1 else 's' }}{% endif %}
|
||||
{%- if attachment.truncated %} · truncated{% endif %}
|
||||
@@ -77,7 +77,7 @@
|
||||
{%- endif %}
|
||||
</span>
|
||||
{% if attachment.extraction_error %}
|
||||
<span class="chip__warning">{{ attachment.extraction_error }}</span>
|
||||
<span class="attach-chip__warning">{{ attachment.extraction_error }}</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
data-url="/api/chats/{{ chat.id }}/terminal/ws"
|
||||
data-label="{{ agent_profile.name if agent_profile else 'this connection' }}"
|
||||
data-dir="{{ chat.project_dir }}">
|
||||
<div class="terminal__header">
|
||||
<h2 class="terminal__title">
|
||||
<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>
|
||||
|
||||
@@ -25,47 +25,22 @@
|
||||
|
||||
<h1 class="topbar__title">
|
||||
<span id="chat-title">{{ chat.title if chat else "New chat" }}</span>
|
||||
</h1>
|
||||
|
||||
{# The mode is the one agent setting that changes mid-chat: it decides
|
||||
what gets asked about, not what the conversation is. In the header
|
||||
rather than the settings panel because it is looked at constantly --
|
||||
it is the difference between being interrupted and not. #}
|
||||
{% if chat and chat.kind == "agent" %}
|
||||
<form class="agent-bar" hx-post="/api/chats/{{ chat.id }}" hx-swap="none"
|
||||
hx-trigger="change">
|
||||
<span class="agent-bar__where" title="{{ chat.project_dir }}">
|
||||
{{ icon("server", "icon--sm") }}
|
||||
{{ agent_profile.name if agent_profile else "connection missing" }}
|
||||
</span>
|
||||
<select class="select select--sm" name="agent_mode" aria-label="Mode">
|
||||
{% for value, label, hint in agent_modes %}
|
||||
<option value="{{ value }}" title="{{ hint }}"
|
||||
{{ 'selected' if value == chat.agent_mode }}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<div class="topbar__actions">
|
||||
{#
|
||||
A link, not a script: the flag lives in the URL, so it survives a
|
||||
reload and can be bookmarked. On an existing temporary chat the same
|
||||
corner explains what temporary means and offers the way out -- without
|
||||
one, a conversation that turns out to matter is destroyed a day later
|
||||
with no recourse.
|
||||
#}
|
||||
{# Beside the title because it describes the chat rather than acting on
|
||||
it. The way out of it is Keep, in the overflow menu. #}
|
||||
{% if chat and chat.temporary %}
|
||||
<span class="badge badge--warning"
|
||||
title="Not listed in the sidebar, and removed 24 hours after the last message.">
|
||||
Temporary
|
||||
</span>
|
||||
<button class="btn btn--sm" type="button"
|
||||
hx-post="/api/chats/{{ chat.id }}/keep" hx-swap="none"
|
||||
title="Keep this chat and list it in the sidebar">
|
||||
{{ icon("pin", "icon--sm") }} Keep
|
||||
</button>
|
||||
{% elif not chat and can.get("chat.create") %}
|
||||
{% endif %}
|
||||
</h1>
|
||||
|
||||
<div class="topbar__actions">
|
||||
{#
|
||||
A link, not a script: the flag lives in the URL, so it survives a
|
||||
reload and can be bookmarked.
|
||||
#}
|
||||
{% if not chat and can.get("chat.create") %}
|
||||
<a class="btn btn--icon {{ 'is-active' if starting_temporary }}"
|
||||
href="{{ '/chat' if starting_temporary else '/chat?temporary=1' }}"
|
||||
aria-label="Temporary chat"
|
||||
@@ -82,25 +57,6 @@
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if chat and (can.get("chat.system_prompt") or can.get("chat.params")) %}
|
||||
<button class="btn btn--icon" type="button" aria-label="Chat settings"
|
||||
title="Chat settings" data-toggle="#chat-settings">
|
||||
{{ icon("sliders") }}
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
{% if chat and messages %}
|
||||
<button class="btn btn--icon" type="button" aria-label="Compact this chat"
|
||||
title="Summarise the earlier messages so they stop taking up context"
|
||||
hx-post="/api/chats/{{ chat.id }}/compact"
|
||||
hx-target="#thread" hx-swap="innerHTML"
|
||||
hx-confirm="Summarise everything before the last reply? The messages stay in the transcript; they just stop being sent to the model."
|
||||
data-confirm-title="Compact this chat"
|
||||
data-confirm-label="Compact">
|
||||
{{ icon("archive") }}
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
{% if terminal_enabled %}
|
||||
{# To the left of the inspector, and never open beside it: see the
|
||||
toggle group in app.js. #}
|
||||
@@ -118,6 +74,68 @@
|
||||
{{ icon("search") }}
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
{#
|
||||
Everything else about this chat, behind one button.
|
||||
|
||||
The bar carried eight controls, which is a toolbar nobody can read at
|
||||
a glance. What is left in it is what you reach for while writing --
|
||||
the model, and the two panels. These three are occasional, and every
|
||||
one of them is also a slash command now.
|
||||
#}
|
||||
{% if chat %}
|
||||
<div class="picker" data-picker>
|
||||
<button class="btn btn--icon" type="button" data-picker-toggle
|
||||
aria-haspopup="menu" aria-expanded="false"
|
||||
aria-label="More" title="More">
|
||||
{{ icon("dots") }}
|
||||
</button>
|
||||
|
||||
<div class="picker__menu picker__menu--compact" data-picker-menu role="menu"
|
||||
hidden aria-label="More">
|
||||
{% if chat.temporary %}
|
||||
<button class="picker__option" type="button" role="menuitem"
|
||||
hx-post="/api/chats/{{ chat.id }}/keep" hx-swap="none">
|
||||
{{ icon("pin", "icon--sm") }}
|
||||
<span class="picker__option-body">
|
||||
<span class="picker__option-name">Keep this chat</span>
|
||||
<span class="picker__option-note">
|
||||
Temporary — removed 24 hours after the last message
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
{% if can.get("chat.system_prompt") or can.get("chat.params") %}
|
||||
<button class="picker__option" type="button" role="menuitem"
|
||||
data-toggle="#chat-settings">
|
||||
{{ icon("sliders", "icon--sm") }}
|
||||
<span class="picker__option-body">
|
||||
<span class="picker__option-name">Chat settings</span>
|
||||
<span class="picker__option-note">Prompt, knowledge, sampling</span>
|
||||
</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
{% if messages %}
|
||||
<button class="picker__option" type="button" role="menuitem"
|
||||
hx-post="/api/chats/{{ chat.id }}/compact"
|
||||
hx-target="#thread" hx-swap="innerHTML"
|
||||
hx-confirm="Summarise everything before the last reply? The messages stay in the transcript; they just stop being sent to the model."
|
||||
data-confirm-title="Compact this chat"
|
||||
data-confirm-label="Compact">
|
||||
{{ icon("archive", "icon--sm") }}
|
||||
<span class="picker__option-body">
|
||||
<span class="picker__option-name">Compact</span>
|
||||
<span class="picker__option-note">
|
||||
Summarise the earlier turns so they stop costing context
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -165,6 +165,48 @@
|
||||
<path d="M13.5 10.5a3.5 3.5 0 0 0-5 0l-3 3a3.5 3.5 0 0 0 5 5L12 17"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="i-chevron-left" viewBox="0 0 24 24">
|
||||
<path d="M14.5 6 8.5 12l6 6"/>
|
||||
</symbol>
|
||||
<symbol id="i-chevron-up" viewBox="0 0 24 24">
|
||||
<path d="M6 14.5 12 8.5l6 6"/>
|
||||
</symbol>
|
||||
|
||||
<!-- @, for a mention. The tail stops short of closing so the ring reads as a
|
||||
ring at 16px rather than as a filled blob. -->
|
||||
<symbol id="i-at" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="3.6"/>
|
||||
<path d="M15.6 8.4v4.9a2.6 2.6 0 0 0 5.2 0V12a8.8 8.8 0 1 0-3.5 7"/>
|
||||
</symbol>
|
||||
|
||||
<!-- The command key, which is what a command menu is. -->
|
||||
<symbol id="i-command" viewBox="0 0 24 24">
|
||||
<path d="M9 9h6v6H9z"/>
|
||||
<path d="M9 9V7.5a2.5 2.5 0 1 0-2.5 2.5H9Zm6 0V7.5a2.5 2.5 0 1 1 2.5 2.5H15ZM9 15v1.5A2.5 2.5 0 1 1 6.5 14H9Zm6 0v1.5a2.5 2.5 0 1 0 2.5-2.5H15Z"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="i-keyboard" viewBox="0 0 24 24">
|
||||
<rect x="2.5" y="6" width="19" height="12" rx="2"/>
|
||||
<path d="M6 9.5h.01M9 9.5h.01M12 9.5h.01M15 9.5h.01M18 9.5h.01M6 12.5h.01M9 12.5h.01M12 12.5h.01M15 12.5h.01M18 12.5h.01M8.5 15.5h7"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="i-file-text" viewBox="0 0 24 24">
|
||||
<path d="M13.5 3.5H7a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V9l-5.5-5.5Z"/>
|
||||
<path d="M13.5 3.5V9H19M8.5 13h7M8.5 16.5h7"/>
|
||||
</symbol>
|
||||
|
||||
<!-- A drag handle. Dots rather than lines: lines at this size read as a
|
||||
hamburger, which means something else entirely. -->
|
||||
<symbol id="i-grip" viewBox="0 0 24 24">
|
||||
<path d="M10 6h.01M10 12h.01M10 18h.01M14 6h.01M14 12h.01M14 18h.01"/>
|
||||
</symbol>
|
||||
|
||||
<!-- Agent chats. They borrowed `server`, which is the machine rather than
|
||||
the act of working on one. -->
|
||||
<symbol id="i-bolt" viewBox="0 0 24 24">
|
||||
<path d="M13 2.5 4.5 13.5H11l-1 8 8.5-11H12l1-8Z"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="i-leaf" viewBox="0 0 64 64">
|
||||
<path d="M20.5 45.5C13.8 31.7 23.8 20.9 45.5 18.5 49.8 35 39.8 45.8 20.5 45.5Z"/>
|
||||
<path d="M20.5 45.5C28 38 36 29 45.5 18.5"/>
|
||||
|
||||
Reference in New Issue
Block a user