From 50270e13f7c6446c4b94a640158e753e74a21952 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaroslav=20Bene=C5=A1?= Date: Tue, 4 Aug 2026 08:46:08 +0200 Subject: [PATCH] Names that fit the chat, and a way to change one Two things about titles were wrong. Every chat spent a second completion on its name, including an agent chat whose opening words are already a title -- somebody starting one states an objective, not a topic. An agent chat now takes `fallback_title` from its first prompt and makes no request at all; an ordinary chat, which opens with a question whose *answer* is what makes a title worth asking for, is unchanged. And renaming existed only as the `/title` slash command, which set the heading and left the sidebar row showing the old name until the next reload -- a rename that looks half-applied is one people do twice. There are pencil buttons on the heading and on every sidebar row now, both PATCHing the route that was already there, and `update_chat` answers a rename with the out-of-band pair the `done` frame has always sent, so one response moves both. Only on a rename: sending it for every PATCH would overwrite the heading from an unrelated save. `/title` sets both spans itself, being a bare fetch rather than htmx. The dialog is the `data-prompt` mechanism the folder work added, which is why the heading keeps a button rather than becoming an inline field: it sits in a flex row beside the badges and the connection chip, and swapping it for a text box moves all of them. Co-Authored-By: Claude Opus 5 (1M context) --- src/lembas/api/chats.py | 12 + src/lembas/services/generation.py | 9 +- src/lembas/web/static/js/commands.js | 13 +- src/lembas/web/templates/chat/index.html | 17 ++ .../web/templates/partials/_chat_link.html | 11 + tests/test_chat_titles.py | 221 ++++++++++++++++++ 6 files changed, 279 insertions(+), 4 deletions(-) create mode 100644 tests/test_chat_titles.py diff --git a/src/lembas/api/chats.py b/src/lembas/api/chats.py index dad3f7e..1a29379 100644 --- a/src/lembas/api/chats.py +++ b/src/lembas/api/chats.py @@ -1463,12 +1463,14 @@ async def update_chat(request: Request, db: Db, user: RequiredUser, chat_id: str allowed = permissions.resolve(db, user) form = await request.form() + renamed = False if "title" in form: cleaned = str(form["title"]).strip()[:300] if cleaned: chat.title = cleaned # An explicit rename must not be overwritten by auto-titling later. chat.title_generated = True + renamed = True if "folder_id" in form: chat.folder_id = str(form["folder_id"]) or None @@ -1576,6 +1578,16 @@ async def update_chat(request: Request, db: Db, user: RequiredUser, chat_id: str chat.params_json = {**(chat.params_json or {}), "reasoning_effort": seeded} db.commit() + + if renamed: + # The two out-of-band spans the `done` frame already uses, so one + # response updates the heading *and* the sidebar row. Renaming used to + # be the `/title` command alone, which set the heading and left the + # sidebar showing the old name until the next reload -- a rename that + # looks half-applied is one people do twice. + return HTMLResponse( + templates.get_template("chat/_title_oob.html").render({"chat": chat}) + ) return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/src/lembas/services/generation.py b/src/lembas/services/generation.py index 460a1de..e71da32 100644 --- a/src/lembas/services/generation.py +++ b/src/lembas/services/generation.py @@ -406,6 +406,13 @@ async def _run(generation: Generation) -> None: ) question = _question_from(payload) needs_title = not chat.title_generated + # An agent chat is titled from its opening words and never costs a + # model call for it. That prompt is a good title already -- somebody + # starting one states an objective, not a topic -- while an ordinary + # chat opens with a question, whose answer is what makes a title + # worth asking for. Read here with the rest, because titling happens + # after this session has closed. + title_from_prompt = chat.kind == KIND_AGENT # Read here, with the rest, because titling happens after this # session has closed and must not open another one. title_prompt = prompts_service.resolve(db, "task.title") @@ -714,7 +721,7 @@ async def _run(generation: Generation) -> None: # a chat title is never worth surfacing an error for. title = "" if needs_title and question: - if generation.error or endpoint is None: + if title_from_prompt or generation.error or endpoint is None: title = chat_service.fallback_title(question) else: with contextlib.suppress(Exception): diff --git a/src/lembas/web/static/js/commands.js b/src/lembas/web/static/js/commands.js index a2dde4e..f127dd3 100644 --- a/src/lembas/web/static/js/commands.js +++ b/src/lembas/web/static/js/commands.js @@ -115,9 +115,16 @@ body.append("title", wanted); fetch("/api/chats/" + chat(), { method: "PATCH", body: body, credentials: "same-origin" }) .then(function () { - var heading = el("#chat-title"); - // textContent, never innerHTML: this is text somebody typed. - if (heading) heading.textContent = wanted; + /* Both places the title appears. The heading alone left the sidebar + row showing the old name until the next reload, which reads as a + rename that half worked -- and is the reason the route now hands + back the out-of-band pair for every other caller. This one is a + bare fetch rather than htmx, so it sets them itself. + + textContent, never innerHTML: this is text somebody typed. */ + [el("#chat-title"), el("#chat-link-label-" + chat())].forEach(function (node) { + if (node) node.textContent = wanted; + }); note("Renamed."); }); } diff --git a/src/lembas/web/templates/chat/index.html b/src/lembas/web/templates/chat/index.html index cd93e30..40ad48f 100644 --- a/src/lembas/web/templates/chat/index.html +++ b/src/lembas/web/templates/chat/index.html @@ -25,6 +25,23 @@

{{ chat.title if chat else "New chat" }} + {# + Rename, where the name is. A themed dialog through `data-prompt` + rather than an inline field: the heading is in a flex row beside the + badges and the connection chip, and swapping it for a text box moves + all of them. The response is the same out-of-band pair the `done` + frame sends, so the sidebar row follows without a second request. + #} + {% if chat %} + + {% endif %} {# Beside the title because it describes the chat rather than acting on it. The way out of it is Keep, in the overflow menu. #} {% if chat and chat.temporary %} diff --git a/src/lembas/web/templates/partials/_chat_link.html b/src/lembas/web/templates/partials/_chat_link.html index fae2045..dbf6325 100644 --- a/src/lembas/web/templates/partials/_chat_link.html +++ b/src/lembas/web/templates/partials/_chat_link.html @@ -16,6 +16,17 @@ {{ '' if chat_item.unread else 'hidden' }} title="New reply"> + {# Works from any page carrying the sidebar, not only from inside the chat. + The response carries both out-of-band spans, so the heading follows if + this happens to be the open chat. #} +