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. #}
+