From c450f1d222f7bd6920c5be3efad41825d7cc5d0b Mon Sep 17 00:00:00 2001 From: Logan Yang Date: Sat, 26 Sep 2026 18:13:08 -0700 Subject: [PATCH 1/4] Keep Agent Mode session titles consistent --- docs/agent-mode-and-tools.md | 1 + .../AgentChatPersistenceManager.test.ts | 62 +++++++- .../session/AgentChatPersistenceManager.ts | 132 ++++++++-------- .../session/AgentSessionManager.test.ts | 143 +++++++++++++++++- src/agentMode/session/AgentSessionManager.ts | 102 ++++++++++--- src/agentMode/ui/AgentTabStrip.tsx | 6 +- src/utils/chatHistoryUtils.test.ts | 50 +++--- src/utils/chatHistoryUtils.ts | 11 +- 8 files changed, 400 insertions(+), 107 deletions(-) diff --git a/docs/agent-mode-and-tools.md b/docs/agent-mode-and-tools.md index d1421ab3b8..0a92daa81b 100644 --- a/docs/agent-mode-and-tools.md +++ b/docs/agent-mode-and-tools.md @@ -135,6 +135,7 @@ Agent Chat keeps each conversation separate: - Select **New Chat** to reset the current tab. - Select **Stop** to cancel the current turn. Queued follow-ups return to the message box, with their notes, web pages, agent mentions, and images, so you can edit or resend them. Queued content comes before the current draft, and custom commands return as their expanded text. - Use **Recent Chats** from the Agent Chat home screen, or **Chat History** inside a conversation, to resume saved work. The **Recent Chats** list can be scrolled or searched. +- Rename a session from its tab or history list to give it a meaningful title. The same title appears in both places when you reopen the chat. - Select **Copy Chat Link** beside **New Chat** to copy an `obsidian://` link to the current conversation. Paste it into another note to reopen the chat in the same vault. A saved conversation stays linked after its Markdown note is renamed; a conversation stored only by its agent can be linked once the agent has a session ID. - A spinner marks a chat while its agent is responding. When the response stops but the session remains live, a small blue dot appears on the bottom-right corner of the chat icon. Hover the row and select **Close session** to release that session. Saved history stays available to reopen, and other chats keep running. If the agent does not support closing individual sessions or release fails, the chat stays marked live with an error. - Add the active note, selected text, other notes, folders, a Copilot Web Viewer tab, or supported images. You can also mention a note with `[[Note title]]`. diff --git a/src/agentMode/session/AgentChatPersistenceManager.test.ts b/src/agentMode/session/AgentChatPersistenceManager.test.ts index 171c1ca9df..e6e9d08ee1 100644 --- a/src/agentMode/session/AgentChatPersistenceManager.test.ts +++ b/src/agentMode/session/AgentChatPersistenceManager.test.ts @@ -1,6 +1,6 @@ /* eslint-disable obsidianmd/no-tfile-tfolder-cast -- test fixtures; not real TFiles */ import { AI_SENDER, USER_SENDER } from "@/constants"; -import { readFrontmatterViaAdapter } from "@/utils/vaultAdapterUtils"; +import { patchFrontmatter, readFrontmatterViaAdapter } from "@/utils/vaultAdapterUtils"; import { AgentChatPersistenceManager } from "./AgentChatPersistenceManager"; import { GLOBAL_SCOPE } from "./scope"; import type { AgentChatMessage } from "./types"; @@ -42,6 +42,7 @@ jest.mock("@/utils", () => ({ jest.mock("@/utils/vaultAdapterUtils", () => ({ isInVaultCache: jest.fn(() => false), listMarkdownFiles: jest.fn().mockResolvedValue([]), + patchFrontmatter: jest.fn(), readFrontmatterViaAdapter: jest.fn().mockResolvedValue(null), })); @@ -131,6 +132,65 @@ describe("AgentChatPersistenceManager", () => { }); describe("saveSession()", () => { + it("keeps a renamed title when metadata cache lags the next save https://github.com/logancyang/obsidian-copilot/issues/3378", async () => { + const messages = [makeMessage(USER_SENDER, "Original prompt")]; + const saved = await manager.saveSession(messages, "opencode", { label: "Old label" }); + const file = app.files.get(saved!.path)!; + Object.setPrototypeOf(file, TFile.prototype); + file.contents = file.contents!.replace("---\n", '---\ntopic: "Current title"\n'); + app.metadataCache.getFileCache.mockReturnValue({ + frontmatter: { topic: "Old title", agentLabel: "Old label" }, + } as never); + + await manager.saveSession(messages, "opencode", { + existingPath: saved!.path, + label: "Current title", + }); + + expect(file.contents).toContain('topic: "Current title"'); + expect(file.contents).toContain('agentLabel: "Current title"'); + }); + + it("keeps the latest tab rename after an in-flight autosave and reload https://github.com/logancyang/obsidian-copilot/issues/3378", async () => { + const messages = [makeMessage(USER_SENDER, "Original prompt")]; + const saved = await manager.saveSession(messages, "opencode", { label: "Alpha" }); + const file = app.files.get(saved!.path)!; + Object.setPrototypeOf(file, TFile.prototype); + file.contents = file.contents!.replace("---\n", '---\ntopic: "Alpha"\n'); + let finishRead!: (content: string) => void; + let readStarted!: () => void; + const started = new Promise((resolve) => (readStarted = resolve)); + app.vault.adapter.read.mockImplementationOnce( + () => + new Promise((resolve) => { + finishRead = resolve; + readStarted(); + }) + ); + (patchFrontmatter as jest.Mock).mockImplementationOnce(async (_app, path, updates) => { + const target = app.files.get(path)!; + target.contents = target.contents!.replace(/^topic:.*$/m, `topic: "${updates.topic}"`); + }); + + const saving = manager.saveSession(messages, "opencode", { + existingPath: saved!.path, + label: "Alpha", + }); + await started; + const renaming = manager.updateTopic(saved!.path, "Beta"); + expect(patchFrontmatter).not.toHaveBeenCalled(); + finishRead(file.contents); + await saving; + await renaming; + await manager.saveSession(messages, "opencode", { + existingPath: saved!.path, + label: "Beta", + }); + + const loaded = await manager.loadFile(file as unknown as TFile); + expect(loaded.topic).toBe("Beta"); + expect(loaded.label).toBe("Beta"); + }); it("preserves organizer links after native rehydration, reopening and later deletion (https://github.com/Brevilabs/obsidian-copilot-private/issues/533)", async () => { const message = { ...makeMessage(USER_SENDER, "image"), diff --git a/src/agentMode/session/AgentChatPersistenceManager.ts b/src/agentMode/session/AgentChatPersistenceManager.ts index 7eea6596bf..a1b879c9d9 100644 --- a/src/agentMode/session/AgentChatPersistenceManager.ts +++ b/src/agentMode/session/AgentChatPersistenceManager.ts @@ -22,7 +22,6 @@ import { isNameTooLongError, listMarkdownFiles, patchFrontmatter, - readFrontmatterViaAdapter, trashFile, } from "@/utils/vaultAdapterUtils"; import { joinPosix } from "@/utils/pathUtils"; @@ -115,9 +114,21 @@ interface ExistingMeta { */ export class AgentChatPersistenceManager { private readonly loadedTranscripts = new Map(); + private readonly pendingFileWrites = new Map>(); constructor(private readonly app: App) {} + private async serializeFileWrite(path: string, write: () => Promise): Promise { + const previous = this.pendingFileWrites.get(path); + const result = previous ? previous.catch(() => undefined).then(write) : write(); + this.pendingFileWrites.set(path, result); + try { + return await result; + } finally { + if (this.pendingFileWrites.get(path) === result) this.pendingFileWrites.delete(path); + } + } + private async updateTranscript(path: string, content: string): Promise { const key = this.app.vault.getAbstractFileByPath(path) ?? path; const baseline = await updateChatTranscript( @@ -156,6 +167,22 @@ export class AgentChatPersistenceManager { /** Latest token-usage snapshot to persist for resume. */ usage?: SessionUsage; } + ): Promise<{ path: string } | null> { + // A rename and an already-running autosave can otherwise write the same + // note out of order and restore its previous title. + // https://github.com/logancyang/obsidian-copilot/issues/3378 + if (options?.existingPath) { + return this.serializeFileWrite(options.existingPath, () => + this.saveSessionNow(messages, backendId, options) + ); + } + return this.saveSessionNow(messages, backendId, options); + } + + private async saveSessionNow( + messages: AgentChatMessage[], + backendId: BackendId, + options?: Parameters[2] ): Promise<{ path: string } | null> { if (messages.length === 0) return null; @@ -172,24 +199,19 @@ export class AgentChatPersistenceManager { const existingFile = options?.existingPath ? this.resolveExistingFile(options.existingPath) : null; - const existingMeta = existingFile ? await this.readExistingMeta(existingFile) : {}; - const preferredFileName = existingFile ? existingFile.path - : this.generateFileName( - messages, - firstMessageEpoch, - conversationsFolder, - existingMeta.topic - ); + : this.generateFileName(messages, firstMessageEpoch, conversationsFolder); + const previousContent = (await this.app.vault.adapter.exists(preferredFileName)) + ? await this.app.vault.adapter.read(preferredFileName) + : ""; + const existingMeta = existingFile ? this.readExistingMeta(previousContent) : {}; const preparedMessages = await prepareChatImagesForSave( this.app, messages, conversationsFolder, - (await this.app.vault.adapter.exists(preferredFileName)) - ? await this.app.vault.adapter.read(preferredFileName) - : "", + previousContent, preferredFileName ); const chatContent = this.formatChatContent(preparedMessages); @@ -199,7 +221,9 @@ export class AgentChatPersistenceManager { firstMessageEpoch, backendId, topic: existingMeta.topic, - label: options?.label ?? existingMeta.label, + // An explicit cleared tab name must not resurrect the old saved label. + // https://github.com/logancyang/obsidian-copilot/issues/3378 + label: options?.label === undefined ? existingMeta.label : options.label, modelKey: options?.modelKey, lastAccessedAt: existingMeta.lastAccessedAt, sessionId: options?.sessionId ?? existingMeta.sessionId, @@ -303,7 +327,12 @@ export class AgentChatPersistenceManager { /** Update the user-visible topic in frontmatter. */ async updateTopic(fileId: string, newTopic: string): Promise { - await patchFrontmatter(this.app, fileId, { topic: newTopic.trim() }); + await this.serializeFileWrite(fileId, () => + patchFrontmatter(this.app, fileId, { + topic: newTopic.trim(), + agentLabel: newTopic.trim(), + }) + ); } async deleteFile(fileId: string): Promise { @@ -326,34 +355,21 @@ export class AgentChatPersistenceManager { return file instanceof TFile ? file : null; } - private async readExistingMeta(file: TFile): Promise { - const cached = this.app.metadataCache.getFileCache(file)?.frontmatter; - if (cached) { - return { - topic: cached.topic, - label: cached.agentLabel, - lastAccessedAt: - typeof cached.lastAccessedAt === "number" ? cached.lastAccessedAt : undefined, - sessionId: typeof cached.sessionId === "string" ? cached.sessionId : undefined, - projectId: coerceProjectId(cached.projectId), - usage: parseUsageJson(cached.usage), - }; - } - try { - const fm = await readFrontmatterViaAdapter(this.app, file.path); - if (!fm) return {}; - const lastAccessed = fm.lastAccessedAt ? Number(fm.lastAccessedAt) : undefined; - return { - topic: fm.topic, - label: fm.agentLabel, - lastAccessedAt: lastAccessed && Number.isFinite(lastAccessed) ? lastAccessed : undefined, - sessionId: typeof fm.sessionId === "string" ? fm.sessionId : undefined, - projectId: coerceProjectId(fm.projectId), - usage: parseUsageJson(fm.usage), - }; - } catch { - return {}; - } + private readExistingMeta(content: string): ExistingMeta { + // A rename can reach disk before Obsidian refreshes metadataCache. + // Autosave must round-trip the current topic instead of restoring its + // previous value from that stale cache. + // https://github.com/logancyang/obsidian-copilot/issues/3378 + const { frontmatter: current } = this.splitFrontmatter(content); + const lastAccessed = current.lastAccessedAt ? Number(current.lastAccessedAt) : undefined; + return { + topic: current.topic, + label: current.agentLabel, + lastAccessedAt: lastAccessed && Number.isFinite(lastAccessed) ? lastAccessed : undefined, + sessionId: typeof current.sessionId === "string" ? current.sessionId : undefined, + projectId: coerceProjectId(current.projectId), + usage: parseUsageJson(current.usage), + }; } private formatChatContent(messages: AgentChatMessage[]): string { @@ -456,30 +472,24 @@ export class AgentChatPersistenceManager { private generateFileName( messages: AgentChatMessage[], firstMessageEpoch: number, - folder: string, - topic?: string + folder: string ): string { const settings = getSettings(); const formatted = formatDateTime(new Date(firstMessageEpoch)); const timestampFileName = formatted.fileName; - let topicForFilename: string; - if (topic) { - topicForFilename = topic; - } else { - const firstUser = messages.find((m) => m.sender === USER_SENDER); - topicForFilename = firstUser - ? firstUser.message - .replace(/\[\[([^\]]+)\]\]/g, "$1") - .replace(/[{}[\]]/g, "") - .split(/\s+/) - .slice(0, 10) - .join(" ") - // eslint-disable-next-line no-control-regex -- serialized frontmatter must reject embedded control bytes - .replace(/[\\/:*?"<>|\x00-\x1F]/g, "") - .trim() || "Untitled Agent Chat" - : "Untitled Agent Chat"; - } + const firstUser = messages.find((m) => m.sender === USER_SENDER); + const topicForFilename = firstUser + ? firstUser.message + .replace(/\[\[([^\]]+)\]\]/g, "$1") + .replace(/[{}[\]]/g, "") + .split(/\s+/) + .slice(0, 10) + .join(" ") + // eslint-disable-next-line no-control-regex -- serialized frontmatter must reject embedded control bytes + .replace(/[\\/:*?"<>|\x00-\x1F]/g, "") + .trim() || "Untitled Agent Chat" + : "Untitled Agent Chat"; let customFileName = settings.defaultConversationNoteName || "{$date}_{$time}__{$topic}"; const filePrefix = AGENT_FILENAME_PREFIX; diff --git a/src/agentMode/session/AgentSessionManager.test.ts b/src/agentMode/session/AgentSessionManager.test.ts index 615b382fce..ebe5c11ac6 100644 --- a/src/agentMode/session/AgentSessionManager.test.ts +++ b/src/agentMode/session/AgentSessionManager.test.ts @@ -443,10 +443,11 @@ function setupSavedNoteTests() { function savedNoteFixture() { const saveSession = jest.fn(async () => ({ path: "chats/saved.md" })); - const mgr = buildManager({}, { saveSession } as unknown as ConstructorParameters< + const updateTopic = jest.fn(async () => undefined); + const mgr = buildManager({}, { saveSession, updateTopic } as unknown as ConstructorParameters< typeof AgentSessionManager >[2]["persistenceManager"]); - return { mgr, saveSession }; + return { mgr, saveSession, updateTopic }; } describe("AgentSessionManager", () => { @@ -1365,6 +1366,41 @@ describe("AgentSessionManager", () => { expect(mgr.getSessionSourcePath(loaded.internalId)).toBe(file.path); await mgr.shutdown(); }); + + it("reopens a history rename in the tab ahead of an older saved label https://github.com/logancyang/obsidian-copilot/issues/3378", async () => { + const file = mockTFile({ path: "chat/Loaded.md" }); + const loadFile = jest.fn().mockResolvedValue({ + backendId: "opencode", + projectId: GLOBAL_SCOPE, + messages: [{ message: "Loaded" }], + topic: "Current history title", + label: "Older tab title", + }); + const mgr = buildManager({}, { loadFile } as never); + const loaded = await mgr.loadSessionFromHistory(file); + expect(loaded.setLabel).toHaveBeenCalledWith("Current history title"); + await mgr.shutdown(); + }); + }); + + describe("renameSession()", () => { + setupSavedNoteTests(); + it("lets a later tab rename replace a history rename with autosave off https://github.com/logancyang/obsidian-copilot/issues/3378", async () => { + const { mgr, updateTopic } = savedNoteFixture(); + const session = await mgr.createSession(); + getSessionTestHandle(session).setMessages([{ message: "Original prompt" }]); + await mgr.saveActiveSession(); + + await mgr.updateChatTitle("chats/saved.md", "Alpha"); + await mgr.renameSession(session.internalId, "Meaningful name"); + + expect(updateTopic.mock.calls).toEqual([ + ["chats/saved.md", "Alpha"], + ["chats/saved.md", "Meaningful name"], + ]); + expect(session.setLabel).toHaveBeenLastCalledWith("Meaningful name"); + await mgr.shutdown(); + }); }); describe("saveActiveSession()", () => { @@ -3387,6 +3423,7 @@ describe("AgentSessionManager chat history aggregation", () => { interface FakeFrontmatter { epoch?: number; topic?: string; + agentLabel?: string; backendId?: string; sessionId?: string; lastAccessedAt?: number; @@ -3412,6 +3449,7 @@ describe("AgentSessionManager chat history aggregation", () => { files?: Record; /** Hidden-folder files: never in the metadata cache, read via adapter. */ hiddenFiles?: Record; + diskFiles?: Record; listSessions?: jest.Mock; /** When set, the preloader exposes a warm opencode probe proc with this listSessions. */ warmListSessions?: jest.Mock; @@ -3436,7 +3474,7 @@ describe("AgentSessionManager chat history aggregation", () => { "/vault" ) as { read: jest.Mock }; adapter.read.mockImplementation(async (p: string) => { - const content = hiddenByPath[p]; + const content = hiddenByPath[p] ?? opts?.diskFiles?.[p]; if (content === undefined) throw new Error(`ENOENT: ${p}`); return content; }); @@ -3559,6 +3597,83 @@ describe("AgentSessionManager chat history aggregation", () => { expect(native?.backendId).toBe("opencode"); }); + it("shows a saved tab rename when the note has no topic https://github.com/logancyang/obsidian-copilot/issues/3378", async () => { + const { manager } = buildHistoryHarness({ + files: { + "chats/agent__a.md": { + backendId: "opencode", + agentLabel: "Quarterly planning", + }, + }, + }); + expect((await manager.getChatHistoryItems())[0]?.title).toBe("Quarterly planning"); + }); + + it("shows an explicit history title ahead of an older tab label https://github.com/logancyang/obsidian-copilot/issues/3378", async () => { + const { manager } = buildHistoryHarness({ + files: { + "chats/agent__a.md": { + backendId: "opencode", + sessionId: "s1", + topic: "Current name", + agentLabel: "Older name", + }, + }, + }); + expect((await manager.getChatHistoryItems())[0]?.title).toBe("Current name"); + }); + + it("shows a just-renamed title while Obsidian's metadata cache is stale https://github.com/logancyang/obsidian-copilot/issues/3378", async () => { + const path = "chats/agent__a.md"; + const frontmatter = { + backendId: "opencode", + sessionId: "s1", + topic: "Old title", + agentLabel: "Old title", + }; + const { manager } = buildHistoryHarness({ + files: { [path]: frontmatter }, + diskFiles: { + [path]: + '---\nbackendId: opencode\nsessionId: "s1"\ntopic: "New title"\nagentLabel: "New title"\n---\n', + }, + }); + await manager.updateChatTitle(path, "New title"); + expect((await manager.getChatHistoryItems())[0]?.title).toBe("New title"); + frontmatter.topic = "New title"; + frontmatter.agentLabel = "New title"; + expect((await manager.getChatHistoryItems())[0]?.title).toBe("New title"); + }); + + it("falls back to the filename after clearing a saved title while metadata is stale https://github.com/logancyang/obsidian-copilot/issues/3378", async () => { + const path = "chats/agent__a.md"; + const { manager } = buildHistoryHarness({ + files: { + [path]: { + backendId: "opencode", + sessionId: "s1", + topic: "Old title", + agentLabel: "Old title", + }, + }, + diskFiles: { + [path]: '---\nbackendId: opencode\nsessionId: "s1"\ntopic: ""\nagentLabel: ""\n---\n', + }, + }); + await manager.updateChatTitle(path, ""); + expect((await manager.getChatHistoryItems())[0]?.title).toBe("a"); + }); + + it("shows a tab rename in an unindexed hidden save folder https://github.com/logancyang/obsidian-copilot/issues/3378", async () => { + const { manager } = buildHistoryHarness({ + hiddenFiles: { + ".copilot/chats/agent__hidden.md": + '---\nbackendId: opencode\nsessionId: "s1"\nagentLabel: "Hidden title"\n---\n', + }, + }); + expect((await manager.getChatHistoryItems())[0]?.title).toBe("Hidden title"); + }); + it("de-duplicates hidden-folder chats via the adapter frontmatter fallback", async () => { // Hidden save folders (e.g. under the config dir) are never indexed by // the metadata cache; the session ref must come from an adapter read or @@ -3673,6 +3788,28 @@ describe("AgentSessionManager chat history aggregation", () => { expect((await index.getEntry("opencode", "s1"))?.title).toBe("New title"); }); + it("keeps a saved history rename in its native twin https://github.com/logancyang/obsidian-copilot/issues/3378", async () => { + const { manager, index, persistence } = buildHistoryHarness({ + files: { + "chats/agent__a.md": { + backendId: "opencode", + sessionId: "s1", + agentLabel: "Older tab title", + }, + }, + }); + await index.recordSession({ + backendId: "opencode", + sessionId: "s1", + title: "Older tab title", + createdAtMs: 1_000, + lastAccessedAtMs: 2_000, + }); + await manager.updateChatTitle("chats/agent__a.md", "New history title"); + expect(persistence.updateTopic).toHaveBeenCalledWith("chats/agent__a.md", "New history title"); + expect((await index.getEntry("opencode", "s1"))?.title).toBe("New history title"); + }); + it("native rename matches the live session by backend, not session id alone", async () => { const { manager, index } = buildHistoryHarness(); const live = await manager.createSession("opencode"); diff --git a/src/agentMode/session/AgentSessionManager.ts b/src/agentMode/session/AgentSessionManager.ts index 04419744b3..e29e226be4 100644 --- a/src/agentMode/session/AgentSessionManager.ts +++ b/src/agentMode/session/AgentSessionManager.ts @@ -32,7 +32,11 @@ import type { } from "@/context/contextCacheStore"; import { err2String } from "@/utils"; import type { ChatHistoryItem } from "@/components/chat-components/ChatHistoryPopover"; -import { fileToHistoryItem, readChatPathProjectId } from "@/utils/chatHistoryUtils"; +import { + extractChatTitle, + fileToHistoryItem, + readChatPathProjectId, +} from "@/utils/chatHistoryUtils"; import { readFrontmatterViaAdapter } from "@/utils/vaultAdapterUtils"; import { App, FileSystemAdapter, Notice, Platform, TFile } from "obsidian"; import { v4 as uuidv4 } from "uuid"; @@ -49,6 +53,7 @@ import { type MarkdownChatEntry, } from "./chatHistoryMerge"; import { MethodUnsupportedError } from "./errors"; +import { unescapeYamlString } from "./agentChatYaml"; import { replayPersistedMode } from "./replayPersistedMode"; import { applyModeSpec } from "./modeApply"; import { @@ -265,6 +270,7 @@ export interface AgentSessionManagerOptions { * injected so this file stays out of the UI layer. */ export class AgentSessionManager { + private readonly pendingTitleRefreshPaths = new Set(); private backends = new Map(); private starting = new Map>(); private sessions = new Map(); @@ -774,6 +780,10 @@ export class AgentSessionManager { // their native twins. const ref = await this.readSessionRefFromFile(file.path); const item = fileToHistoryItem(this.app, file, tracker); + // A cleared title must use the filename even while the metadata + // cache still holds the old topic and agent label. + // https://github.com/logancyang/obsidian-copilot/issues/3378 + if (ref) item.title = ref.title ?? extractChatTitle(this.app, file, {}); return { item: liveAttentionPaths.has(item.id) ? { ...item, needsAttention: true } : item, backendId: ref?.backendId, @@ -964,16 +974,35 @@ export class AgentSessionManager { if (native) { const index = this.opts.sessionIndex; if (!index) throw new Error("Agent session index is not configured."); + const live = this.findLiveSession(native.backendId, native.sessionId); + const sourcePath = live ? this.getSessionSourcePath(live.internalId) : ""; + // A native row can still represent an open chat with a saved note. + // https://github.com/logancyang/obsidian-copilot/issues/3378 + if (sourcePath) { + await this.opts.persistenceManager?.updateTopic(sourcePath, newTitle); + this.pendingTitleRefreshPaths.add(sourcePath); + } await index.setTitle(native.backendId, native.sessionId, newTitle); // Match the (backendId, sessionId) pair, not the id alone: on a // cross-backend id collision, renaming by id could relabel the wrong // backend's live tab (and its index entry via the label autosave). - this.findLiveSession(native.backendId, native.sessionId)?.setLabel(newTitle); + live?.setLabel(newTitle); return; } const persistence = this.opts.persistenceManager; if (!persistence) throw new Error("Agent chat persistence is not configured."); + const ref = await this.readSessionRefFromFile(fileId); await persistence.updateTopic(fileId, newTitle); + this.pendingTitleRefreshPaths.add(fileId); + // The note, native index, and any open tab must agree after either rename + // entry point. Otherwise the next reopen can restore a stale label. + // https://github.com/logancyang/obsidian-copilot/issues/3378 + if (ref?.backendId && ref.sessionId) { + await this.opts.sessionIndex?.setTitle(ref.backendId, ref.sessionId, newTitle); + } + for (const [internalId, session] of this.sessions) { + if (this.getSessionSourcePath(internalId) === fileId) session.setLabel(newTitle); + } } /** @@ -996,7 +1025,7 @@ export class AgentSessionManager { if (!persistence) throw new Error("Agent chat persistence is not configured."); if (index) { const ref = await this.readSessionRefFromFile(fileId); - if (ref) { + if (ref?.backendId && ref.sessionId) { this.cancelPendingIndexTouch(ref.backendId, ref.sessionId); await index.deleteSession(ref.backendId, ref.sessionId); } @@ -1027,29 +1056,51 @@ export class AgentSessionManager { } /** - * Read the backend session identity from a saved chat's frontmatter, via - * the metadata cache with an adapter fallback for hidden-directory files. - * Returns null when the file predates session-id persistence. + * Read a saved chat's title and backend identity from frontmatter, including + * hidden-directory files that Obsidian does not index in its metadata cache. */ private async readSessionRefFromFile( fileId: string - ): Promise<{ backendId: BackendId; sessionId: string } | null> { - let fm: Record | undefined; + ): Promise<{ backendId?: BackendId; sessionId?: string; title?: string } | null> { + let cached: Record | undefined; const file = this.app.vault.getAbstractFileByPath(fileId); if (file instanceof TFile) { - fm = this.app.metadataCache.getFileCache(file)?.frontmatter; + cached = this.app.metadataCache.getFileCache(file)?.frontmatter; } - if (!fm) { + let disk: Record | null = null; + if (!cached || this.pendingTitleRefreshPaths.has(fileId)) { try { - fm = (await readFrontmatterViaAdapter(this.app, fileId)) ?? undefined; + disk = await readFrontmatterViaAdapter(this.app, fileId); } catch { - return null; + if (!cached) return null; } } - const backendId = typeof fm?.backendId === "string" ? fm.backendId.trim() : ""; - const sessionId = typeof fm?.sessionId === "string" ? fm.sessionId.trim() : ""; - if (!backendId || !sessionId) return null; - return { backendId, sessionId }; + // A rename can be on disk before Obsidian refreshes the cache. Read that + // one note directly until its cache catches up; other rows keep the fast path. + // https://github.com/logancyang/obsidian-copilot/issues/3378 + if ( + disk && + cached && + unescapeYamlString(disk.topic ?? "") === (cached.topic ?? "") && + unescapeYamlString(disk.agentLabel ?? "") === (cached.agentLabel ?? "") + ) { + this.pendingTitleRefreshPaths.delete(fileId); + } + const fm = disk ?? cached; + if (!fm) return null; + const backendId = typeof fm.backendId === "string" ? fm.backendId.trim() : undefined; + const sessionId = typeof fm.sessionId === "string" ? fm.sessionId.trim() : undefined; + const topic = typeof fm.topic === "string" ? fm.topic.trim() : ""; + const agentLabel = typeof fm.agentLabel === "string" ? fm.agentLabel.trim() : ""; + // Saved tabs can predate a history rename, so their label must remain + // visible in the list even when no explicit topic was written yet. + // https://github.com/logancyang/obsidian-copilot/issues/3378 + const title = topic || agentLabel; + return { + backendId, + sessionId, + title: title ? (disk ? unescapeYamlString(title) : title) : undefined, + }; } /** @@ -2849,10 +2900,20 @@ export class AgentSessionManager { } /** Update a session's user-visible label. No-op if `id` is unknown. */ - renameSession(id: string, label: string | null): void { + async renameSession(id: string, label: string | null): Promise { const session = this.sessions.get(id); if (!session) return; - session.setLabel(label); + const sourcePath = this.getSessionSourcePath(id); + // A saved tab must update the same note title that Recent Chats reads. + // https://github.com/logancyang/obsidian-copilot/issues/3378 + if (sourcePath) { + await this.updateChatTitle(sourcePath, label ?? ""); + } else { + session.setLabel(label); + const sessionId = session.getBackendSessionId(); + if (sessionId) + await this.opts.sessionIndex?.setTitle(session.backendId, sessionId, label ?? ""); + } this.notify(); } @@ -3106,7 +3167,10 @@ export class AgentSessionManager { session.loadDisplayMessages(loaded.messages); session.seedSessionUsage(loaded.usage); - if (loaded.label) session.setLabel(loaded.label); + // A history rename is newer than a previously saved tab label. + // https://github.com/logancyang/obsidian-copilot/issues/3378 + if (loaded.topic) session.setLabel(loaded.topic); + else if (loaded.label) session.setLabel(loaded.label); this.getSessionState(session.internalId).source = file; if (loaded.sessionId) { // Keep the native twin's recency in step with the markdown side so the diff --git a/src/agentMode/ui/AgentTabStrip.tsx b/src/agentMode/ui/AgentTabStrip.tsx index b4f727ef2b..762fa985b4 100644 --- a/src/agentMode/ui/AgentTabStrip.tsx +++ b/src/agentMode/ui/AgentTabStrip.tsx @@ -16,6 +16,7 @@ import type { AgentSession, AgentSessionStatus } from "@/agentMode/session/Agent import type { AgentSessionManager } from "@/agentMode/session/AgentSessionManager"; import type { BackendDescriptor } from "@/agentMode/session/types"; import { Loader2, MoreHorizontal, Plus, X } from "lucide-react"; +import { Notice } from "obsidian"; import React from "react"; interface Props { @@ -213,7 +214,10 @@ export const AgentTabStrip: React.FC = ({ manager }) => { onClose={() => handleClose(session.internalId)} onStartRename={() => setRenamingId(session.internalId)} onSubmitRename={(label) => { - manager.renameSession(session.internalId, label); + void manager.renameSession(session.internalId, label).catch((error) => { + logError("[AgentMode] Failed to rename session", error); + new Notice("Failed to rename session."); + }); setRenamingId(null); }} onCancelRename={() => setRenamingId(null)} diff --git a/src/utils/chatHistoryUtils.test.ts b/src/utils/chatHistoryUtils.test.ts index 841434c6c2..ab27bb62c0 100644 --- a/src/utils/chatHistoryUtils.test.ts +++ b/src/utils/chatHistoryUtils.test.ts @@ -1,5 +1,5 @@ /* eslint-disable obsidianmd/no-tfile-tfolder-cast -- test fixtures; not real TFiles */ -import { fileToHistoryItem } from "@/utils/chatHistoryUtils"; +import { extractChatTitle, fileToHistoryItem } from "@/utils/chatHistoryUtils"; import type { RecentUsageManager } from "@/utils/recentUsageManager"; import type { App, TFile } from "obsidian"; @@ -42,28 +42,38 @@ function makeFile(): TFile { } as unknown as TFile; } -describe("fileToHistoryItem projectId extraction", () => { - it("extracts a string projectId from frontmatter", () => { - const app = makeApp({ epoch: 1735732800000, projectId: "proj-123" }); - const item = fileToHistoryItem(app, makeFile(), lastAccessedStub); - expect(item.projectId).toBe("proj-123"); +describe("chatHistoryUtils", () => { + describe("extractChatTitle()", () => { + it("uses fresh frontmatter instead of a stale cached topic", () => { + const app = makeApp({ topic: "Old title" }); + expect(extractChatTitle(app, makeFile(), { topic: "New title" })).toBe("New title"); + expect(extractChatTitle(app, makeFile(), {})).toBe("chat"); + }); }); - it("coerces a numeric projectId (unquoted YAML) to a string", () => { - const app = makeApp({ epoch: 1735732800000, projectId: 123 }); - const item = fileToHistoryItem(app, makeFile(), lastAccessedStub); - expect(item.projectId).toBe("123"); - }); + describe("fileToHistoryItem()", () => { + it("extracts a string projectId from frontmatter", () => { + const app = makeApp({ epoch: 1735732800000, projectId: "proj-123" }); + const item = fileToHistoryItem(app, makeFile(), lastAccessedStub); + expect(item.projectId).toBe("proj-123"); + }); - it("leaves projectId undefined when absent (no GLOBAL_SCOPE default in this layer)", () => { - const app = makeApp({ epoch: 1735732800000 }); - const item = fileToHistoryItem(app, makeFile(), lastAccessedStub); - expect(item.projectId).toBeUndefined(); - }); + it("coerces a numeric projectId (unquoted YAML) to a string", () => { + const app = makeApp({ epoch: 1735732800000, projectId: 123 }); + const item = fileToHistoryItem(app, makeFile(), lastAccessedStub); + expect(item.projectId).toBe("123"); + }); + + it("leaves projectId undefined when absent (no GLOBAL_SCOPE default in this layer)", () => { + const app = makeApp({ epoch: 1735732800000 }); + const item = fileToHistoryItem(app, makeFile(), lastAccessedStub); + expect(item.projectId).toBeUndefined(); + }); - it("treats a blank projectId as undefined", () => { - const app = makeApp({ epoch: 1735732800000, projectId: " " }); - const item = fileToHistoryItem(app, makeFile(), lastAccessedStub); - expect(item.projectId).toBeUndefined(); + it("treats a blank projectId as undefined", () => { + const app = makeApp({ epoch: 1735732800000, projectId: " " }); + const item = fileToHistoryItem(app, makeFile(), lastAccessedStub); + expect(item.projectId).toBeUndefined(); + }); }); }); diff --git a/src/utils/chatHistoryUtils.ts b/src/utils/chatHistoryUtils.ts index 45fc160e8d..74763cbe79 100644 --- a/src/utils/chatHistoryUtils.ts +++ b/src/utils/chatHistoryUtils.ts @@ -104,10 +104,17 @@ export async function filterChatHistoryFiles(app: App, files: TFile[]): Promise< * Extract chat title from a file. * First checks frontmatter.topic, then extracts from filename by removing * project ID prefix, date/time patterns, and normalizing separators. + * @param app Vault app used to read cached frontmatter when no override is supplied. + * @param file Chat note whose title is being resolved. + * @param frontmatterOverride Fresh frontmatter to use when Obsidian's cache may lag a rename. */ -export function extractChatTitle(app: App, file: TFile): string { +export function extractChatTitle( + app: App, + file: TFile, + frontmatterOverride?: Record +): string { // Read the file's front matter - const frontmatter = app.metadataCache.getFileCache(file)?.frontmatter; + const frontmatter = frontmatterOverride ?? app.metadataCache.getFileCache(file)?.frontmatter; // First check if there's a custom topic in frontmatter if (frontmatter?.topic && typeof frontmatter.topic === "string" && frontmatter.topic.trim()) { From e65c4d8c4f5a94b1353b2844307bf329622fed7a Mon Sep 17 00:00:00 2001 From: Logan Yang Date: Sat, 26 Sep 2026 19:45:52 -0700 Subject: [PATCH 2/4] Keep manual-save race from losing session rename --- .../session/AgentSessionManager.test.ts | 28 +++++++++++++++++++ src/agentMode/session/AgentSessionManager.ts | 5 ++++ 2 files changed, 33 insertions(+) diff --git a/src/agentMode/session/AgentSessionManager.test.ts b/src/agentMode/session/AgentSessionManager.test.ts index ebe5c11ac6..3e369ff9d8 100644 --- a/src/agentMode/session/AgentSessionManager.test.ts +++ b/src/agentMode/session/AgentSessionManager.test.ts @@ -1405,6 +1405,34 @@ describe("AgentSessionManager", () => { describe("saveActiveSession()", () => { setupSavedNoteTests(); + it("keeps a tab rename made during the first manual save in the saved note https://github.com/logancyang/obsidian-copilot/issues/3378", async () => { + const { mgr, saveSession, updateTopic } = savedNoteFixture(); + let finishSave!: (result: { path: string }) => void; + saveSession.mockImplementationOnce( + () => + new Promise((resolve) => { + finishSave = resolve; + }) + ); + const session = await mgr.createSession(); + let liveLabel: string | null = null; + jest.spyOn(session, "getLabel").mockImplementation(() => liveLabel); + jest.mocked(session.setLabel).mockImplementation((next) => { + liveLabel = next; + }); + getSessionTestHandle(session).setMessages([{ message: "Original prompt" }]); + + const saving = mgr.saveActiveSession(); + await jest.advanceTimersByTimeAsync(0); + await mgr.renameSession(session.internalId, "Useful title"); + finishSave({ path: "chats/saved.md" }); + await saving; + + expect(updateTopic).toHaveBeenCalledWith("chats/saved.md", "Useful title"); + expect(mgr.getSessionSourcePath(session.internalId)).toBe("chats/saved.md"); + await mgr.shutdown(); + }); + it("persists changes during the first manual save for https://github.com/logancyang/obsidian-copilot/issues/3225", async () => { const { mgr, saveSession } = savedNoteFixture(); let finishSave!: (result: { path: string }) => void; diff --git a/src/agentMode/session/AgentSessionManager.ts b/src/agentMode/session/AgentSessionManager.ts index e29e226be4..82a3fc2371 100644 --- a/src/agentMode/session/AgentSessionManager.ts +++ b/src/agentMode/session/AgentSessionManager.ts @@ -3696,6 +3696,11 @@ export class AgentSessionManager { if (result) { state.source = this.app.vault.getAbstractFileByPath(result.path) ?? result; state.signature = signature; + // A tab can be renamed before its first manual save finishes; that + // rename has no note path to update until this write completes. + // https://github.com/logancyang/obsidian-copilot/issues/3378 + const latestLabel = session.getLabel(); + if (latestLabel !== label) await this.updateChatTitle(result.path, latestLabel ?? ""); // The first successful save changes relative-link resolution in the mounted chat. // https://github.com/Brevilabs/obsidian-copilot-private/issues/539 if (previousSourcePath !== result.path) this.notify(); From e5ea6096df67976a3c2a3b25510e2c592852cc43 Mon Sep 17 00:00:00 2001 From: Logan Yang Date: Sun, 27 Sep 2026 20:19:33 -0700 Subject: [PATCH 3/4] Preserve valid YAML metadata during agent autosave --- .../AgentChatPersistenceManager.test.ts | 34 +++++++++++++++++++ .../session/AgentChatPersistenceManager.ts | 14 +++++--- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/src/agentMode/session/AgentChatPersistenceManager.test.ts b/src/agentMode/session/AgentChatPersistenceManager.test.ts index e6e9d08ee1..94fe1f8ba4 100644 --- a/src/agentMode/session/AgentChatPersistenceManager.test.ts +++ b/src/agentMode/session/AgentChatPersistenceManager.test.ts @@ -13,6 +13,7 @@ jest.mock("obsidian", () => ({ normalizePath: (path: string) => path, Notice: jest.fn(), TFile: jest.fn(), + parseYaml: (content: string) => jest.requireActual("yaml").parse(content), })); jest.mock("@/logger"); jest.mock("@/settings/model", () => ({ @@ -132,6 +133,39 @@ describe("AgentChatPersistenceManager", () => { }); describe("saveSession()", () => { + it("preserves a BOM-prefixed note's title and recency during autosave https://github.com/logancyang/obsidian-copilot/issues/3378", async () => { + const messages = [makeMessage(USER_SENDER, "Original prompt")]; + const saved = await manager.saveSession(messages, "opencode"); + const file = app.files.get(saved!.path)!; + Object.setPrototypeOf(file, TFile.prototype); + file.contents = + "\uFEFF" + + file.contents!.replace("---\n", '---\ntopic: "Useful workflow"\nlastAccessedAt: 123456\n'); + app.metadataCache.getFileCache.mockReturnValue({ + frontmatter: { topic: "Useful workflow", lastAccessedAt: 123456 }, + } as never); + + await manager.saveSession(messages, "opencode", { existingPath: saved!.path }); + + expect(file.contents).toContain('topic: "Useful workflow"'); + expect(file.contents).toContain("lastAccessedAt: 123456"); + }); + + it("preserves a valid folded YAML title during autosave https://github.com/logancyang/obsidian-copilot/issues/3378", async () => { + const messages = [makeMessage(USER_SENDER, "Original prompt")]; + const saved = await manager.saveSession(messages, "opencode"); + const file = app.files.get(saved!.path)!; + Object.setPrototypeOf(file, TFile.prototype); + file.contents = file.contents!.replace("---\n", "---\ntopic: >-\n Useful\n workflow\n"); + app.metadataCache.getFileCache.mockReturnValue({ + frontmatter: { topic: "Useful workflow" }, + } as never); + + await manager.saveSession(messages, "opencode", { existingPath: saved!.path }); + + expect(file.contents).toContain('topic: "Useful workflow"'); + }); + it("keeps a renamed title when metadata cache lags the next save https://github.com/logancyang/obsidian-copilot/issues/3378", async () => { const messages = [makeMessage(USER_SENDER, "Original prompt")]; const saved = await manager.saveSession(messages, "opencode", { label: "Old label" }); diff --git a/src/agentMode/session/AgentChatPersistenceManager.ts b/src/agentMode/session/AgentChatPersistenceManager.ts index a1b879c9d9..a7f02fca47 100644 --- a/src/agentMode/session/AgentChatPersistenceManager.ts +++ b/src/agentMode/session/AgentChatPersistenceManager.ts @@ -25,7 +25,7 @@ import { trashFile, } from "@/utils/vaultAdapterUtils"; import { joinPosix } from "@/utils/pathUtils"; -import { TFile, type App } from "obsidian"; +import { parseYaml, TFile, type App } from "obsidian"; import { Notice } from "obsidian"; import { coerceProjectId, escapeYamlString, unescapeYamlString } from "./agentChatYaml"; import { GLOBAL_SCOPE } from "./scope"; @@ -358,13 +358,17 @@ export class AgentChatPersistenceManager { private readExistingMeta(content: string): ExistingMeta { // A rename can reach disk before Obsidian refreshes metadataCache. // Autosave must round-trip the current topic instead of restoring its - // previous value from that stale cache. + // previous value from that stale cache. Full YAML parsing preserves + // multiline values and externally edited notes with a UTF-8 BOM. // https://github.com/logancyang/obsidian-copilot/issues/3378 - const { frontmatter: current } = this.splitFrontmatter(content); + const yaml = content + .replace(/^\uFEFF/, "") + .match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/)?.[1]; + const current = (parseYaml(yaml ?? "") ?? {}) as Record; const lastAccessed = current.lastAccessedAt ? Number(current.lastAccessedAt) : undefined; return { - topic: current.topic, - label: current.agentLabel, + topic: typeof current.topic === "string" ? current.topic : undefined, + label: typeof current.agentLabel === "string" ? current.agentLabel : undefined, lastAccessedAt: lastAccessed && Number.isFinite(lastAccessed) ? lastAccessed : undefined, sessionId: typeof current.sessionId === "string" ? current.sessionId : undefined, projectId: coerceProjectId(current.projectId), From 6d33d6007b89c16fa129e10312309552cbbbb95a Mon Sep 17 00:00:00 2001 From: Logan Yang Date: Mon, 28 Sep 2026 22:18:01 -0700 Subject: [PATCH 4/4] Preserve saved titles when reopening Agent Mode chats --- .../AgentChatPersistenceManager.test.ts | 49 ++++++++------- .../session/AgentChatPersistenceManager.ts | 62 +++++-------------- .../session/AgentSessionManager.test.ts | 38 +++++++++++- src/agentMode/session/AgentSessionManager.ts | 19 +++--- src/agentMode/session/agentChatYaml.test.ts | 18 ++++++ src/agentMode/session/agentChatYaml.ts | 45 ++++++-------- 6 files changed, 127 insertions(+), 104 deletions(-) create mode 100644 src/agentMode/session/agentChatYaml.test.ts diff --git a/src/agentMode/session/AgentChatPersistenceManager.test.ts b/src/agentMode/session/AgentChatPersistenceManager.test.ts index 94fe1f8ba4..7f0304133a 100644 --- a/src/agentMode/session/AgentChatPersistenceManager.test.ts +++ b/src/agentMode/session/AgentChatPersistenceManager.test.ts @@ -1,6 +1,6 @@ /* eslint-disable obsidianmd/no-tfile-tfolder-cast -- test fixtures; not real TFiles */ import { AI_SENDER, USER_SENDER } from "@/constants"; -import { patchFrontmatter, readFrontmatterViaAdapter } from "@/utils/vaultAdapterUtils"; +import { patchFrontmatter } from "@/utils/vaultAdapterUtils"; import { AgentChatPersistenceManager } from "./AgentChatPersistenceManager"; import { GLOBAL_SCOPE } from "./scope"; import type { AgentChatMessage } from "./types"; @@ -44,7 +44,6 @@ jest.mock("@/utils/vaultAdapterUtils", () => ({ isInVaultCache: jest.fn(() => false), listMarkdownFiles: jest.fn().mockResolvedValue([]), patchFrontmatter: jest.fn(), - readFrontmatterViaAdapter: jest.fn().mockResolvedValue(null), })); interface FakeFile { @@ -132,6 +131,31 @@ describe("AgentChatPersistenceManager", () => { manager = new AgentChatPersistenceManager(app as unknown as App); }); + describe("loadFile()", () => { + it.each([ + ["a folded title", ""], + ["a BOM-prefixed folded title", "\uFEFF"], + ])( + "reopens %s with its original transcript https://github.com/logancyang/obsidian-copilot/issues/3378", + async (_case, prefix) => { + const saved = await manager.saveSession( + [makeMessage(USER_SENDER, "Original prompt")], + "opencode", + { label: "Useful workflow" } + ); + const file = app.files.get(saved!.path)!; + file.contents = + prefix + file.contents!.replace("---\n", "---\ntopic: >-\n Useful\n workflow\n"); + + const loaded = await manager.loadFile(file as unknown as TFile); + + expect(loaded.topic).toBe("Useful workflow"); + expect(loaded.label).toBe("Useful workflow"); + expect(loaded.messages.map((message) => message.message)).toEqual(["Original prompt"]); + } + ); + }); + describe("saveSession()", () => { it("preserves a BOM-prefixed note's title and recency during autosave https://github.com/logancyang/obsidian-copilot/issues/3378", async () => { const messages = [makeMessage(USER_SENDER, "Original prompt")]; @@ -527,12 +551,6 @@ describe("AgentChatPersistenceManager", () => { }); describe("usage frontmatter", () => { - afterEach(() => { - // Restore the default no-metadata behavior for the adapter helper so a - // per-test override (round-trip-on-omit) doesn't leak into other suites. - (readFrontmatterViaAdapter as jest.Mock).mockResolvedValue(null); - }); - it("round-trips a SessionUsage snapshot through save/load", async () => { const messages = [makeMessage(USER_SENDER, "hi")]; const usage = { @@ -560,21 +578,6 @@ describe("AgentChatPersistenceManager", () => { // the mocked prototype so the resave takes the existing-file path (where // usage round-trips) instead of treating it as a brand-new write. Object.setPrototypeOf(app.files.get(first!.path)!, TFile.prototype); - // Mirror production: `readExistingMeta` reads the prior file's frontmatter - // to round-trip fields the caller didn't re-supply. The default mock - // returns null (no metadata), so parse the stored file here — quote-strip - // matches the real adapter helper so the JSON value comes back intact. - (readFrontmatterViaAdapter as jest.Mock).mockImplementation(async (_app, path: string) => { - const raw = app.files.get(path)?.contents ?? ""; - const yaml = raw.match(/^---\n([\s\S]*?)\n---/)?.[1]; - if (!yaml) return null; - const fm: Record = {}; - for (const line of yaml.split("\n")) { - const m = line.match(/^([\w-]+):\s*(.+)/); - if (m) fm[m[1]] = m[2].trim().replace(/^["']|["']$/g, ""); - } - return fm; - }); // A save with no usage option must not drop the stored snapshot. const second = await manager.saveSession(messages, "claude", { existingPath: first!.path, diff --git a/src/agentMode/session/AgentChatPersistenceManager.ts b/src/agentMode/session/AgentChatPersistenceManager.ts index a7f02fca47..7bfcfff689 100644 --- a/src/agentMode/session/AgentChatPersistenceManager.ts +++ b/src/agentMode/session/AgentChatPersistenceManager.ts @@ -25,9 +25,9 @@ import { trashFile, } from "@/utils/vaultAdapterUtils"; import { joinPosix } from "@/utils/pathUtils"; -import { parseYaml, TFile, type App } from "obsidian"; +import { TFile, type App } from "obsidian"; import { Notice } from "obsidian"; -import { coerceProjectId, escapeYamlString, unescapeYamlString } from "./agentChatYaml"; +import { coerceProjectId, escapeYamlString, splitAgentChatFrontmatter } from "./agentChatYaml"; import { GLOBAL_SCOPE } from "./scope"; import type { AgentChatMessage, BackendId, SessionUsage } from "./types"; @@ -205,7 +205,11 @@ export class AgentChatPersistenceManager { const previousContent = (await this.app.vault.adapter.exists(preferredFileName)) ? await this.app.vault.adapter.read(preferredFileName) : ""; - const existingMeta = existingFile ? this.readExistingMeta(previousContent) : {}; + // A rename can reach disk before Obsidian refreshes its metadata cache. + // https://github.com/logancyang/obsidian-copilot/issues/3378 + const existingMeta = existingFile + ? this.readExistingMeta(splitAgentChatFrontmatter(previousContent).frontmatter) + : {}; const preparedMessages = await prepareChatImagesForSave( this.app, @@ -295,18 +299,19 @@ export class AgentChatPersistenceManager { const key = this.app.vault.getAbstractFileByPath(file.path) ?? file.path; this.loadedTranscripts.set(key, content); - const { frontmatter, body } = this.splitFrontmatter(content); - const backendId = (frontmatter.backendId ?? "").trim(); + const { frontmatter, body } = splitAgentChatFrontmatter(content); + const backendId = typeof frontmatter.backendId === "string" ? frontmatter.backendId.trim() : ""; if (!backendId) { throw new Error(`Missing backendId in agent chat frontmatter: ${file.path}`); } - const topic = frontmatter.topic?.trim() || undefined; - const label = frontmatter.agentLabel?.trim() || undefined; - const sessionId = frontmatter.sessionId?.trim() || undefined; + const meta = this.readExistingMeta(frontmatter); + const topic = meta.topic?.trim() || undefined; + const label = meta.label?.trim() || undefined; + const sessionId = meta.sessionId?.trim() || undefined; // HARD CONTRACT: absent/blank projectId → GLOBAL_SCOPE, so legacy `agent__` // chats stay in the global history. Never inferred from the filename. - const projectId = frontmatter.projectId?.trim() || GLOBAL_SCOPE; - const usage = parseUsageJson(frontmatter.usage); + const projectId = meta.projectId ?? GLOBAL_SCOPE; + const usage = meta.usage; const messages = this.parseChatBody(body); logInfo( @@ -355,16 +360,7 @@ export class AgentChatPersistenceManager { return file instanceof TFile ? file : null; } - private readExistingMeta(content: string): ExistingMeta { - // A rename can reach disk before Obsidian refreshes metadataCache. - // Autosave must round-trip the current topic instead of restoring its - // previous value from that stale cache. Full YAML parsing preserves - // multiline values and externally edited notes with a UTF-8 BOM. - // https://github.com/logancyang/obsidian-copilot/issues/3378 - const yaml = content - .replace(/^\uFEFF/, "") - .match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/)?.[1]; - const current = (parseYaml(yaml ?? "") ?? {}) as Record; + private readExistingMeta(current: Record): ExistingMeta { const lastAccessed = current.lastAccessedAt ? Number(current.lastAccessedAt) : undefined; return { topic: typeof current.topic === "string" ? current.topic : undefined, @@ -447,32 +443,6 @@ export class AgentChatPersistenceManager { return messages; } - private splitFrontmatter(content: string): { - frontmatter: Record; - body: string; - } { - const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); - if (!match) return { frontmatter: {}, body: content }; - const frontmatter: Record = {}; - for (const line of match[1].split("\n")) { - const m = line.match(/^(\w+):\s*(.+)/); - if (!m) continue; - const raw = m[2].trim(); - // Unquote and unescape: only double-quoted values were escaped on save, - // so single-quoted / unquoted values are returned verbatim. - let value: string; - if (raw.startsWith('"') && raw.endsWith('"') && raw.length >= 2) { - value = unescapeYamlString(raw.slice(1, -1)); - } else if (raw.startsWith("'") && raw.endsWith("'") && raw.length >= 2) { - value = raw.slice(1, -1); - } else { - value = raw; - } - frontmatter[m[1]] = value; - } - return { frontmatter, body: content.slice(match[0].length).trim() }; - } - private generateFileName( messages: AgentChatMessage[], firstMessageEpoch: number, diff --git a/src/agentMode/session/AgentSessionManager.test.ts b/src/agentMode/session/AgentSessionManager.test.ts index ea7007985d..31ed70a20a 100644 --- a/src/agentMode/session/AgentSessionManager.test.ts +++ b/src/agentMode/session/AgentSessionManager.test.ts @@ -1358,7 +1358,7 @@ describe("AgentSessionManager", () => { await mgr.shutdown(); }); it("uses the loaded conversation file before any save https://github.com/Brevilabs/obsidian-copilot-private/issues/539", async () => { - const file = mockTFile({ path: "chat/Loaded.md" }); + const file = mockTFile({ path: "chat/Loaded.md", basename: "Loaded" }); const loadFile = jest.fn().mockResolvedValue({ backendId: "opencode", projectId: GLOBAL_SCOPE, @@ -1373,7 +1373,7 @@ describe("AgentSessionManager", () => { }); it("reopens a history rename in the tab ahead of an older saved label https://github.com/logancyang/obsidian-copilot/issues/3378", async () => { - const file = mockTFile({ path: "chat/Loaded.md" }); + const file = mockTFile({ path: "chat/Loaded.md", basename: "Loaded" }); const loadFile = jest.fn().mockResolvedValue({ backendId: "opencode", projectId: GLOBAL_SCOPE, @@ -1388,6 +1388,30 @@ describe("AgentSessionManager", () => { }); }); + describe("loadSessionFromHistory()", () => { + it("restores the filename fallback after both saved title fields are cleared https://github.com/logancyang/obsidian-copilot/issues/3378", async () => { + const file = mockTFile({ + path: "chat/agent__Useful_workflow.md", + basename: "agent__Useful_workflow", + }); + const loadFile = jest.fn().mockResolvedValue({ + backendId: "opencode", + projectId: GLOBAL_SCOPE, + messages: [{ message: "Original prompt" }], + topic: undefined, + label: undefined, + }); + const mgr = buildManager({}, { loadFile } as never); + try { + const loaded = await mgr.loadSessionFromHistory(file); + expect(loaded.getLabel()).toBe("Useful workflow"); + expect(loaded.getLabelSource()).toBe("agent"); + } finally { + await mgr.shutdown(); + } + }); + }); + describe("renameSession()", () => { setupSavedNoteTests(); it("lets a later tab rename replace a history rename with autosave off https://github.com/logancyang/obsidian-copilot/issues/3378", async () => { @@ -3801,6 +3825,16 @@ describe("AgentSessionManager chat history aggregation", () => { expect((await manager.getChatHistoryItems())[0]?.title).toBe("a"); }); + it("shows a valid folded YAML title in hidden-folder history https://github.com/logancyang/obsidian-copilot/issues/3378", async () => { + const { manager } = buildHistoryHarness({ + hiddenFiles: { + ".copilot/chats/agent__hidden.md": + "---\nbackendId: opencode\ntopic: >-\n Useful\n workflow\n---\n", + }, + }); + expect((await manager.getChatHistoryItems())[0]?.title).toBe("Useful workflow"); + }); + it("shows a tab rename in an unindexed hidden save folder https://github.com/logancyang/obsidian-copilot/issues/3378", async () => { const { manager } = buildHistoryHarness({ hiddenFiles: { diff --git a/src/agentMode/session/AgentSessionManager.ts b/src/agentMode/session/AgentSessionManager.ts index f6e8880a6d..fa77873435 100644 --- a/src/agentMode/session/AgentSessionManager.ts +++ b/src/agentMode/session/AgentSessionManager.ts @@ -37,7 +37,6 @@ import { fileToHistoryItem, readChatPathProjectId, } from "@/utils/chatHistoryUtils"; -import { readFrontmatterViaAdapter } from "@/utils/vaultAdapterUtils"; import { App, FileSystemAdapter, Notice, Platform, TFile } from "obsidian"; import { v4 as uuidv4 } from "uuid"; import { AgentSession, ATTENTION_TRIGGER_STATUSES, DEFAULT_TITLE_PREFIX } from "./AgentSession"; @@ -53,7 +52,7 @@ import { type MarkdownChatEntry, } from "./chatHistoryMerge"; import { MethodUnsupportedError } from "./errors"; -import { unescapeYamlString } from "./agentChatYaml"; +import { splitAgentChatFrontmatter } from "./agentChatYaml"; import { replayPersistedMode } from "./replayPersistedMode"; import { applyModeSpec } from "./modeApply"; import { @@ -1067,10 +1066,10 @@ export class AgentSessionManager { if (file instanceof TFile) { cached = this.app.metadataCache.getFileCache(file)?.frontmatter; } - let disk: Record | null = null; + let disk: Record | null = null; if (!cached || this.pendingTitleRefreshPaths.has(fileId)) { try { - disk = await readFrontmatterViaAdapter(this.app, fileId); + disk = splitAgentChatFrontmatter(await this.app.vault.adapter.read(fileId)).frontmatter; } catch { if (!cached) return null; } @@ -1081,8 +1080,8 @@ export class AgentSessionManager { if ( disk && cached && - unescapeYamlString(disk.topic ?? "") === (cached.topic ?? "") && - unescapeYamlString(disk.agentLabel ?? "") === (cached.agentLabel ?? "") + (disk.topic ?? "") === (cached.topic ?? "") && + (disk.agentLabel ?? "") === (cached.agentLabel ?? "") ) { this.pendingTitleRefreshPaths.delete(fileId); } @@ -1099,7 +1098,7 @@ export class AgentSessionManager { return { backendId, sessionId, - title: title ? (disk ? unescapeYamlString(title) : title) : undefined, + title: title || undefined, }; } @@ -3167,10 +3166,14 @@ export class AgentSessionManager { session.loadDisplayMessages(loaded.messages); session.seedSessionUsage(loaded.usage); - // A history rename is newer than a previously saved tab label. + // Prefer the explicit history title when legacy tab and history names differ. // https://github.com/logancyang/obsidian-copilot/issues/3378 if (loaded.topic) session.setLabel(loaded.topic); else if (loaded.label) session.setLabel(loaded.label); + // A cleared saved title uses the same fallback in its tab and history. + // Keep the fallback agent-sourced so later agent titles can still replace it. + // https://github.com/logancyang/obsidian-copilot/issues/3378 + else session.restoreLabel(extractChatTitle(this.app, file, {}), "agent"); this.getSessionState(session.internalId).source = file; if (loaded.sessionId) { // Keep the native twin's recency in step with the markdown side so the diff --git a/src/agentMode/session/agentChatYaml.test.ts b/src/agentMode/session/agentChatYaml.test.ts new file mode 100644 index 0000000000..c152e62622 --- /dev/null +++ b/src/agentMode/session/agentChatYaml.test.ts @@ -0,0 +1,18 @@ +import { splitAgentChatFrontmatter } from "./agentChatYaml"; + +describe("agentChatYaml", () => { + describe("splitAgentChatFrontmatter()", () => { + it("reads a BOM-prefixed CRLF note with a folded title and preserves its transcript https://github.com/logancyang/obsidian-copilot/issues/3378", () => { + const content = + "\uFEFF---\r\nbackendId: opencode\r\ntopic: >-\r\n Useful\r\n workflow\r\nprojectId: 123\r\n---\r\nOriginal transcript"; + expect(splitAgentChatFrontmatter(content)).toEqual({ + frontmatter: { backendId: "opencode", topic: "Useful workflow", projectId: 123 }, + body: "Original transcript", + }); + }); + it("preserves a body with no frontmatter without treating its contents as YAML https://github.com/logancyang/obsidian-copilot/issues/3378", () => { + const content = " Original transcript\n---\nMore content\n"; + expect(splitAgentChatFrontmatter(content)).toEqual({ frontmatter: {}, body: content }); + }); + }); +}); diff --git a/src/agentMode/session/agentChatYaml.ts b/src/agentMode/session/agentChatYaml.ts index 2649d83d60..0fca010106 100644 --- a/src/agentMode/session/agentChatYaml.ts +++ b/src/agentMode/session/agentChatYaml.ts @@ -1,8 +1,25 @@ +import { parseYaml } from "obsidian"; + +const EMPTY_FRONTMATTER: Record = Object.freeze({}); + /** - * Tiny YAML-frontmatter helpers shared by `AgentChatPersistenceManager`. Kept - * in their own module so the manager stays focused on persistence flow and - * under the file-size budget. All functions are pure and side-effect free. + * Read complete YAML values for saved-chat loading, autosaving, and history. + * Folded titles and UTF-8 BOMs must survive all three paths. + * https://github.com/logancyang/obsidian-copilot/issues/3378 + * @param content Saved chat note including its frontmatter and transcript. */ +export function splitAgentChatFrontmatter(content: string): { + frontmatter: Record; + body: string; +} { + const normalized = content.replace(/^\uFEFF/, ""); + const match = normalized.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/); + if (!match) return { frontmatter: EMPTY_FRONTMATTER, body: content }; + return { + frontmatter: (parseYaml(match[1]) ?? EMPTY_FRONTMATTER) as Record, + body: normalized.slice(match[0].length).trim(), + }; +} /** * Escape a string for a safe YAML double-quoted value. Strips control chars @@ -19,28 +36,6 @@ export function escapeYamlString(str: string): string { ); } -/** - * Inverse of {@link escapeYamlString} for the values our hand-rolled - * frontmatter parser extracts. Only handles the two escapes we emit (`\\` and - * `\"`). - */ -export function unescapeYamlString(str: string): string { - let out = ""; - for (let i = 0; i < str.length; i++) { - const c = str[i]; - if (c === "\\" && i + 1 < str.length) { - const next = str[i + 1]; - if (next === "\\" || next === '"') { - out += next; - i++; - continue; - } - } - out += c; - } - return out; -} - /** * Coerce a raw frontmatter `projectId` to a trimmed string, or `undefined` * when absent/blank. Obsidian's YAML parser turns an unquoted numeric id into a