fix(ai): sync chat panel header title with conversation rename

This commit is contained in:
Abeautifulsnow authored and GitHub committed 2026-09-22 17:51:01 +08:00
1 parent e1fc28ab86
commit 6a417b9c90
2 files changed
+91 -1

No files matched your search

@@ -1398,6 +1398,15 @@ function messagesForAgentHistory(historyMessages: ChatMessage[]): AiMessage[] {
}
const chatTitle = computed(() => {
// Same precedence as buildConversationSnapshot(): the user's rename wins,
// then the stored title, so renaming a conversation from the history list
// updates this header immediately instead of leaving the first-message
// excerpt stuck (issue #9904). Saved chats follow the stored title exactly
// like the history row (even if the first message is edited later); only
// unsaved/new chats derive from messages.
const activeConversation = conversations.value.find((conversation) => conversation.id === conversationId.value);
const conversationTitle = renamedConversationTitles.get(conversationId.value) || activeConversation?.title;
if (conversationTitle) return conversationTitle;
const first = messages.value.find((m) => m.role === "user" && m.kind !== "contextSummary");
return pluginContext.value?.title || (first ? messageTitle(first).slice(0, 30) : t("ai.newChat"));
});
@@ -4302,7 +4311,10 @@ async function persistPendingInputRecovery(conversation: AiConversation, message
const first = messages.find((m) => m.role === "user" && m.kind !== "contextSummary");
const snapshot: AiConversation = {
id: conversation.id,
title: conversation.pluginContext?.title || (first ? messageTitle(first).slice(0, 50) : conversation.title || "Untitled"),
// Same title precedence as buildConversationSnapshot(): a recovered run
// must not overwrite a title the user renamed; plugin-scoped chats keep
// the plugin-provided title.
title: conversation.pluginContext?.title || renamedConversationTitles.get(conversation.id) || conversation.title || (first ? messageTitle(first).slice(0, 50) : "Untitled"),
pluginContext: conversation.pluginContext,
connectionName: conversation.connectionName,
database: conversation.database,
@@ -4367,6 +4379,9 @@ async function commitRenameConversation(conv: AiConversation) {
const i = conversations.value.findIndex((item) => item.id === conv.id);
if (i >= 0) conversations.value[i] = updated;
} catch {
// Save failed: retract the claim so the header, the history row and the
// persisted record all stay on the old title until a rename succeeds.
renamedConversationTitles.delete(conv.id);
toast(t("ai.conversationRenameFailed"), 5000);
} finally {
cancelRenameConversation();
@@ -0,0 +1,75 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
// Regression coverage for https://github.com/t8y2/dbx/issues/9904:
//
// Renaming the current conversation from the history list updates
// `conversations` and the `renamedConversationTitles` claim map, but the
// panel header rendered `chatTitle` purely from the first user message of
// the panel-local `messages` snapshot, so the top-left title stayed stale
// (reopening the conversation didn't pick the stored title up either).
// AiAssistant.vue is a ~6000-line SFC the suite never mounts, so these
// assertions pin the template/computed wiring as text, matching the house
// source-assertion style.
const source = readFileSync(new URL("../AiAssistant.vue", import.meta.url), "utf8");
function bodyOf(fnSignature: string): string {
const start = source.indexOf(fnSignature);
expect(start, `expected to find "${fnSignature}" in AiAssistant.vue`).toBeGreaterThanOrEqual(0);
const braceStart = source.indexOf("{", start);
let depth = 0;
for (let i = braceStart; i < source.length; i++) {
if (source[i] === "{") depth++;
else if (source[i] === "}") {
depth--;
if (depth === 0) return source.slice(braceStart, i + 1);
}
}
throw new Error(`unbalanced braces reading body of "${fnSignature}"`);
}
describe("AI assistant header title tracks the conversation title", () => {
it("chatTitle prefers the renamed/stored title of the open conversation over the first-message excerpt", () => {
// The claim map must stay reactive — it is the header's invalidation link
// to a rename that commits while the save is still in flight.
expect(source).toContain("const renamedConversationTitles = reactive(new Map<string, string>());");
const body = bodyOf("const chatTitle = computed");
// Both the rename-claim map and the conversation list must be reactive
// dependencies so a commitRenameConversation() write re-renders the header.
expect(body).toContain("renamedConversationTitles.get(conversationId.value)");
expect(body).toContain("conversations.value.find(");
expect(body).toContain("conversation.id === conversationId.value");
expect(body).toContain("messages.value.find");
// Same precedence as buildConversationSnapshot(): the rename claim wins,
// the stored title comes next, and the first-message excerpt is only the
// fallback for unsaved/new chats. (The toContain guards above keep these
// index orderings from passing vacuously at -1.)
expect(body.indexOf("renamedConversationTitles.get(conversationId.value)")).toBeLessThan(body.indexOf("activeConversation?.title"));
expect(body.indexOf("renamedConversationTitles.get(conversationId.value)")).toBeLessThan(body.indexOf("messages.value.find"));
expect(body).toContain("messageTitle(first).slice(0, 30)");
expect(body).toContain('t("ai.newChat")');
});
it("the header renders chatTitle and rename commits feed the sources chatTitle reads", () => {
expect(source).toContain("{{ chatTitle }}");
const commit = bodyOf("async function commitRenameConversation(conv: AiConversation)");
// The claim must be set before the async save (title race guard) and the
// list entry replaced afterwards — both writes are what make the header
// reactive to the rename.
expect(commit).toContain("renamedConversationTitles.set(conv.id, title);");
expect(commit.indexOf("renamedConversationTitles.set(conv.id, title);")).toBeLessThan(commit.indexOf("await saveAiConversation(updated)"));
expect(commit).toContain("conversations.value[i] = updated;");
// A failed save retracts the claim so the header, the history row and the
// persisted record all keep the old title.
expect(commit).toContain("renamedConversationTitles.delete(conv.id);");
});
it("pending-input recovery keeps a renamed title instead of re-deriving it from the first message", () => {
// A recovered queued run used to persist `messageTitle(first)` outright,
// overwriting a renamed title on disk (and, via syncPersistedConversation,
// in the list the header now reads).
const recovery = bodyOf("async function persistPendingInputRecovery(");
expect(recovery).toContain("renamedConversationTitles.get(conversation.id)");
expect(recovery.indexOf("renamedConversationTitles.get(conversation.id)")).toBeLessThan(recovery.indexOf("messageTitle(first)"));
});
});