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),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user