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:
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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.");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
|
||||
Reference in New Issue
Block a user