Two kinds of work, and a switch to say which

The sidebar rendered an agent chat and an ordinary one identically, in one
list, so hours of machine work sat among a morning's questions. A switch
below the pinned models now shows one kind at a time, stored on the account
so it follows the reader to another browser.

Three things it does that are not the obvious version:

The switch is inside the fragment it swaps. Targeting only the tree would
leave the two buttons showing the side you had just left -- the request
works and the interface says otherwise, which is the failure this codebase
keeps cataloguing.

A folder can be emptied by the filter, or have been empty all along, and
only the first is a reason to hide it. `shown_in` is that line: a folder
somebody made a moment ago and has not filled yet stays on both sides, or
it can never be found again, let alone filed into.

With agent chats switched off there is no switch, and the sidebar goes back
to showing everything rather than to one side of a fork nobody can move.
An administrator turning the feature off would otherwise strand whoever
last left the switch on Agents in an empty sidebar with no way out.

The control reuses the composer's `.segmented`, which is the same choice in
a different place, and the verb goes on the input rather than the wrapper.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-04 08:19:17 +02:00
parent 7c51dc306d
commit 9c61e40662
10 changed files with 455 additions and 48 deletions
+5
View File
@@ -1248,6 +1248,11 @@
outline: 2px solid var(--accent);
outline-offset: 1px;
}
/* The sidebar's copy fills its column rather than sitting at its content
width: it is the heading for everything below it, not a control in a row. */
.segmented--grow { display: flex; margin: 0 0 var(--sp-3); }
.segmented--grow .segmented__option { flex: 1; }
.segmented--grow .segmented__option span { flex: 1; justify-content: center; }
/* --- A plan, and the way to carry it out ----------------------------------- */
.plan {
+10 -3
View File
@@ -275,15 +275,22 @@
#}
{% if not chat and agent_profiles %}
<div class="composer__context" data-agent-picker>
<input type="hidden" name="kind" value="chat" id="chat-kind">
{# Seeded from `?kind=`, which is how the sidebar's Agent side opens
this screen already on the right fork. `ui.js` reads the checked
radio when it wires the picker, so the hidden field and the
revealed connection follow from this and nothing else. #}
<input type="hidden" name="kind" value="{{ starting_kind | default('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>
<input type="radio" name="kind_choice" value="chat"
{{ '' if starting_kind == 'agent' else 'checked' }}>
<span>{{ icon("chat", "icon--sm") }} Chat</span>
</label>
<label class="segmented__option">
<input type="radio" name="kind_choice" value="agent">
<input type="radio" name="kind_choice" value="agent"
{{ 'checked' if starting_kind == 'agent' }}>
<span>{{ icon("bolt", "icon--sm") }} Agent</span>
</label>
</div>
@@ -6,7 +6,10 @@
<div class="nav-item {% if chat and chat.id == chat_item.id %}is-active{% endif %}"
data-chat-id="{{ chat_item.id }}">
<a class="nav-item__link" href="/chat/{{ chat_item.id }}">
{{ icon("chat", "icon--sm") }}
{# The kind, in the one place a person looks. Legible even with the switch
off, which is what it is for: a chat that can run commands should not look
like one that cannot. #}
{{ icon("terminal" if chat_item.kind == "agent" else "chat", "icon--sm") }}
<span class="nav-item__label" id="chat-link-label-{{ chat_item.id }}">{{ chat_item.title }}</span>
{# Toggled out of band by the unread poll; see /api/chats/unread. #}
<span id="unread-{{ chat_item.id }}" class="unread-dot"
@@ -32,10 +32,12 @@
<div class="folder__contents" x-show="open" x-cloak>
{# Bound once: the loop and the "Empty" check must be looking at the same
list, or a folder holding only archived chats claims to be empty while
showing them. #}
{% set listed = folder.visible_chats %}
showing them. Narrowed by the sidebar's switch, so a folder shows one
kind at a time -- a folder is free to hold both. #}
{% set listed = folder.visible_chats(sidebar_kind | default("")) %}
{% set shown = folder.visible_children(sidebar_kind | default("")) %}
{% for child in folder.children %}
{% for child in shown %}
{% with folder = child %}
{% include "partials/_folder.html" %}
{% endwith %}
@@ -45,7 +47,7 @@
{% include "partials/_chat_link.html" %}
{% endfor %}
{% if not folder.children and not listed %}
{% if not shown and not listed %}
<p class="nav-empty">Empty</p>
{% endif %}
</div>
@@ -0,0 +1,76 @@
{% from "_macros.html" import icon %}
{#
The Chat/Agent switch, the folder tree and the unfiled chats.
Its own partial because it is rendered from two places: the sidebar on every
page, and `POST /api/preferences/sidebar-kind` when the switch is flicked.
Pinned models stay above it in `sidebar.html` -- they start a chat of either
kind and are not part of what is being switched.
The switch is INSIDE the swapped fragment on purpose. Targeting only the tree
would leave the two buttons showing the side you just left, which is the same
class of failure as a control whose verb goes somewhere the event does not:
the request works and the interface says otherwise. Each button keeps a stable
id so htmx puts focus back on the one that was pressed.
`sidebar_kind` is narrowed already: `sidebar_context` drops a folder holding
nothing of this kind at any depth, so the heading below cannot appear above
nothing. It is still passed down to `_folder.html`, which needs it for its own
contents and for the children it recurses into.
#}
<div id="sidebar-tree">
{% if sidebar_split %}
{#
The same component the new-chat screen uses to pick a kind, which is the
same choice in a different place. The verb goes on the input, not on the
wrapper: `change` fires on the control and bubbles through its DOM
ancestors, and htmx binds its listener to the annotated element itself.
`hx-vals` rather than relying on the input's own value being collected --
the two radios are not inside a form, so what htmx would gather is worth
not depending on.
#}
<div class="segmented segmented--grow" role="group" aria-label="Which chats to show">
{% for option, label, glyph in [("chat", "Chats", "chat"), ("agent", "Agents", "terminal")] %}
<label class="segmented__option">
<input type="radio" name="sidebar_kind" value="{{ option }}"
id="sidebar-kind-{{ option }}"
{{ 'checked' if sidebar_kind == option }}
hx-post="/api/preferences/sidebar-kind"
hx-vals='{"kind": "{{ option }}"}'
hx-trigger="change"
hx-target="#sidebar-tree" hx-swap="outerHTML">
<span>{{ icon(glyph, "icon--sm") }} {{ label }}</span>
</label>
{% endfor %}
</div>
{% endif %}
{% if folders %}
<div class="nav-group">
<div class="nav-group__label">Folders</div>
{% for folder in folders %}
{% include "partials/_folder.html" %}
{% endfor %}
</div>
{% endif %}
<div class="nav-group">
<div class="nav-group__label">
{{ "Agent chats" if sidebar_kind == "agent" else "Chats" }}
</div>
{% if unfiled_chats %}
{% for chat_item in unfiled_chats %}
{% include "partials/_chat_link.html" %}
{% endfor %}
{% else %}
<p class="nav-empty">
{%- if sidebar_kind == "agent" -%}
No agent chats yet. Nothing is stirring out there.
{%- else -%}
No chats yet. The road begins here.
{%- endif -%}
</p>
{% endif %}
</div>
</div>
+8 -22
View File
@@ -14,8 +14,12 @@
{% if can.get("chat.create") or can.get("folder.manage") %}
<div class="sidebar__actions">
{% if can.get("chat.create") %}
<a class="btn btn--primary btn--grow" href="/chat">
{{ icon("plus", "icon--sm") }} New chat
{# Carries the side the switch is on, so "New chat" on the Agent side opens
the new-chat screen already set to an agent chat. #}
<a class="btn btn--primary btn--grow"
href="/chat{{ '?kind=agent' if sidebar_kind == 'agent' else '' }}">
{{ icon("plus", "icon--sm") }}
{{ "New agent chat" if sidebar_kind == "agent" else "New chat" }}
</a>
{% endif %}
{% if can.get("folder.manage") %}
@@ -33,7 +37,7 @@
<div hidden hx-get="/api/chats/unread" hx-trigger="every 10s"
hx-swap="none"></div>
<nav class="sidebar__scroll" id="sidebar-tree" aria-label="Chats">
<nav class="sidebar__scroll" aria-label="Chats">
{% if pinned_models and can.get("chat.create") %}
{# Shortcuts to start a chat with a particular model. These link rather than
post, so no chat exists until something is actually said. #}
@@ -48,25 +52,7 @@
</div>
{% endif %}
{% if folders %}
<div class="nav-group">
<div class="nav-group__label">Folders</div>
{% for folder in folders %}
{% include "partials/_folder.html" %}
{% endfor %}
</div>
{% endif %}
<div class="nav-group">
<div class="nav-group__label">Chats</div>
{% if unfiled_chats %}
{% for chat_item in unfiled_chats %}
{% include "partials/_chat_link.html" %}
{% endfor %}
{% else %}
<p class="nav-empty">No chats yet. The road begins here.</p>
{% endif %}
</div>
{% include "partials/_sidebar_tree.html" %}
</nav>
<div class="sidebar__footer">