Pinned models that know which side you are on
Reported: a pinned model always opened an ordinary chat, even with Agents selected in the sidebar. They now carry `&kind=agent` with the switch -- a preselection like `?model=` itself, so the new-chat screen still decides and nothing is fixed until the first message is sent. They sit above the tree the switch swaps, so this is the same shape as the New chat button a few commits ago and gets the same treatment: their own partial, arriving out of band. The group is rendered even when nothing is pinned, because a block that vanished when the last model was unpinned would leave that fragment with nowhere to land -- and htmx says nothing at all when a target is missing, which is the silent failure this codebase keeps cataloguing. `.nav-group--pinned:empty` stops the empty one taking room. Chasing it turned up something else. The shortcuts came from `_chat_context`, which only the chat pages build -- so the library, connections, settings and folder pages carried the sidebar without them. A shortcut that is there on one page and gone on the next. They come from `sidebar_context` now, where they belong: it is sidebar content, and it is what the fragment route has. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -20,7 +20,7 @@ lembas info # paths + counts, useful when confused
|
||||
lembas secret-key # generate LEMBAS_SECRET_KEY
|
||||
lembas create-admin # create or promote an admin
|
||||
|
||||
pytest # 1412 tests, ~87s
|
||||
pytest # 1419 tests, ~87s
|
||||
# PLAN.md tracks what is and is not built
|
||||
ruff check . # lint (line length 100)
|
||||
python scripts/build_artwork.py # regenerate artwork (SVG + PWA icons;
|
||||
@@ -999,6 +999,17 @@ already been a bug once.
|
||||
`position` order. Pinned models get shortcuts in the chat sidebar and nothing
|
||||
else -- a picker whose order differs from the admin screen is just confusing.
|
||||
|
||||
The shortcuts come from `sidebar_context`, not from `_chat_context` where they
|
||||
began: they are sidebar content, the fragment route that re-renders the sidebar
|
||||
has only the former, and the library and connections pages carry the sidebar
|
||||
without ever calling the latter -- so the shortcuts were simply absent on all of
|
||||
them. They carry `&kind=agent` with the switch, and they sit above the tree it
|
||||
swaps, so they arrive out of band exactly as the New chat button does. The group
|
||||
is rendered **even when empty**, because a block that vanished when the last
|
||||
model was unpinned would leave that out-of-band fragment with nowhere to land,
|
||||
and htmx says nothing at all when a target is missing; `.nav-group--pinned:empty`
|
||||
is what stops the empty one taking room.
|
||||
|
||||
**System prompts are precedence, not concatenation.** chat > folder > model >
|
||||
instance, most specific wins outright
|
||||
(`services/chat.py:effective_system_prompt`). Stacking them reads well in a
|
||||
|
||||
@@ -38,9 +38,6 @@ def _chat_context(db: DBSession, user: User, chat: Chat | None) -> dict:
|
||||
current = next((m for m in models if m.model_id == chat.model_id), None) if chat else None
|
||||
return {
|
||||
"models": models,
|
||||
# For the sidebar shortcuts only. The picker lists `models` in the
|
||||
# administrator's order, pinned or not.
|
||||
"pinned_models": [m for m in models if m.pinned],
|
||||
"current_model": current,
|
||||
# Assistant bubbles show the avatar of the model that wrote them, which
|
||||
# may not be the model the chat is set to now. Keyed by model_id, the
|
||||
@@ -279,6 +276,14 @@ def sidebar_context(db: DBSession, user: User) -> dict:
|
||||
return {
|
||||
"folders": folders,
|
||||
"unfiled_chats": unfiled,
|
||||
# The shortcuts at the top of the sidebar. Here rather than in
|
||||
# `_chat_context`, where they used to be, for two reasons: they are
|
||||
# sidebar content and the fragment route that re-renders the sidebar has
|
||||
# only this, and the library and connections pages carry the sidebar
|
||||
# without ever calling `_chat_context` -- so the shortcuts simply were
|
||||
# not there on any of them. The picker lists every model in the
|
||||
# administrator's order, pinned or not; pinning is not ordering.
|
||||
"pinned_models": [m for m in chat_service.available_models(db, user) if m.pinned],
|
||||
"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
|
||||
|
||||
@@ -923,6 +923,10 @@ body.is-resizing .canvas__body { pointer-events: none; }
|
||||
/* --- Sidebar navigation ---------------------------------------------------- */
|
||||
.nav-group { margin-bottom: var(--sp-4); }
|
||||
.nav-group:last-child { margin-bottom: 0; }
|
||||
/* The pinned group is rendered even with nothing in it, so the Chat/Agent
|
||||
switch has a target to swap out of band -- htmx says nothing when a target is
|
||||
missing. Empty, it must take no room. */
|
||||
.nav-group--pinned:empty { display: none; margin: 0; }
|
||||
.nav-group__label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
{% from "_macros.html" import model_avatar %}
|
||||
{#
|
||||
Shortcuts to start a chat with a particular model. These link rather than
|
||||
post, so no chat exists until something is actually said.
|
||||
|
||||
Its own partial, and always rendered even when empty, because the Chat/Agent
|
||||
switch changes what they link to and they sit above the tree the switch
|
||||
swaps -- so they arrive out of band, exactly as the New chat button does. An
|
||||
empty div is what lets that swap find its target: a block that vanished when
|
||||
the last model was unpinned would leave the out-of-band fragment with nowhere
|
||||
to land, and htmx says nothing when a target is missing.
|
||||
#}
|
||||
<div class="nav-group nav-group--pinned" id="sidebar-pinned"
|
||||
{% if oob %}hx-swap-oob="true"{% endif %}>
|
||||
{% if pinned_models and can.get("chat.create") %}
|
||||
<div class="nav-group__label">Pinned models</div>
|
||||
{% for model in pinned_models %}
|
||||
{# Carries the side the switch is on. Picking a pinned model on the Agent
|
||||
side used to open an ordinary chat, which is the fork silently ignoring the
|
||||
one choice already made. `?kind=` is a preselection like `?model=` itself:
|
||||
the new-chat screen still decides, and nothing is fixed until the first
|
||||
message is sent. #}
|
||||
<a class="nav-item nav-item--model"
|
||||
href="/chat?model={{ model.model_id }}{{ '&kind=agent' if sidebar_kind == 'agent' else '' }}">
|
||||
{{ model_avatar(model, cls="nav-item__avatar") }}
|
||||
<span class="nav-item__label">{{ model.label }}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -56,6 +56,7 @@
|
||||
{% if oob %}
|
||||
{% with oob = true %}
|
||||
{% include "partials/_sidebar_actions.html" %}
|
||||
{% include "partials/_sidebar_pinned.html" %}
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
|
||||
|
||||
@@ -20,20 +20,7 @@
|
||||
hx-swap="none"></div>
|
||||
|
||||
<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. #}
|
||||
<div class="nav-group">
|
||||
<div class="nav-group__label">Pinned models</div>
|
||||
{% for model in pinned_models %}
|
||||
<a class="nav-item nav-item--model" href="/chat?model={{ model.model_id }}">
|
||||
{{ model_avatar(model, cls="nav-item__avatar") }}
|
||||
<span class="nav-item__label">{{ model.label }}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% include "partials/_sidebar_pinned.html" %}
|
||||
{% include "partials/_sidebar_tree.html" %}
|
||||
</nav>
|
||||
|
||||
|
||||
@@ -248,3 +248,86 @@ def test_the_switch_posts_at_a_route_that_serves_post(client: TestClient, db, re
|
||||
interface looks exactly like one that works."""
|
||||
assert client.get("/api/preferences/sidebar-kind").status_code == 405
|
||||
assert client.post("/api/preferences/sidebar-kind", data={"kind": "chat"}).status_code == 200
|
||||
|
||||
|
||||
# --- Pinned models ----------------------------------------------------------------
|
||||
def _pin(db, model_id: str = "test-model") -> None:
|
||||
model = db.scalar(select(Model).where(Model.model_id == model_id))
|
||||
model.pinned = True
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_a_pinned_model_carries_the_side_the_switch_is_on(
|
||||
client: TestClient, db, registered
|
||||
):
|
||||
"""Picking a pinned model on the Agent side used to open an ordinary chat,
|
||||
which is the fork silently ignoring the one choice already made."""
|
||||
_add_connection(db)
|
||||
_enable_agents(db)
|
||||
_pin(db)
|
||||
|
||||
page = client.get("/chat").text
|
||||
assert 'href="/chat?model=test-model"' in page
|
||||
|
||||
client.post("/api/preferences/sidebar-kind", data={"kind": "agent"})
|
||||
page = client.get("/chat").text
|
||||
assert 'href="/chat?model=test-model&kind=agent"' in page
|
||||
|
||||
|
||||
def test_the_pinned_links_follow_the_switch_without_a_reload(
|
||||
client: TestClient, db, registered
|
||||
):
|
||||
"""They sit above the tree the switch swaps, so they arrive out of band --
|
||||
the same shape as the New chat button. Asserted on the *fragment*: a page
|
||||
load re-renders them anyway, which is how the New chat button went unnoticed.
|
||||
"""
|
||||
_add_connection(db)
|
||||
_enable_agents(db)
|
||||
_pin(db)
|
||||
|
||||
response = client.post("/api/preferences/sidebar-kind", data={"kind": "agent"})
|
||||
assert 'id="sidebar-pinned"' in response.text
|
||||
assert "kind=agent" in response.text
|
||||
|
||||
response = client.post("/api/preferences/sidebar-kind", data={"kind": "chat"})
|
||||
assert 'href="/chat?model=test-model"' in response.text
|
||||
|
||||
|
||||
def test_the_pinned_group_is_always_there_to_be_swapped(
|
||||
client: TestClient, db, registered
|
||||
):
|
||||
"""Rendered even with nothing pinned. A block that vanished when the last
|
||||
model was unpinned would leave the out-of-band fragment with nowhere to
|
||||
land, and htmx says nothing at all when a target is missing."""
|
||||
_add_connection(db)
|
||||
_enable_agents(db)
|
||||
|
||||
assert 'id="sidebar-pinned"' in client.get("/chat").text
|
||||
response = client.post("/api/preferences/sidebar-kind", data={"kind": "agent"})
|
||||
assert 'id="sidebar-pinned"' in response.text
|
||||
# And it says nothing when there is nothing to say.
|
||||
assert "Pinned models" not in response.text
|
||||
|
||||
|
||||
def test_an_empty_pinned_group_takes_no_room():
|
||||
"""It is in the DOM whether or not it holds anything, so it has to collapse
|
||||
or every sidebar with nothing pinned gains a gap."""
|
||||
from pathlib import Path
|
||||
|
||||
import lembas
|
||||
|
||||
css = (Path(lembas.__file__).parent / "web/static/css/app.css").read_text(encoding="utf-8")
|
||||
assert ".nav-group--pinned:empty" in css
|
||||
|
||||
|
||||
def test_the_shortcuts_are_on_every_page_that_shows_the_sidebar(
|
||||
client: TestClient, db, registered
|
||||
):
|
||||
"""They came from the chat page's own context, so the library and the
|
||||
connections pages carried the sidebar without them -- a shortcut that is
|
||||
there on one page and gone on the next."""
|
||||
_add_connection(db)
|
||||
_pin(db)
|
||||
|
||||
for path in ("/chat", "/library/knowledge", "/agents", "/settings"):
|
||||
assert "Pinned models" in client.get(path).text, path
|
||||
|
||||
Reference in New Issue
Block a user