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) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-04 08:46:08 +02:00
parent 6fb260892f
commit 50270e13f7
6 changed files with 279 additions and 4 deletions
+10 -3
View File
@@ -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.");
});
}
+17
View File
@@ -25,6 +25,23 @@
<h1 class="topbar__title">
<span id="chat-title">{{ chat.title if chat else "New chat" }}</span>
{#
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 %}
<button class="btn btn--icon btn--sm topbar__rename" type="button"
hx-patch="/api/chats/{{ chat.id }}" hx-swap="none"
data-prompt="What should this chat be called?"
data-prompt-title="Rename chat" data-prompt-field="title"
data-prompt-value="{{ chat.title }}"
aria-label="Rename chat" title="Rename chat">
{{ icon("pencil", "icon--sm") }}
</button>
{% 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 %}
@@ -16,6 +16,17 @@
{{ '' if chat_item.unread else 'hidden' }} title="New reply"></span>
</a>
<span class="nav-item__actions">
{# 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. #}
<button class="btn btn--icon btn--sm" type="button"
hx-patch="/api/chats/{{ chat_item.id }}" hx-swap="none"
data-prompt="What should this chat be called?"
data-prompt-title="Rename chat" data-prompt-field="title"
data-prompt-value="{{ chat_item.title }}"
aria-label="Rename chat" title="Rename chat">
{{ icon("pencil", "icon--sm") }}
</button>
<button class="btn btn--icon btn--sm" type="button"
hx-delete="/api/chats/{{ chat_item.id }}"
hx-confirm="Delete “{{ chat_item.title }}”? This cannot be undone."