diff --git a/src/lembas/api/pages.py b/src/lembas/api/pages.py index 138f53b..07efa86 100644 --- a/src/lembas/api/pages.py +++ b/src/lembas/api/pages.py @@ -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 diff --git a/src/lembas/web/static/css/app.css b/src/lembas/web/static/css/app.css index dd6c5c3..722cffe 100644 --- a/src/lembas/web/static/css/app.css +++ b/src/lembas/web/static/css/app.css @@ -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; diff --git a/src/lembas/web/templates/partials/_sidebar_pinned.html b/src/lembas/web/templates/partials/_sidebar_pinned.html new file mode 100644 index 0000000..d8277c5 --- /dev/null +++ b/src/lembas/web/templates/partials/_sidebar_pinned.html @@ -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. +#} +
diff --git a/src/lembas/web/templates/partials/_sidebar_tree.html b/src/lembas/web/templates/partials/_sidebar_tree.html index 8da472f..3495e12 100644 --- a/src/lembas/web/templates/partials/_sidebar_tree.html +++ b/src/lembas/web/templates/partials/_sidebar_tree.html @@ -56,6 +56,7 @@ {% if oob %} {% with oob = true %} {% include "partials/_sidebar_actions.html" %} + {% include "partials/_sidebar_pinned.html" %} {% endwith %} {% endif %} diff --git a/src/lembas/web/templates/partials/sidebar.html b/src/lembas/web/templates/partials/sidebar.html index 3de2712..6a5d4e3 100644 --- a/src/lembas/web/templates/partials/sidebar.html +++ b/src/lembas/web/templates/partials/sidebar.html @@ -20,20 +20,7 @@ hx-swap="none"> diff --git a/tests/test_sidebar_split.py b/tests/test_sidebar_split.py index 5742fc4..360b9e0 100644 --- a/tests/test_sidebar_split.py +++ b/tests/test_sidebar_split.py @@ -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