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:
+57
-15
@@ -8,7 +8,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.api.deps import Db, RequiredUser
|
||||
from lembas.db.models import Chat, Folder, KnowledgeBase, Message, User
|
||||
from lembas.db.models import KIND_CHAT, KINDS, Chat, Folder, KnowledgeBase, Message, User
|
||||
from lembas.security import permissions
|
||||
from lembas.services import audio as audio_service
|
||||
from lembas.services import chat as chat_service
|
||||
@@ -209,6 +209,18 @@ def _terminal_enabled(db: DBSession, user: User, chat: Chat | None, profile) ->
|
||||
return ssh_service.available() == ""
|
||||
|
||||
|
||||
def sidebar_kind(user: User) -> str:
|
||||
"""Which side of the sidebar's switch this user last chose.
|
||||
|
||||
One resolver, because the page, the fragment route and the switch's own
|
||||
pressed state all have to agree about it. Anything unrecognised -- an older
|
||||
release's value, a hand-edited row -- reads as ordinary chats rather than
|
||||
showing an empty sidebar nobody can explain.
|
||||
"""
|
||||
chosen = (user.settings_json or {}).get("sidebar_kind")
|
||||
return chosen if chosen in KINDS else KIND_CHAT
|
||||
|
||||
|
||||
def sidebar_context(db: DBSession, user: User) -> dict:
|
||||
"""Folder tree plus the chats that belong to no folder.
|
||||
|
||||
@@ -217,29 +229,50 @@ def sidebar_context(db: DBSession, user: User) -> dict:
|
||||
|
||||
Only root folders are queried; children come through the relationship and
|
||||
render recursively in the template.
|
||||
|
||||
Everything is narrowed to one `Chat.kind`. A folder the filter has emptied
|
||||
is dropped here rather than in the template, so the "Folders" heading cannot
|
||||
appear above nothing -- the same reason `visible_chats` moved off the
|
||||
template in the first place. `shown_in` is what draws that line: a folder
|
||||
that was empty to begin with is kept, on both sides.
|
||||
"""
|
||||
folders = list(
|
||||
db.scalars(
|
||||
# With the switch absent the sidebar goes back to showing everything, rather
|
||||
# than to one side of a fork nobody can move. An administrator turning agent
|
||||
# chats off would otherwise strand whoever last left the switch on Agents in
|
||||
# a sidebar that is empty with no way out of it.
|
||||
split = permissions.has(db, user, "agent.ssh") and bool(
|
||||
settings_store.agents(db).get("enabled")
|
||||
)
|
||||
kind = sidebar_kind(user) if split else ""
|
||||
|
||||
folders = [
|
||||
folder
|
||||
for folder in db.scalars(
|
||||
select(Folder)
|
||||
.where(Folder.user_id == user.id, Folder.parent_id.is_(None))
|
||||
.order_by(Folder.position, Folder.name)
|
||||
)
|
||||
if folder.shown_in(kind)
|
||||
]
|
||||
narrowed = select(Chat).where(
|
||||
Chat.user_id == user.id,
|
||||
Chat.folder_id.is_(None),
|
||||
Chat.archived.is_(False),
|
||||
Chat.temporary.is_(False),
|
||||
)
|
||||
if kind:
|
||||
narrowed = narrowed.where(Chat.kind == kind)
|
||||
unfiled = list(
|
||||
db.scalars(
|
||||
select(Chat)
|
||||
.where(
|
||||
Chat.user_id == user.id,
|
||||
Chat.folder_id.is_(None),
|
||||
Chat.archived.is_(False),
|
||||
Chat.temporary.is_(False),
|
||||
)
|
||||
.order_by(Chat.pinned.desc(), Chat.updated_at.desc())
|
||||
)
|
||||
db.scalars(narrowed.order_by(Chat.pinned.desc(), Chat.updated_at.desc()))
|
||||
)
|
||||
return {
|
||||
"folders": folders,
|
||||
"unfiled_chats": unfiled,
|
||||
"sidebar_kind": kind,
|
||||
# Whether the switch is worth showing at all. A two-way switch with one
|
||||
# useful side is worse than no switch: it offers a view that is empty by
|
||||
# construction and cannot be made otherwise.
|
||||
"sidebar_split": split,
|
||||
"can": permissions.resolve(db, user),
|
||||
}
|
||||
|
||||
@@ -315,14 +348,22 @@ async def offline(request: Request) -> Response:
|
||||
|
||||
@router.get("/chat")
|
||||
async def chat_index(
|
||||
request: Request, db: Db, user: RequiredUser, model: str = "", temporary: bool = False
|
||||
request: Request,
|
||||
db: Db,
|
||||
user: RequiredUser,
|
||||
model: str = "",
|
||||
temporary: bool = False,
|
||||
kind: str = "",
|
||||
):
|
||||
"""A composer with no chat behind it yet.
|
||||
|
||||
`?model=` preselects one, which is how the pinned shortcuts work without
|
||||
creating a row for a chat that may never be sent. `?temporary=1` is the
|
||||
same idea for the temporary flag: it lives in the URL rather than in
|
||||
JavaScript, so it survives a reload and can be bookmarked.
|
||||
JavaScript, so it survives a reload and can be bookmarked. `?kind=agent`
|
||||
is how the sidebar's Agent side opens a new chat already on that side --
|
||||
a preselection like the other two, not a decision: the kind is still
|
||||
chosen on the screen and still fixed only when the first message is sent.
|
||||
"""
|
||||
context = _chat_context(db, user, None)
|
||||
|
||||
@@ -350,6 +391,7 @@ async def chat_index(
|
||||
**context,
|
||||
"current_model": preselected,
|
||||
"starting_temporary": temporary,
|
||||
"starting_kind": kind if kind in KINDS else KIND_CHAT,
|
||||
"suggestions": suggestions_service.visible(db),
|
||||
**sidebar_context(db, user),
|
||||
},
|
||||
|
||||
@@ -76,6 +76,41 @@ async def set_layout(db: Db, user: RequiredUser, widths: dict = Body(...)) -> di
|
||||
return {"ok": True, "layout": kept}
|
||||
|
||||
|
||||
@router.post("/sidebar-kind")
|
||||
async def set_sidebar_kind(
|
||||
request: Request, db: Db, user: RequiredUser, kind: str = Form("")
|
||||
) -> Response:
|
||||
"""Switch the sidebar between ordinary chats and agent chats.
|
||||
|
||||
Saves and re-renders in one round trip, because the two cannot be allowed to
|
||||
disagree: a switch that stored a choice and left the tree showing the other
|
||||
side would look broken, and re-rendering without storing would lose it on the
|
||||
next navigation. The tree comes back as a fragment rather than an `HX-Refresh`
|
||||
-- a full reload is what `api/folders.py` does for a structural change, and it
|
||||
would throw away the folder open/closed state on every flick of the switch,
|
||||
which is the same thing `/api/chats/unread` avoids by swapping out of band.
|
||||
|
||||
An unrecognised value is refused rather than stored: `sidebar_kind` reads it
|
||||
back as "chat" anyway, so storing it would be a preference that silently
|
||||
does nothing.
|
||||
"""
|
||||
from lembas.api.pages import sidebar_context
|
||||
from lembas.db.models import KINDS
|
||||
from lembas.web.templating import templates
|
||||
|
||||
if kind not in KINDS:
|
||||
return Response(status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
user.settings_json = {**(user.settings_json or {}), "sidebar_kind": kind}
|
||||
db.commit()
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"partials/_sidebar_tree.html",
|
||||
{"chat": None, "user": user, **sidebar_context(db, user)},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/default-model")
|
||||
async def set_default_model(
|
||||
db: Db, user: RequiredUser, model_id: str = Form("")
|
||||
|
||||
@@ -57,8 +57,7 @@ class Folder(UUIDPrimaryKey, Timestamps, Base):
|
||||
parent: Mapped[Folder | None] = relationship(back_populates="children", remote_side="Folder.id")
|
||||
chats: Mapped[list[Chat]] = relationship(back_populates="folder")
|
||||
|
||||
@property
|
||||
def visible_chats(self) -> list[Chat]:
|
||||
def visible_chats(self, kind: str = "") -> list[Chat]:
|
||||
"""The chats in this folder that belong in the sidebar.
|
||||
|
||||
The relationship itself stays unfiltered -- back-population needs every
|
||||
@@ -68,13 +67,52 @@ class Folder(UUIDPrimaryKey, Timestamps, Base):
|
||||
existed. The unfiled list has always filtered them (api/pages.py); the
|
||||
folder branch went through the relationship and filtered nothing.
|
||||
|
||||
`kind` narrows to one side of the sidebar's Chat/Agent switch. Empty
|
||||
means both, which is what every caller outside the sidebar wants.
|
||||
|
||||
Ordered like the unfiled list: pinned first, then most recently touched.
|
||||
"""
|
||||
kept = [chat for chat in self.chats if not chat.archived and not chat.temporary]
|
||||
kept = [
|
||||
chat
|
||||
for chat in self.chats
|
||||
if not chat.archived and not chat.temporary and (not kind or chat.kind == kind)
|
||||
]
|
||||
kept.sort(key=lambda chat: chat.updated_at, reverse=True)
|
||||
kept.sort(key=lambda chat: not chat.pinned)
|
||||
return kept
|
||||
|
||||
def visible_children(self, kind: str = "") -> list[Folder]:
|
||||
"""Sub-folders the sidebar should show on this side of the switch.
|
||||
|
||||
Here rather than in the template because Jinja's `selectattr` names a
|
||||
test, it does not call a method -- so the filter would have to be spelled
|
||||
out as a loop appending to a list, in a template that already includes
|
||||
itself recursively.
|
||||
"""
|
||||
return [child for child in self.children if child.shown_in(kind)]
|
||||
|
||||
def holds(self, kind: str = "") -> bool:
|
||||
"""Whether anything of this kind is anywhere under this folder.
|
||||
|
||||
Recursive, because a folder's only matching chat may be three levels
|
||||
down and judging on its own contents alone would bury it.
|
||||
"""
|
||||
if self.visible_chats(kind):
|
||||
return True
|
||||
return any(child.holds(kind) for child in self.children)
|
||||
|
||||
def shown_in(self, kind: str = "") -> bool:
|
||||
"""Whether this folder belongs on one side of the sidebar's switch.
|
||||
|
||||
Two different reasons a folder can have nothing in it, and only one of
|
||||
them is a reason to hide it. A folder full of ordinary chats is noise on
|
||||
the Agent side and is dropped. A folder that is empty of *everything* is
|
||||
a container somebody just made and has not filled yet -- hiding that one
|
||||
means it can never be found again, let alone filed into, so it shows on
|
||||
both sides and says "Empty" for itself.
|
||||
"""
|
||||
return self.holds(kind) or not self.holds()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Folder {self.name}>"
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user