fix(ai): sync chat panel header title with conversation rename
This commit is contained in:
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)"));
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user