1 Commits
Author SHA1 Message Date
HomerandClaude Opus 5.5 e65ea90fe6 A temporary chat that keeps the model it was started on
The new-chat screen keeps its preselections in the query string, and the
three ways off it each rebuilt the URL with only their own key: the
Temporary button went to /chat?temporary=1, the model picker to
/chat?model=<id>, and /temp to /chat?temporary=1. So each undid the other,
and a temporary chat could only be started on the default model. The
folder from "New chat here" and the agent kind were dropped the same way.

chat_index now builds both URLs from one set of carried values and changes
only its own key; /temp keeps location.search when run on /chat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 13:07:57 +00:00
8 changed files with 112 additions and 6 deletions
+9
View File
@@ -16,6 +16,15 @@ for 1.0.0 have something to be assembled from.
## Unreleased ## Unreleased
## 1.9.1
- **A temporary chat can be started on any model.** On the new-chat screen,
turning on Temporary switched the model back to the default, and choosing a
model switched Temporary off, so a temporary chat could only ever be started
on the default model. The Temporary button, the model menu and `/temp` now
keep each other's choice, and also keep the folder a chat was started in
("New chat here") and whether it is an agent chat.
## 1.9.0 ## 1.9.0
The model menu says which model is loaded, and a new chat now matches the The model menu says which model is loaded, and a new chat now matches the
+1 -1
View File
@@ -1,3 +1,3 @@
"""LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints.""" """LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints."""
__version__ = "1.9.0" __version__ = "1.9.1"
+21
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from urllib.parse import urlencode
from zoneinfo import available_timezones from zoneinfo import available_timezones
from fastapi import APIRouter, HTTPException, Request, Response, status from fastapi import APIRouter, HTTPException, Request, Response, status
@@ -727,6 +728,24 @@ async def chat_index(
if preselected is None and context["models"]: if preselected is None and context["models"]:
preselected = context["models"][0] preselected = context["models"][0]
# Every preselection lives in the URL, so every link that changes one of
# them has to carry the rest. The temporary toggle used to link to a bare
# `/chat?temporary=1` and the model picker to a bare `/chat?model=`, so
# each undid the other: temporary chats could only ever be started on the
# default model. The model goes last in the picker's URL because ui.js
# appends the chosen id to it.
carried = {
"model": model if model and preselected and preselected.model_id == model else "",
"temporary": "1" if temporary else "",
"kind": kind if kind in KINDS and kind != KIND_CHAT else "",
"folder": starting_folder.id if starting_folder is not None else "",
}
def new_chat_url(**changes: str) -> str:
query = urlencode({k: v for k, v in {**carried, **changes}.items() if v})
return f"/chat?{query}" if query else "/chat"
without_model = new_chat_url(model="")
return render( return render(
request, request,
"chat/index.html", "chat/index.html",
@@ -749,6 +768,8 @@ async def chat_index(
else chat_service.DEFAULT_EFFORTS else chat_service.DEFAULT_EFFORTS
), ),
"starting_temporary": temporary, "starting_temporary": temporary,
"temporary_toggle_url": new_chat_url(temporary="" if temporary else "1"),
"model_navigate_url": without_model + ("&" if "?" in without_model else "?") + "model=",
"starting_kind": kind if kind in KINDS else KIND_CHAT, "starting_kind": kind if kind in KINDS else KIND_CHAT,
"starting_folder": starting_folder, "starting_folder": starting_folder,
"suggestions": suggestions_service.visible(db), "suggestions": suggestions_service.visible(db),
+10 -1
View File
@@ -222,7 +222,16 @@
{ {
name: "temp", name: "temp",
summary: "Start a temporary chat, gone after a day", summary: "Start a temporary chat, gone after a day",
run: function () { window.location = "/chat?temporary=1"; } run: function () {
/* On the new-chat screen the model, folder and kind already chosen are
in the query string. Add the flag to them rather than starting over,
or the chat is made on the default model. */
var query = new URLSearchParams(
window.location.pathname === "/chat" ? window.location.search : ""
);
query.set("temporary", "1");
window.location = "/chat?" + query.toString();
}
}, },
{ {
name: "stop", name: "stop",
@@ -91,7 +91,9 @@
</form> </form>
{% else %} {% else %}
{# No chat yet: selecting navigates so the whole composer re-renders with the {# No chat yet: selecting navigates so the whole composer re-renders with the
right vision warning and the right hidden model_id. #} right vision warning and the right hidden model_id. The URL carries the
<span hidden data-picker-navigate="/chat?model="></span> other preselections -- temporary, folder, kind -- and ends in `model=`,
which ui.js completes. #}
<span hidden data-picker-navigate="{{ model_navigate_url }}"></span>
{% endif %} {% endif %}
</div> </div>
+3 -2
View File
@@ -65,11 +65,12 @@
<div class="topbar__actions"> <div class="topbar__actions">
{# {#
A link, not a script: the flag lives in the URL, so it survives a A link, not a script: the flag lives in the URL, so it survives a
reload and can be bookmarked. reload and can be bookmarked. The URL is built in `chat_index` so it
keeps the chosen model, folder and kind.
#} #}
{% if not chat and can.get("chat.create") %} {% if not chat and can.get("chat.create") %}
<a class="btn btn--icon {{ 'is-active' if starting_temporary }}" <a class="btn btn--icon {{ 'is-active' if starting_temporary }}"
href="{{ '/chat' if starting_temporary else '/chat?temporary=1' }}" href="{{ temporary_toggle_url }}"
aria-label="{{ t('Temporary chat') }}" aria-label="{{ t('Temporary chat') }}"
title="{% if starting_temporary %}Starting a temporary chat. Click to go back to a normal one.{% else %}Start a temporary chat: not listed in the sidebar, and removed after a day.{% endif %}"> title="{% if starting_temporary %}Starting a temporary chat. Click to go back to a normal one.{% else %}Start a temporary chat: not listed in the sidebar, and removed after a day.{% endif %}">
{{ icon("clock") }} {{ icon("clock") }}
+9
View File
@@ -58,3 +58,12 @@ def test_send_from_anywhere_never_means_stop():
stops.""" stops."""
window = SOURCE[SOURCE.index('event.code === "Enter"') :][:600] window = SOURCE[SOURCE.index('event.code === "Enter"') :][:600]
assert 'composerAction === "send"' in window assert 'composerAction === "send"' in window
def test_temp_keeps_what_the_new_chat_screen_already_chose():
"""`/temp` went to a bare `/chat?temporary=1`, so on a new-chat screen with
a model picked it quietly swapped back to the default model."""
window = SOURCE[SOURCE.index('name: "temp"') :][:600]
assert 'window.location = "/chat?temporary=1"' not in window
assert "window.location.search" in window
assert 'query.set("temporary", "1")' in window
+55
View File
@@ -2,6 +2,8 @@
from __future__ import annotations from __future__ import annotations
import html
import re
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@@ -100,6 +102,59 @@ def test_the_new_chat_screen_carries_the_flag(client: TestClient, db, registered
assert 'name="temporary"' in client.get("/chat?temporary=1").text assert 'name="temporary"' in client.get("/chat?temporary=1").text
def _temporary_link(page: str) -> str:
match = re.search(r'href="([^"]*)"\s+aria-label="Temporary chat"', page)
assert match, "no temporary toggle on the page"
return html.unescape(match.group(1))
def _picker_url(page: str) -> str:
match = re.search(r'data-picker-navigate="([^"]*)"', page)
assert match, "no navigating model picker on the page"
return html.unescape(match.group(1))
def test_the_temporary_toggle_keeps_the_chosen_model(client: TestClient, db, registered):
"""Temporary chats could only be started on the default model: the toggle
linked to a bare `/chat?temporary=1`, so the model picked a moment before
was dropped."""
connection = _connection(db)
db.add(Model(connection_id=connection.id, model_id="other-model"))
db.commit()
on = _temporary_link(client.get("/chat?model=other-model").text)
assert "model=other-model" in on and "temporary=1" in on
assert 'name="model_id" value="other-model"' in client.get(on).text
off = _temporary_link(client.get(on).text)
assert "model=other-model" in off and "temporary" not in off
def test_choosing_a_model_keeps_the_temporary_flag(client: TestClient, db, registered):
"""The other half: the picker navigated to a bare `/chat?model=`, so
picking a model after the toggle quietly made the chat an ordinary one."""
connection = _connection(db)
db.add(Model(connection_id=connection.id, model_id="other-model"))
db.commit()
url = _picker_url(client.get("/chat?temporary=1").text)
assert url.endswith("model=")
page = client.get(url + "other-model").text
assert 'name="temporary"' in page
assert 'name="model_id" value="other-model"' in page
assert _picker_url(client.get("/chat").text) == "/chat?model="
def test_both_links_keep_the_folder(client: TestClient, db, registered):
_connection(db)
client.post("/api/folders", data={"name": "Quests"})
folder = db.scalar(select(Folder))
page = client.get(f"/chat?folder={folder.id}").text
assert f"folder={folder.id}" in _temporary_link(page)
assert f"folder={folder.id}" in _picker_url(page)
def test_starting_a_temporary_chat_sets_the_flag(client: TestClient, db, registered): def test_starting_a_temporary_chat_sets_the_flag(client: TestClient, db, registered):
_connection(db) _connection(db)
client.post("/api/chats/start", data={"content": "hello", "temporary": "true"}) client.post("/api/chats/start", data={"content": "hello", "temporary": "true"})